From ddf909502a6515cef83231387de45e167843fea2 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Mon, 20 Jul 2026 09:44:41 +0200 Subject: [PATCH] Browser-Plugin: Multi-User/X-API-Key + Footer-Download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Dockerfile | 1 + extension/README.md | 85 +++++++++++++++++-------------- extension/background.js | 43 ++++++++++------ extension/manifest.json | 13 ++--- extension/popup.html | 7 +-- extension/popup.js | 41 +++++++++------ lib/zipdir.js | 104 ++++++++++++++++++++++++++++++++++++++ server.js | 54 +++++++++++++++++++- views/partials/footer.ejs | 10 ++++ 9 files changed, 278 insertions(+), 80 deletions(-) create mode 100644 lib/zipdir.js diff --git a/Dockerfile b/Dockerfile index 1167bb7..a2b3fb3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ COPY server.js ./ COPY lib ./lib COPY views ./views COPY public ./public +COPY extension ./extension # Persisted SQLite data lives here; make it writable for the non-root user RUN mkdir -p /app/data && chown -R node:node /app diff --git a/extension/README.md b/extension/README.md index 9b37c81..f163b88 100644 --- a/extension/README.md +++ b/extension/README.md @@ -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 -Stellenbeschreibung** einfügt. Ein Klick sendet alle Stelleninformationen an den -NextJobs. Dort wird die Stelle als **Entwurf** angelegt und die KI -erstellt automatisch zugeschnittene Bewerbungsunterlagen (auf Basis der hinterlegten -Vorlagen). +Chromium-Erweiterung (Manifest V3), die **auf jeder Webseite** Stellen +ausliest und an NextJobs (`https://nextjobs.cc`) sendet. Auf Indeed wird +zusätzlich ein Button *direkt neben der Stellenbeschreibung* eingeblendet. +Die Stelle wird im Tracker als **Entwurf** angelegt; die KI erstellt daraus +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) -1. NextJobs starten (Standard: `http://localhost:3000`). -2. In Chrome/Edge/Brave `chrome://extensions` öffnen. -3. **Entwicklermodus** oben rechts aktivieren. -4. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner auswählen. - -## 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. +1. In Chrome/Edge/Brave `chrome://extensions` öffnen. +2. **Entwicklermodus** oben rechts aktivieren. +3. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner + auswählen. +4. API-Key eintragen (siehe oben). ## Verwendung -1. Auf `indeed.com` eine Stelle öffnen. -2. Neben der Überschrift **„Vollständige Stellenbeschreibung"** erscheint der grüne - Button **„An NextJobs senden"**. -3. Klicken → die Stelle wird als Entwurf angelegt, die Unterlagen werden im - Hintergrund generiert. Über den Link „Entwurf öffnen" gelangst du direkt zur - Bewerbung im Tracker. +1. Auf einer Stellenanzeige das Erweiterungs-Symbol klicken. Die Felder werden + automatisch ausgelesen (Schema.org/JSON-LD → OpenGraph → Auswahl/Text). + Text auf der Seite markieren → wird als Beschreibung übernommen. +2. Felder prüfen, ggf. korrigieren und **An Tracker senden** klicken. + Auf Indeed alternativ den grünen Button **„An NextJobs senden"** neben + der Stellenbeschreibung. +3. Über den Link **Entwurf öffnen** gelangst du direkt zur Bewerbung im Tracker. ## Voraussetzungen im Tracker -- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben, Lebenslauf) - hinterlegen — diese dienen der KI als Faktengrundlage. -- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt – z. B. in der `.env`-Datei des Trackers –, - damit die KI-Generierung läuft. +- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben, + Lebenslauf) hinterlegen — diese dienen der KI als Faktengrundlage. +- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt – z. B. in den Einstellungen des + 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) | -|------|---------------------| -| Stelle | `[data-testid="jobsearch-JobInfoHeader-title"]` | -| Firma | `[data-testid="inlineHeader-companyName"]` (im `jobsearch-CompanyInfoContainer`) | -| Ort | `[data-testid="jobsearch-JobInfoHeader-companyLocation"]` | -| Gehalt/Art | `#salaryInfoAndJobType` | -| Stellenbeschreibung | `#jobDescriptionText` | -| Quelle-URL | aus `vjk`/`jk`-Parameter bzw. aktueller URL | +| Berechtigung | Zweck | +|---|---| +| `storage` | API-Key speichern (`chrome.storage.sync`) | +| `scripting` + `activeTab` | Stelle auf der aktiven Seite auslesen (nur auf Klick) | +| `https://nextjobs.cc/*` | Import an den Tracker senden | +| `*://*.indeed.com/*` | Inline-Button auf Indeed einblenden | -Indeed ist eine Single-Page-App; die Erweiterung erkennt Wechsel der geöffneten -Stelle per `MutationObserver` und hält den Button aktuell. +Die Erweiterung liest nur die aktive Seite aus, wenn du sie aufrufst, und +sendet die Daten ausschließlich an `https://nextjobs.cc`. \ No newline at end of file diff --git a/extension/background.js b/extension/background.js index 42e7515..94071f8 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,15 +1,18 @@ -// Service worker: performs the cross-origin POST to the NextJobs. -// Routing the request through the background worker (instead of the content -// script) keeps it independent of the Indeed page's Content-Security-Policy. +// Service worker: performs the cross-origin POST to NextJobs. +// Routing the request through the background worker (instead of a content +// 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) => { - chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => { - let url = (items.trackerUrl || DEFAULT_TRACKER_URL).trim(); - url = url.replace(/\/+$/, ''); // strip trailing slashes - resolve(url); + chrome.storage.sync.get({ apiKey: '' }, (items) => { + resolve((items.apiKey || '').trim()); }); }); } @@ -18,10 +21,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message && message.type === 'IMPORT_JOB') { (async () => { try { - const base = await getTrackerUrl(); - const res = await fetch(base + '/api/indeed-import', { + const apiKey = await getApiKey(); + if (!apiKey) { + sendResponse({ ok: false, needKey: true, error: 'Kein API-Key hinterlegt.' }); + return; + } + const res = await fetch(TRACKER_URL + '/api/indeed-import', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, body: JSON.stringify(message.payload), }); @@ -39,6 +46,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { }); return; } + if (res.status === 401) { + sendResponse({ ok: false, needKey: true, error: 'API-Key abgelehnt (401). Bitte in den Einstellungen prüfen.' }); + return; + } sendResponse({ ok: false, error: (data && data.error) || `Server antwortete mit ${res.status}`, @@ -50,17 +61,17 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { ok: true, id: data.id, message: data.message, - openUrl: data.url ? base + data.url : null, + openUrl: data.url ? TRACKER_URL + data.url : null, }); } catch (err) { sendResponse({ ok: false, error: - 'Konnte den NextJobs nicht erreichen. Läuft er, und ist die ' + - 'Adresse in den Erweiterungs-Einstellungen korrekt? (' + String(err.message) + ')', + 'Konnte NextJobs nicht erreichen (' + 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 } -}); +}); \ No newline at end of file diff --git a/extension/manifest.json b/extension/manifest.json index f90ec43..3c361d0 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,15 +1,12 @@ { "manifest_version": 3, "name": "NextJobs – Stellen-Import", - "version": "1.1.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.", + "version": "2.0.0", + "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"], "host_permissions": [ - "*://*.indeed.com/*", - "http://localhost/*", - "http://127.0.0.1/*", - "http://*/*", - "https://*/*" + "https://nextjobs.cc/*", + "*://*.indeed.com/*" ], "content_scripts": [ { @@ -26,4 +23,4 @@ "default_popup": "popup.html", "default_title": "NextJobs" } -} +} \ No newline at end of file diff --git a/extension/popup.html b/extension/popup.html index 626e68a..7f6c162 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -94,10 +94,11 @@
-
+
Einstellungen - - + + +

