diff --git a/Dockerfile b/Dockerfile
index 531fb7a..4c605e3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,7 +12,10 @@ RUN npm run build
# ── Stage 2: Express liefert API + React in einem Container ──────────────────
FROM node:20-alpine
-RUN apk add --no-cache tzdata python3 make g++
+RUN apk add --no-cache tzdata python3 make g++ \
+ chromium nss freetype harfbuzz ca-certificates ttf-freefont
+ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
+ CHROMIUM_PATH=/usr/bin/chromium-browser
WORKDIR /app
COPY backend/package.json ./
RUN npm install --production
diff --git a/backend/package.json b/backend/package.json
index 40bd080..457055b 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -7,6 +7,8 @@
"better-sqlite3": "^9.4.3",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
- "multer": "^1.4.5-lts.1"
+ "multer": "^1.4.5-lts.1",
+ "puppeteer-core": "^22.0.0",
+ "@sparticuz/chromium": "^123.0.0"
}
}
diff --git a/backend/src/tools/paywallkiller/routes.js b/backend/src/tools/paywallkiller/routes.js
new file mode 100644
index 0000000..0a93fcf
--- /dev/null
+++ b/backend/src/tools/paywallkiller/routes.js
@@ -0,0 +1,156 @@
+const express = require('express');
+const router = express.Router();
+const { authenticate } = require('../../middleware/auth');
+const https = require('https');
+const http = require('http');
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+
+// ── Hilfsfunktion: HTTP GET mit Follow-Redirect ────────────────────────────
+function fetchUrl(url, options = {}) {
+ return new Promise((resolve, reject) => {
+ const mod = url.startsWith('https') ? https : http;
+ const req = mod.get(url, {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (compatible; DickenDock/1.0)',
+ ...options.headers,
+ },
+ timeout: 10000,
+ }, (res) => {
+ // Redirects manuell folgen (max 5)
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && (options.redirects || 0) < 5) {
+ const location = res.headers.location.startsWith('http')
+ ? res.headers.location
+ : new URL(res.headers.location, url).href;
+ res.resume();
+ return fetchUrl(location, { ...options, redirects: (options.redirects || 0) + 1 })
+ .then(resolve).catch(reject);
+ }
+ resolve({ statusCode: res.statusCode, url, finalUrl: url, headers: res.headers, res });
+ });
+ req.on('error', reject);
+ req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
+ });
+}
+
+// ── Prüfe ob archive.is-URL existiert (HEAD-ähnlich via GET + sofortiges Abbrechen) ──
+async function checkArchive(archiveUrl) {
+ try {
+ const result = await fetchUrl(archiveUrl);
+ result.res.destroy();
+ // archive.is gibt 200 zurück wenn Archiv existiert, 404 oder Redirect zu / wenn nicht
+ if (result.statusCode === 200) return true;
+ return false;
+ } catch {
+ return false;
+ }
+}
+
+// ── POST /api/tools/paywallkiller/check ───────────────────────────────────
+// Body: { url: "https://..." }
+// Response: { archiveUrl: "https://archive.is/...", type: "newest"|"oldest" }
+router.post('/check', authenticate, async (req, res) => {
+ const { url } = req.body;
+ if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL fehlt' });
+
+ let targetUrl = url.trim();
+ if (!targetUrl.startsWith('http')) targetUrl = 'https://' + targetUrl;
+
+ const newestUrl = `https://archive.is/newest/${targetUrl}`;
+ const oldestUrl = `https://archive.is/oldest/${targetUrl}`;
+
+ try {
+ const newestOk = await checkArchive(newestUrl);
+ if (newestOk) {
+ return res.json({ archiveUrl: newestUrl, type: 'newest' });
+ }
+ const oldestOk = await checkArchive(oldestUrl);
+ if (oldestOk) {
+ return res.json({ archiveUrl: oldestUrl, type: 'oldest' });
+ }
+ return res.status(404).json({ error: 'Kein Archiv für diese URL gefunden' });
+ } catch (err) {
+ return res.status(500).json({ error: 'Fehler beim Prüfen: ' + err.message });
+ }
+});
+
+// ── POST /api/tools/paywallkiller/pdf ─────────────────────────────────────
+// Body: { archiveUrl: "https://archive.is/..." }
+// Response: PDF als Download-Stream, Datei danach gelöscht
+router.post('/pdf', authenticate, async (req, res) => {
+ const { archiveUrl } = req.body;
+ if (!archiveUrl || typeof archiveUrl !== 'string') return res.status(400).json({ error: 'archiveUrl fehlt' });
+ if (!archiveUrl.startsWith('https://archive.is/')) return res.status(400).json({ error: 'Ungültige archive.is URL' });
+
+ let browser;
+ const tmpFile = path.join('/tmp', `pwk_${crypto.randomBytes(8).toString('hex')}.pdf`);
+
+ try {
+ let puppeteer, chromium;
+
+ // Versuche puppeteer-core + @sparticuz/chromium (Alpine-kompatibel)
+ try {
+ puppeteer = require('puppeteer-core');
+ chromium = require('@sparticuz/chromium');
+ } catch {
+ // Fallback: normales puppeteer (falls installiert)
+ try {
+ puppeteer = require('puppeteer');
+ chromium = null;
+ } catch {
+ return res.status(500).json({ error: 'PDF-Rendering nicht verfügbar – puppeteer nicht installiert' });
+ }
+ }
+
+ const launchOptions = chromium
+ ? {
+ args: [
+ ...chromium.args,
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-dev-shm-usage',
+ ],
+ defaultViewport: { width: 1280, height: 900 },
+ executablePath: process.env.CHROMIUM_PATH || await chromium.executablePath(),
+ headless: true,
+ }
+ : { headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] };
+
+ browser = await puppeteer.launch(launchOptions);
+ const page = await browser.newPage();
+
+ await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36');
+ await page.goto(archiveUrl, { waitUntil: 'networkidle2', timeout: 30000 });
+
+ await page.pdf({
+ path: tmpFile,
+ format: 'A4',
+ printBackground: true,
+ margin: { top: '10mm', bottom: '10mm', left: '10mm', right: '10mm' },
+ });
+
+ await browser.close();
+ browser = null;
+
+ const filename = 'archiv_' + Date.now() + '.pdf';
+ res.setHeader('Content-Type', 'application/pdf');
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
+
+ const stream = fs.createReadStream(tmpFile);
+ stream.pipe(res);
+ stream.on('end', () => {
+ fs.unlink(tmpFile, () => {});
+ });
+ stream.on('error', () => {
+ fs.unlink(tmpFile, () => {});
+ });
+
+ } catch (err) {
+ if (browser) try { await browser.close(); } catch {}
+ fs.unlink(tmpFile, () => {});
+ return res.status(500).json({ error: 'PDF-Erstellung fehlgeschlagen: ' + err.message });
+ }
+});
+
+module.exports = router;
diff --git a/frontend/src/icons.jsx b/frontend/src/icons.jsx
index aeb2261..7453d71 100644
--- a/frontend/src/icons.jsx
+++ b/frontend/src/icons.jsx
@@ -153,6 +153,13 @@ export const SkizzeIcon = ({size=20,color='currentColor',sw}) => base(<>
export const WrenchIcon = ({size=20,color='currentColor',sw}) => base(<>