Browser-Plugin: Multi-User/X-API-Key + Footer-Download
Plugin (Manifest V3) refactored für gehostete Multi-User-Instanz: - Tracker-URL hardcoded https://nextjobs.cc (keine URL-Eingabe mehr) - Authentifizierung ausschließlich über persönlichen REST-API-Key (Header X-API-Key), pro Benutzer in chrome.storage.sync - Server: /api/indeed-import nutzt neuen requireApiKey-Middleware, ordnet Import dem Benutzerkonto mit passendem cfg:API_TOKEN zu Download: /plugin/nextjobs-stellen-import.zip packt extension/ on the fly (dep-freier STORE-Zip in lib/zipdir.js, deterministischer Timestamp), route hinter requireAuth. Footer-Link im App-Footer. Dockerfile kopiert extension/ ins Image. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ COPY server.js ./
|
|||||||
COPY lib ./lib
|
COPY lib ./lib
|
||||||
COPY views ./views
|
COPY views ./views
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
COPY extension ./extension
|
||||||
|
|
||||||
# Persisted SQLite data lives here; make it writable for the non-root user
|
# Persisted SQLite data lives here; make it writable for the non-root user
|
||||||
RUN mkdir -p /app/data && chown -R node:node /app
|
RUN mkdir -p /app/data && chown -R node:node /app
|
||||||
|
|||||||
+48
-37
@@ -1,50 +1,61 @@
|
|||||||
# NextJobs – Indeed Import (Browser-Erweiterung)
|
# NextJobs – Stellen-Import (Browser-Erweiterung)
|
||||||
|
|
||||||
Chromium-Erweiterung (Manifest V3), die auf Indeed einen Button **direkt neben der
|
Chromium-Erweiterung (Manifest V3), die **auf jeder Webseite** Stellen
|
||||||
Stellenbeschreibung** einfügt. Ein Klick sendet alle Stelleninformationen an den
|
ausliest und an NextJobs (`https://nextjobs.cc`) sendet. Auf Indeed wird
|
||||||
NextJobs. Dort wird die Stelle als **Entwurf** angelegt und die KI
|
zusätzlich ein Button *direkt neben der Stellenbeschreibung* eingeblendet.
|
||||||
erstellt automatisch zugeschnittene Bewerbungsunterlagen (auf Basis der hinterlegten
|
Die Stelle wird im Tracker als **Entwurf** angelegt; die KI erstellt daraus
|
||||||
Vorlagen).
|
zugeschnittene Bewerbungsunterlagen (auf Basis der hinterlegten Vorlagen).
|
||||||
|
|
||||||
|
## Multi-User & Authentifizierung
|
||||||
|
|
||||||
|
Die Tracker-Adresse ist fest `https://nextjobs.cc` (hardcodiert). Jeder
|
||||||
|
Benutzer authentifiziert sich mit seinem **persönlichen REST-API-Key**:
|
||||||
|
|
||||||
|
- Im Tracker unter **Einstellungen → API-Token (X-API-Key)** einen Token
|
||||||
|
setzen (falls noch keiner da ist: neu vergeben).
|
||||||
|
- In der Erweiterung auf das Symbol klicken → **Einstellungen** → Token
|
||||||
|
eintragen → **Speichern** + **Verbindung testen**.
|
||||||
|
|
||||||
|
Der Key wird im `X-API-Key`-Header gesendet; der Server ordnet den Import dem
|
||||||
|
Benutzerkonto zu, das diesen Token hinterlegt hat. Ohne (oder mit ungültigem)
|
||||||
|
Key antwortet der Server mit **401**. Der Key wird pro Browser-Profil in der
|
||||||
|
`chrome.storage.sync` gespeichert — wer mehrere Konten verwaltet, kann den Key
|
||||||
|
jederzeit in den Einstellungen wechseln.
|
||||||
|
|
||||||
## Installation (Entwicklermodus)
|
## Installation (Entwicklermodus)
|
||||||
|
|
||||||
1. NextJobs starten (Standard: `http://localhost:3000`).
|
1. In Chrome/Edge/Brave `chrome://extensions` öffnen.
|
||||||
2. In Chrome/Edge/Brave `chrome://extensions` öffnen.
|
2. **Entwicklermodus** oben rechts aktivieren.
|
||||||
3. **Entwicklermodus** oben rechts aktivieren.
|
3. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner
|
||||||
4. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner auswählen.
|
auswählen.
|
||||||
|
4. API-Key eintragen (siehe oben).
|
||||||
## Konfiguration
|
|
||||||
|
|
||||||
Auf das Erweiterungs-Symbol klicken und die **Tracker-Adresse** setzen
|
|
||||||
(z. B. `http://localhost:3000` oder die Adresse deiner Deployment-Instanz).
|
|
||||||
Mit **Verbindung testen** prüfen, ob der Tracker erreichbar ist.
|
|
||||||
|
|
||||||
## Verwendung
|
## Verwendung
|
||||||
|
|
||||||
1. Auf `indeed.com` eine Stelle öffnen.
|
1. Auf einer Stellenanzeige das Erweiterungs-Symbol klicken. Die Felder werden
|
||||||
2. Neben der Überschrift **„Vollständige Stellenbeschreibung"** erscheint der grüne
|
automatisch ausgelesen (Schema.org/JSON-LD → OpenGraph → Auswahl/Text).
|
||||||
Button **„An NextJobs senden"**.
|
Text auf der Seite markieren → wird als Beschreibung übernommen.
|
||||||
3. Klicken → die Stelle wird als Entwurf angelegt, die Unterlagen werden im
|
2. Felder prüfen, ggf. korrigieren und **An Tracker senden** klicken.
|
||||||
Hintergrund generiert. Über den Link „Entwurf öffnen" gelangst du direkt zur
|
Auf Indeed alternativ den grünen Button **„An NextJobs senden"** neben
|
||||||
Bewerbung im Tracker.
|
der Stellenbeschreibung.
|
||||||
|
3. Über den Link **Entwurf öffnen** gelangst du direkt zur Bewerbung im Tracker.
|
||||||
|
|
||||||
## Voraussetzungen im Tracker
|
## Voraussetzungen im Tracker
|
||||||
|
|
||||||
- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben, Lebenslauf)
|
- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben,
|
||||||
hinterlegen — diese dienen der KI als Faktengrundlage.
|
Lebenslauf) hinterlegen — diese dienen der KI als Faktengrundlage.
|
||||||
- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt – z. B. in der `.env`-Datei des Trackers –,
|
- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt – z. B. in den Einstellungen des
|
||||||
damit die KI-Generierung läuft.
|
Benutzers – damit die KI-Generierung läuft.
|
||||||
|
- Ein **API-Token (X-API-Key)** für den benutzten Account.
|
||||||
|
|
||||||
## Ausgelesene Felder
|
## Berechtigungen
|
||||||
|
|
||||||
| Feld | Quelle (Indeed DOM) |
|
| Berechtigung | Zweck |
|
||||||
|------|---------------------|
|
|---|---|
|
||||||
| Stelle | `[data-testid="jobsearch-JobInfoHeader-title"]` |
|
| `storage` | API-Key speichern (`chrome.storage.sync`) |
|
||||||
| Firma | `[data-testid="inlineHeader-companyName"]` (im `jobsearch-CompanyInfoContainer`) |
|
| `scripting` + `activeTab` | Stelle auf der aktiven Seite auslesen (nur auf Klick) |
|
||||||
| Ort | `[data-testid="jobsearch-JobInfoHeader-companyLocation"]` |
|
| `https://nextjobs.cc/*` | Import an den Tracker senden |
|
||||||
| Gehalt/Art | `#salaryInfoAndJobType` |
|
| `*://*.indeed.com/*` | Inline-Button auf Indeed einblenden |
|
||||||
| Stellenbeschreibung | `#jobDescriptionText` |
|
|
||||||
| Quelle-URL | aus `vjk`/`jk`-Parameter bzw. aktueller URL |
|
|
||||||
|
|
||||||
Indeed ist eine Single-Page-App; die Erweiterung erkennt Wechsel der geöffneten
|
Die Erweiterung liest nur die aktive Seite aus, wenn du sie aufrufst, und
|
||||||
Stelle per `MutationObserver` und hält den Button aktuell.
|
sendet die Daten ausschließlich an `https://nextjobs.cc`.
|
||||||
+27
-16
@@ -1,15 +1,18 @@
|
|||||||
// Service worker: performs the cross-origin POST to the NextJobs.
|
// Service worker: performs the cross-origin POST to NextJobs.
|
||||||
// Routing the request through the background worker (instead of the content
|
// Routing the request through the background worker (instead of a content
|
||||||
// script) keeps it independent of the Indeed page's Content-Security-Policy.
|
// script) keeps it independent of the page's Content-Security-Policy.
|
||||||
|
//
|
||||||
|
// Die Tracker-Adresse ist fest (https://nextjobs.cc). Die Authentifizierung
|
||||||
|
// läuft über den persönlichen REST-API-Key (Header X-API-Key), den jeder
|
||||||
|
// Benutzer im Popup hinterlegt — der Server ordnet den Import damit dem
|
||||||
|
// richtigen Benutzerkonto zu. Ohne Key lehnt der Server mit 401 ab.
|
||||||
|
|
||||||
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
|
const TRACKER_URL = 'https://nextjobs.cc';
|
||||||
|
|
||||||
function getTrackerUrl() {
|
function getApiKey() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
|
chrome.storage.sync.get({ apiKey: '' }, (items) => {
|
||||||
let url = (items.trackerUrl || DEFAULT_TRACKER_URL).trim();
|
resolve((items.apiKey || '').trim());
|
||||||
url = url.replace(/\/+$/, ''); // strip trailing slashes
|
|
||||||
resolve(url);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -18,10 +21,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
if (message && message.type === 'IMPORT_JOB') {
|
if (message && message.type === 'IMPORT_JOB') {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const base = await getTrackerUrl();
|
const apiKey = await getApiKey();
|
||||||
const res = await fetch(base + '/api/indeed-import', {
|
if (!apiKey) {
|
||||||
|
sendResponse({ ok: false, needKey: true, error: 'Kein API-Key hinterlegt.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch(TRACKER_URL + '/api/indeed-import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
||||||
body: JSON.stringify(message.payload),
|
body: JSON.stringify(message.payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,6 +46,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (res.status === 401) {
|
||||||
|
sendResponse({ ok: false, needKey: true, error: 'API-Key abgelehnt (401). Bitte in den Einstellungen prüfen.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
sendResponse({
|
sendResponse({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: (data && data.error) || `Server antwortete mit ${res.status}`,
|
error: (data && data.error) || `Server antwortete mit ${res.status}`,
|
||||||
@@ -50,17 +61,17 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
id: data.id,
|
id: data.id,
|
||||||
message: data.message,
|
message: data.message,
|
||||||
openUrl: data.url ? base + data.url : null,
|
openUrl: data.url ? TRACKER_URL + data.url : null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendResponse({
|
sendResponse({
|
||||||
ok: false,
|
ok: false,
|
||||||
error:
|
error:
|
||||||
'Konnte den NextJobs nicht erreichen. Läuft er, und ist die ' +
|
'Konnte NextJobs nicht erreichen (' + String(err.message) + '). ' +
|
||||||
'Adresse in den Erweiterungs-Einstellungen korrekt? (' + String(err.message) + ')',
|
'Prüfe deine Internetverbindung und ob der API-Key in den Einstellungen korrekt ist.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return true; // keep the message channel open for the async response
|
return true; // keep the message channel open for the async response
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "NextJobs – Stellen-Import",
|
"name": "NextJobs – Stellen-Import",
|
||||||
"version": "1.1.0",
|
"version": "2.0.0",
|
||||||
"description": "Erfasst Stellen von jeder Webseite (nicht nur Indeed) über das Erweiterungs-Popup und sendet sie an den NextJobs, der automatisch zugeschnittene Bewerbungsunterlagen erstellt.",
|
"description": "Erfasst Stellen von jeder Webseite (nicht nur Indeed) über das Erweiterungs-Popup und sendet sie an NextJobs (https://nextjobs.cc), der automatisch zugeschnittene Bewerbungsunterlagen erstellt. Authentifizierung über deinen persönlichen REST-API-Key.",
|
||||||
"permissions": ["storage", "scripting", "activeTab"],
|
"permissions": ["storage", "scripting", "activeTab"],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"*://*.indeed.com/*",
|
"https://nextjobs.cc/*",
|
||||||
"http://localhost/*",
|
"*://*.indeed.com/*"
|
||||||
"http://127.0.0.1/*",
|
|
||||||
"http://*/*",
|
|
||||||
"https://*/*"
|
|
||||||
],
|
],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
@@ -26,4 +23,4 @@
|
|||||||
"default_popup": "popup.html",
|
"default_popup": "popup.html",
|
||||||
"default_title": "NextJobs"
|
"default_title": "NextJobs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,10 +94,11 @@
|
|||||||
|
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
|
|
||||||
<details id="settings">
|
<details id="settings" open>
|
||||||
<summary>Einstellungen</summary>
|
<summary>Einstellungen</summary>
|
||||||
<label for="trackerUrl">Tracker-Adresse</label>
|
<label for="apiKey">REST-API-Key</label>
|
||||||
<input type="url" id="trackerUrl" placeholder="http://localhost:3000" />
|
<input type="password" id="apiKey" placeholder="API-Key aus den Tracker-Einstellungen" autocomplete="off" />
|
||||||
|
<p class="hint" style="margin:6px 0 0">Deinen persönlichen Key findest du im Tracker unter <b>Einstellungen → API-Token (X-API-Key)</b>. Pro Benutzer eigener Key.</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="primary" id="saveBtn">Speichern</button>
|
<button class="primary" id="saveBtn">Speichern</button>
|
||||||
<button class="secondary" id="testBtn">Verbindung testen</button>
|
<button class="secondary" id="testBtn">Verbindung testen</button>
|
||||||
|
|||||||
+26
-15
@@ -1,36 +1,40 @@
|
|||||||
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
|
// Tracker-Adresse ist fest (gehostete Instanz). Jeder Benutzer gibt seinen
|
||||||
|
// eigenen REST-API-Key ein (X-API-Key), über den der Server den Import dem
|
||||||
|
// richtigen Benutzerkonto zuordnet — siehe /api/indeed-import serverseitig.
|
||||||
|
const TRACKER_URL = 'https://nextjobs.cc';
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const statusEl = $('status');
|
const statusEl = $('status');
|
||||||
|
|
||||||
function normalize(url) {
|
|
||||||
return (url || '').trim().replace(/\/+$/, '');
|
|
||||||
}
|
|
||||||
function setStatus(html, cls) {
|
function setStatus(html, cls) {
|
||||||
statusEl.innerHTML = html;
|
statusEl.innerHTML = html;
|
||||||
statusEl.className = cls || '';
|
statusEl.className = cls || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Settings (tracker URL) ------------------------------------------------
|
// ---- Settings (API-Key) ----------------------------------------------------
|
||||||
|
|
||||||
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
|
chrome.storage.sync.get({ apiKey: '' }, (items) => {
|
||||||
$('trackerUrl').value = items.trackerUrl || DEFAULT_TRACKER_URL;
|
$('apiKey').value = items.apiKey || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
$('saveBtn').addEventListener('click', () => {
|
$('saveBtn').addEventListener('click', () => {
|
||||||
const url = normalize($('trackerUrl').value) || DEFAULT_TRACKER_URL;
|
const key = $('apiKey').value.trim();
|
||||||
chrome.storage.sync.set({ trackerUrl: url }, () => {
|
chrome.storage.sync.set({ apiKey: key }, () => {
|
||||||
$('trackerUrl').value = url;
|
setStatus(key ? 'API-Key gespeichert.' : 'API-Key entfernt.', key ? 'ok' : '');
|
||||||
setStatus('Einstellungen gespeichert.', 'ok');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$('testBtn').addEventListener('click', async () => {
|
$('testBtn').addEventListener('click', async () => {
|
||||||
const url = normalize($('trackerUrl').value) || DEFAULT_TRACKER_URL;
|
const key = $('apiKey').value.trim();
|
||||||
|
if (!key) { setStatus('Bitte erst API-Key eingeben.', 'err'); return; }
|
||||||
setStatus('Teste Verbindung …', '');
|
setStatus('Teste Verbindung …', '');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url + '/api/settings', { method: 'GET' });
|
const res = await fetch(TRACKER_URL + '/api/v1/health', {
|
||||||
setStatus(res.ok ? 'Verbindung erfolgreich ✓' : 'Erreichbar, aber unerwartete Antwort (' + res.status + ').', res.ok ? 'ok' : 'err');
|
headers: { 'X-API-Key': key },
|
||||||
|
});
|
||||||
|
if (res.ok) setStatus('Verbindung erfolgreich ✓ — Key gültig.', 'ok');
|
||||||
|
else if (res.status === 401) setStatus('Key abgelehnt (401). Bitte API-Key prüfen.', 'err');
|
||||||
|
else setStatus('Erreichbar, aber unerwartete Antwort (' + res.status + ').', 'err');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('Nicht erreichbar: ' + err.message, 'err');
|
setStatus('Nicht erreichbar: ' + err.message, 'err');
|
||||||
}
|
}
|
||||||
@@ -158,6 +162,13 @@ function sendJob(payload) {
|
|||||||
setStatus('Fehler: ' + chrome.runtime.lastError.message, 'err');
|
setStatus('Fehler: ' + chrome.runtime.lastError.message, 'err');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (resp && resp.needKey) {
|
||||||
|
$('sendBtn').disabled = false;
|
||||||
|
setStatus('Bitte erst in den Einstellungen deinen API-Key hinterlegen.', 'err');
|
||||||
|
const s = document.getElementById('settings'); if (s) s.open = true;
|
||||||
|
$('apiKey').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (resp && resp.duplicate) {
|
if (resp && resp.duplicate) {
|
||||||
$('sendBtn').disabled = false;
|
$('sendBtn').disabled = false;
|
||||||
if (confirm(duplicateWarning(resp.matches))) {
|
if (confirm(duplicateWarning(resp.matches))) {
|
||||||
@@ -198,4 +209,4 @@ $('sendBtn').addEventListener('click', () => {
|
|||||||
$('rescanBtn').addEventListener('click', scanActivePage);
|
$('rescanBtn').addEventListener('click', scanActivePage);
|
||||||
|
|
||||||
// Auto-extract as soon as the popup opens.
|
// Auto-extract as soon as the popup opens.
|
||||||
scanActivePage();
|
scanActivePage();
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
// Minimal, dependency-free ZIP (STORE, no compression) for serving the
|
||||||
|
// browser extension as a single downloadable file. The files are tiny, so
|
||||||
|
// skipping DEFLATE keeps the code small and needs no native tooling in the
|
||||||
|
// container (the slim runtime image ships no `zip` binary).
|
||||||
|
//
|
||||||
|
// Returns a Buffer containing a valid .zip whose entries sit at the archive
|
||||||
|
// root (flat — matching "load unpacked extension" on an extracted folder).
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const CRC_TABLE = (() => {
|
||||||
|
const t = new Uint32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
t[n] = c >>> 0;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOS time/date for a fixed deterministic stamp (1980-01-01 00:00:00).
|
||||||
|
// We avoid per-build timestamps so the archive is byte-identical for a given
|
||||||
|
// source state (cache-friendly, reproducible).
|
||||||
|
const DOS_TIME = 0x0000;
|
||||||
|
const DOS_DATE = 0x0021; // 1980-01-01
|
||||||
|
|
||||||
|
function u16(n) { return Buffer.from([n & 0xff, (n >>> 8) & 0xff]); }
|
||||||
|
function u32(n) { return Buffer.from([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]); }
|
||||||
|
|
||||||
|
// Files: array of { name, data: Buffer } (root-relative, no leading slash).
|
||||||
|
function zipFiles(files) {
|
||||||
|
const localParts = [];
|
||||||
|
const centralParts = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
const nameBuf = Buffer.from(f.name, 'utf8');
|
||||||
|
const crc = crc32(f.data);
|
||||||
|
const size = f.data.length;
|
||||||
|
|
||||||
|
const local = Buffer.concat([
|
||||||
|
u32(0x04034b50), // local file header signature
|
||||||
|
u16(20), // version needed to extract
|
||||||
|
u16(0), // general purpose bit flag
|
||||||
|
u16(0), // compression method (0 = store)
|
||||||
|
u16(DOS_TIME), u16(DOS_DATE),
|
||||||
|
u32(crc), u32(size), u32(size),
|
||||||
|
u16(nameBuf.length), u16(0),
|
||||||
|
nameBuf,
|
||||||
|
f.data,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const central = Buffer.concat([
|
||||||
|
u32(0x02014b50), // central directory header signature
|
||||||
|
u16(20), // version made by
|
||||||
|
u16(20), // version needed to extract
|
||||||
|
u16(0), u16(0),
|
||||||
|
u16(DOS_TIME), u16(DOS_DATE),
|
||||||
|
u32(crc), u32(size), u32(size),
|
||||||
|
u16(nameBuf.length), u16(0), u16(0), // name len, extra len, comment len
|
||||||
|
u16(0), // disk number start
|
||||||
|
u16(0), // internal attributes
|
||||||
|
u32(0), // external attributes
|
||||||
|
u32(offset), // local header offset
|
||||||
|
nameBuf,
|
||||||
|
]);
|
||||||
|
|
||||||
|
localParts.push(local);
|
||||||
|
centralParts.push(central);
|
||||||
|
offset += local.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralBuf = Buffer.concat(centralParts);
|
||||||
|
const end = Buffer.concat([
|
||||||
|
u32(0x06054b50), // end of central directory signature
|
||||||
|
u16(0), u16(0), // disk number, disk with CD
|
||||||
|
u16(files.length), u16(files.length), // entries on this disk, total
|
||||||
|
u32(centralBuf.length), u32(offset),
|
||||||
|
u16(0), // comment length
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Buffer.concat([...localParts, centralBuf, end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read every file in a directory (non-recursive, flat) and return a zip Buffer.
|
||||||
|
function zipDir(dir) {
|
||||||
|
const names = fs.readdirSync(dir).sort();
|
||||||
|
const files = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const full = path.join(dir, name);
|
||||||
|
if (!fs.statSync(full).isFile()) continue;
|
||||||
|
files.push({ name, data: fs.readFileSync(full) });
|
||||||
|
}
|
||||||
|
return zipFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { zipDir, zipFiles, crc32 };
|
||||||
@@ -50,6 +50,7 @@ const migrate = require('./lib/migrate-multiuser');
|
|||||||
const { runMigration, importEnvIntoAdmin } = migrate;
|
const { runMigration, importEnvIntoAdmin } = migrate;
|
||||||
const suchprofil = require('./lib/suchprofil');
|
const suchprofil = require('./lib/suchprofil');
|
||||||
const suche = require('./lib/suche');
|
const suche = require('./lib/suche');
|
||||||
|
const { zipDir } = require('./lib/zipdir');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -216,6 +217,7 @@ function isPublicPath(p) {
|
|||||||
if (PUBLIC_PATHS.has(p)) return true;
|
if (PUBLIC_PATHS.has(p)) return true;
|
||||||
if (p === '/health' || p === '/swagger' || p === '/swagger.json' || p === '/api-docs') return true;
|
if (p === '/health' || p === '/swagger' || p === '/swagger.json' || p === '/api-docs') return true;
|
||||||
if (p.startsWith('/api/v1/')) return true; // own X-API-Key auth
|
if (p.startsWith('/api/v1/')) return true; // own X-API-Key auth
|
||||||
|
if (p === '/api/indeed-import') return true; // X-API-Key auth (Browser-Plugin)
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +260,38 @@ function loginOk(req) {
|
|||||||
loginFails.delete(clientIp(req));
|
loginFails.delete(clientIp(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// X-API-Key-Authentifizierung für Endpunkte, die ohne Browser-Session von außen
|
||||||
|
// kommen (heute: das Browser-Plugin über /api/indeed-import). Löst den Header
|
||||||
|
// X-API-Key zum Benutzer auf, dessen cfg:API_TOKEN ihn trägt, und läuft den
|
||||||
|
// Request in dessen Kontext — identisch zu /api/v1 (siehe lib/api.js). Ein
|
||||||
|
// fehlender oder unbekannter Key → 401; ein mehrdeutiger Token → fail closed.
|
||||||
|
async function requireApiKey(req, res, next) {
|
||||||
|
const provided = req.get('X-API-Key');
|
||||||
|
if (!provided) {
|
||||||
|
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const matches = await dbAll(
|
||||||
|
`SELECT u.id, u.username, u.is_admin
|
||||||
|
FROM app_state a JOIN users u ON u.id = a.user_id
|
||||||
|
WHERE a.key = ? AND a.value = ? AND a.value != ''`,
|
||||||
|
['cfg:API_TOKEN', provided]
|
||||||
|
);
|
||||||
|
if (matches.length !== 1) {
|
||||||
|
if (matches.length > 1) console.error('Plugin-Auth: Token ist mehreren Benutzern zugeordnet — Zugriff verweigert.');
|
||||||
|
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||||
|
}
|
||||||
|
const user = matches[0];
|
||||||
|
req.user = user;
|
||||||
|
req.apiUser = user; // kenntzeichnet API- (ggf. nicht Admin-) Kontext
|
||||||
|
await config.ensureLoaded(user.id);
|
||||||
|
userContext.run(user, next);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Plugin-API-Auth-Fehler:', err);
|
||||||
|
res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Login page + form handler.
|
// Login page + form handler.
|
||||||
app.get('/login', (req, res) => {
|
app.get('/login', (req, res) => {
|
||||||
if (req.user) return res.redirect('/');
|
if (req.user) return res.redirect('/');
|
||||||
@@ -2294,7 +2328,7 @@ initializeDatabase().then(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ----- Indeed import (called by the browser extension) -----
|
// ----- Indeed import (called by the browser extension) -----
|
||||||
app.post('/api/indeed-import', async (req, res) => {
|
app.post('/api/indeed-import', requireApiKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url, art } = req.body || {};
|
const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url, art } = req.body || {};
|
||||||
|
|
||||||
@@ -2357,6 +2391,24 @@ initializeDatabase().then(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ----- Browser-Plugin als ZIP herunterladen -----
|
||||||
|
// Packt das Verzeichnis extension/ on the fly (STORE, dep-frei). Liegt hinter
|
||||||
|
// requireAuth — nur angemeldete Nutzer laden das Plugin. Der Dateiname ist
|
||||||
|
// stabil, das Archiv deterministisch (fester Zeitstempel) → cache-freundlich.
|
||||||
|
app.get('/plugin/nextjobs-stellen-import.zip', (req, res) => {
|
||||||
|
try {
|
||||||
|
const dir = path.join(__dirname, 'extension');
|
||||||
|
const zip = zipDir(dir);
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="nextjobs-stellen-import.zip"');
|
||||||
|
res.setHeader('Content-Length', zip.length);
|
||||||
|
res.send(zip);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Plugin-Download:', error);
|
||||||
|
res.status(500).send('Plugin konnte nicht gepackt werden.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Volltextsuche für die Startseite: Bewerbungen samt ihrer E-Mail-Korrespondenz
|
// Volltextsuche für die Startseite: Bewerbungen samt ihrer E-Mail-Korrespondenz
|
||||||
// (Absender, Betreff, Text), Notizen und Stellenbeschreibung. Liefert je Treffer
|
// (Absender, Betreff, Text), Notizen und Stellenbeschreibung. Liefert je Treffer
|
||||||
// die Fundstellen mit Snippet — die Marker darin ersetzt der Client durch <mark>.
|
// die Fundstellen mit Snippet — die Marker darin ersetzt der Client durch <mark>.
|
||||||
|
|||||||
@@ -3,6 +3,16 @@
|
|||||||
<p class="text-xs text-gray-400 dark:text-slate-500">
|
<p class="text-xs text-gray-400 dark:text-slate-500">
|
||||||
NextJobs · © nextjobs.cc <span id="currentYear"><%= new Date().getFullYear() %></span>
|
NextJobs · © nextjobs.cc <span id="currentYear"><%= new Date().getFullYear() %></span>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mt-2 text-xs">
|
||||||
|
<a href="/plugin/nextjobs-stellen-import.zip"
|
||||||
|
class="inline-flex items-center gap-1 text-gray-500 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 underline underline-offset-2"
|
||||||
|
title="Browser-Erweiterung (Chrome/Edge) herunterladen">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V4"></path>
|
||||||
|
</svg>
|
||||||
|
Browser-Plugin herunterladen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user