Deinen persönlichen Key findest du im Tracker unter Einstellungen → API-Token (X-API-Key). Pro Benutzer eigener Key.

diff --git a/extension/popup.js b/extension/popup.js index 0db3721..633e18d 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -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 statusEl = $('status'); -function normalize(url) { - return (url || '').trim().replace(/\/+$/, ''); -} function setStatus(html, cls) { statusEl.innerHTML = html; statusEl.className = cls || ''; } -// ---- Settings (tracker URL) ------------------------------------------------ +// ---- Settings (API-Key) ---------------------------------------------------- -chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => { - $('trackerUrl').value = items.trackerUrl || DEFAULT_TRACKER_URL; +chrome.storage.sync.get({ apiKey: '' }, (items) => { + $('apiKey').value = items.apiKey || ''; }); $('saveBtn').addEventListener('click', () => { - const url = normalize($('trackerUrl').value) || DEFAULT_TRACKER_URL; - chrome.storage.sync.set({ trackerUrl: url }, () => { - $('trackerUrl').value = url; - setStatus('Einstellungen gespeichert.', 'ok'); + const key = $('apiKey').value.trim(); + chrome.storage.sync.set({ apiKey: key }, () => { + setStatus(key ? 'API-Key gespeichert.' : 'API-Key entfernt.', key ? 'ok' : ''); }); }); $('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 …', ''); try { - const res = await fetch(url + '/api/settings', { method: 'GET' }); - setStatus(res.ok ? 'Verbindung erfolgreich ✓' : 'Erreichbar, aber unerwartete Antwort (' + res.status + ').', res.ok ? 'ok' : 'err'); + const res = await fetch(TRACKER_URL + '/api/v1/health', { + 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) { setStatus('Nicht erreichbar: ' + err.message, 'err'); } @@ -158,6 +162,13 @@ function sendJob(payload) { setStatus('Fehler: ' + chrome.runtime.lastError.message, 'err'); 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) { $('sendBtn').disabled = false; if (confirm(duplicateWarning(resp.matches))) { @@ -198,4 +209,4 @@ $('sendBtn').addEventListener('click', () => { $('rescanBtn').addEventListener('click', scanActivePage); // Auto-extract as soon as the popup opens. -scanActivePage(); +scanActivePage(); \ No newline at end of file diff --git a/lib/zipdir.js b/lib/zipdir.js new file mode 100644 index 0000000..ea64f13 --- /dev/null +++ b/lib/zipdir.js @@ -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 }; \ No newline at end of file diff --git a/server.js b/server.js index e5d6ee5..c0a7517 100644 --- a/server.js +++ b/server.js @@ -50,6 +50,7 @@ const migrate = require('./lib/migrate-multiuser'); const { runMigration, importEnvIntoAdmin } = migrate; const suchprofil = require('./lib/suchprofil'); const suche = require('./lib/suche'); +const { zipDir } = require('./lib/zipdir'); const app = express(); const PORT = process.env.PORT || 3000; @@ -216,6 +217,7 @@ function isPublicPath(p) { if (PUBLIC_PATHS.has(p)) 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 === '/api/indeed-import') return true; // X-API-Key auth (Browser-Plugin) return false; } @@ -258,6 +260,38 @@ function loginOk(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. app.get('/login', (req, res) => { if (req.user) return res.redirect('/'); @@ -2294,7 +2328,7 @@ initializeDatabase().then(async () => { }); // ----- 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 { 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 // (Absender, Betreff, Text), Notizen und Stellenbeschreibung. Liefert je Treffer // die Fundstellen mit Snippet — die Marker darin ersetzt der Client durch . diff --git a/views/partials/footer.ejs b/views/partials/footer.ejs index ce57304..3be30f6 100644 --- a/views/partials/footer.ejs +++ b/views/partials/footer.ejs @@ -3,6 +3,16 @@

NextJobs · © nextjobs.cc <%= new Date().getFullYear() %>

+

+ + + + + Browser-Plugin herunterladen + +