diff --git a/README.md b/README.md index 063f0e9..9b40dfe 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,20 @@ Admin-Passwort für dich, und pro Freund ein frei wählbares Freigabe-Passwort z - Jeder Freund hat ein **Profilbild** und einen **Steckbrief** (klassische Freundebuch-Felder). - Beliebig viele **Blöcke** pro Freund: 🖼️ Bild, 📝 Text, 🎙️ Audio (MP3 o. ä.) – alles wird im jeweiligen Freundes-Ordner gespeichert. +- **Audio-Cover**: Beim Hochladen eines Liedes/einer Sprachnachricht kann optional ein eigenes + Cover-Bild mitgegeben werden. Wird keins hochgeladen, versucht die App automatisch ein in der + MP3 eingebettetes Cover (ID3-Tag) zu extrahieren und zu verwenden. - **Freigabe-Link**: Für jeden Freund kannst du einen Link mit eigenem Passwort erzeugen. Damit kann sich der Freund **nur** in seine eigene Seite einloggen und dort Inhalte hinzufügen/bearbeiten. Die Freigabe kannst du jederzeit als Admin beenden. +- **Öffentliche URL für Freigabe-Links**: Läuft der Server hinter einem Reverse-Proxy, kannst + du im Admin-Bereich unter "Einstellungen" (oder per `PUBLIC_BASE_URL`-Env) eine öffentliche + Basis-URL hinterlegen. Freigabe-Links werden dann darauf basierend erzeugt statt auf der + lokalen Server-Adresse/IP. +- **Export & Import**: Einzelne Freunde oder das gesamte Freundesbuch lassen sich als ZIP + exportieren und wieder importieren (z. B. zum Umziehen auf einen anderen Server oder als + Backup). Der Import eines Freundes/Buchs legt dabei immer neue Einträge an; beim + Buch-Import kann optional vorher alles Bestehende gelöscht werden. - **Freundesbuch-Ansicht**: verspieltes Scrapbook-Layout mit schräg angeordneten Polaroid-Fotos, Washi-Tape-Optik usw. – seitenweise durchblätterbar. - **PDF-Export** des gesamten Freundesbuchs (serverseitig über Puppeteer/Chromium gerendert). @@ -40,8 +51,14 @@ Admin-Passwort für dich, und pro Freund ein frei wählbares Freigabe-Passwort z ``` Die Anwendung läuft danach auf `http://:3000`. -4. Alle Daten (Freunde-Ordner, Fotos, Texte, Audios) landen im lokalen Ordner `./data`, - der als Volume eingebunden ist – so bleiben sie auch bei Container-Updates erhalten. +4. **Reverse-Proxy / öffentliche URL (optional)**: Läuft der Server hinter einem Reverse-Proxy + (z. B. Nginx, Traefik, Caddy), trage entweder `PUBLIC_BASE_URL` in der `docker-compose.yml` + ein, oder setze die URL direkt im Admin-Bereich unter "Einstellungen" (`https://deine-domain.tld`, + ohne abschließenden Slash). Danach zeigen alle neu erzeugten Freigabe-Links auf diese Adresse + statt auf die lokale Server-IP. + +5. Alle Daten (Freunde-Ordner, Fotos, Texte, Audios, Einstellungen) landen im lokalen Ordner + `./data`, der als Volume eingebunden ist – so bleiben sie auch bei Container-Updates erhalten. ## Nutzung @@ -52,6 +69,9 @@ Admin-Passwort für dich, und pro Freund ein frei wählbares Freigabe-Passwort z vergebenen Freigabe-Passwort an und kann **nur** dort etwas eintragen. - **/book** – die verspielte Freundesbuch-Ansicht mit Blätterfunktion und PDF-Export (Button oben rechts). +- Im Admin-Bereich (`/admin`) findest du außerdem die Bereiche **Einstellungen** + (öffentliche URL) und **Sichern & Wiederherstellen** (Export/Import für einzelne Freunde + und für das gesamte Buch als ZIP-Datei) sowie einen Export-Button auf jeder Freundeskarte. ## Hinweise diff --git a/backend/package-lock.json b/backend/package-lock.json index c3001a3..0408a3b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,9 +8,11 @@ "name": "freundesbuch-backend", "version": "1.0.0", "dependencies": { + "adm-zip": "^0.5.16", "cookie-session": "^2.1.0", "express": "^4.19.2", "multer": "^2.0.0", + "node-id3": "^0.2.6", "puppeteer": "^22.15.0" } }, @@ -115,6 +117,15 @@ "node": ">= 0.6" } }, + "node_modules/adm-zip": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1469,6 +1480,27 @@ "node": ">= 0.4.0" } }, + "node_modules/node-id3": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/node-id3/-/node-id3-0.2.9.tgz", + "integrity": "sha512-dSxhuxrkkGVRgUhDHFxdY0pilzOREcodO01HcZWfaRkCaPWGmo0dOgD8ygyL6ln4Iv4cmfRxAWn1WD9bIB9Bhw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.2" + } + }, + "node_modules/node-id3/node_modules/iconv-lite": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz", + "integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/backend/package.json b/backend/package.json index 1a41ecf..e29bf22 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,9 +8,11 @@ "start": "node server.js" }, "dependencies": { + "adm-zip": "^0.5.16", "cookie-session": "^2.1.0", "express": "^4.19.2", "multer": "^2.0.0", + "node-id3": "^0.2.6", "puppeteer": "^22.15.0" } } diff --git a/backend/public/admin/admin.css b/backend/public/admin/admin.css index 97307e3..cf0c1fe 100644 --- a/backend/public/admin/admin.css +++ b/backend/public/admin/admin.css @@ -44,3 +44,16 @@ display: flex; align-items: center; justify-content: center; z-index: 50; padding: 1em; } .modal { width: 100%; max-width: 420px; } + +.transfer-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1.4em; +} +.transfer-row input[type="file"] { margin-top: 0.3em; } + +.saved-msg2 { + margin-top: 0.6em; color: #3a8a5a; font-weight: 700; font-size: 0.9rem; + opacity: 0; transition: opacity 0.3s; +} +.saved-msg2.show { opacity: 1; } diff --git a/backend/public/admin/admin.js b/backend/public/admin/admin.js index 352128f..546d359 100644 --- a/backend/public/admin/admin.js +++ b/backend/public/admin/admin.js @@ -1,5 +1,6 @@ let friends = []; let shareTargetId = null; +let settings = { publicBaseUrl: '' }; async function checkSession() { const res = await fetch('/api/session'); @@ -9,6 +10,37 @@ async function checkSession() { } } +async function loadSettings() { + const res = await fetch('/api/settings'); + if (!res.ok) return; + const data = await res.json(); + settings = data.settings || { publicBaseUrl: '' }; + document.getElementById('publicBaseUrl').value = settings.publicBaseUrl || ''; +} + +document.getElementById('saveSettingsBtn').addEventListener('click', async () => { + const publicBaseUrl = document.getElementById('publicBaseUrl').value.trim(); + const res = await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ publicBaseUrl }), + }); + const data = await res.json(); + if (res.ok) { + settings = data.settings; + const msg = document.getElementById('settingsSaved'); + msg.textContent = 'Gespeichert ✓'; + msg.classList.add('show'); + setTimeout(() => msg.classList.remove('show'), 1800); + } +}); + +function baseUrl() { + return settings.publicBaseUrl && settings.publicBaseUrl.length + ? settings.publicBaseUrl + : window.location.origin; +} + async function loadFriends() { const res = await fetch('/api/friends'); if (res.status === 401) { window.location.href = '/'; return; } @@ -37,6 +69,7 @@ function renderFriends() {
✏️ Bearbeiten +
`; @@ -49,6 +82,9 @@ function renderFriends() { grid.querySelectorAll('[data-delete]').forEach((btn) => { btn.addEventListener('click', () => deleteFriend(btn.dataset.delete)); }); + grid.querySelectorAll('[data-export]').forEach((btn) => { + btn.addEventListener('click', () => window.location.href = `/api/friends/${btn.dataset.export}/export`); + }); } // wir brauchen den vollen Ordnernamen für Bild-URLs; da wir ihn nicht separat liefern, @@ -101,7 +137,7 @@ function openShareModal(id) { const linkBox = document.getElementById('shareLinkBox'); if (f && f.shareEnabled) { linkBox.style.display = 'block'; - document.getElementById('shareLinkInput').value = `${window.location.origin}/share/${id}`; + document.getElementById('shareLinkInput').value = `${baseUrl()}/share/${id}`; } else { linkBox.style.display = 'none'; } @@ -127,7 +163,7 @@ document.getElementById('shareEnableBtn').addEventListener('click', async () => return; } document.getElementById('shareLinkBox').style.display = 'block'; - document.getElementById('shareLinkInput').value = `${window.location.origin}/share/${shareTargetId}`; + document.getElementById('shareLinkInput').value = `${baseUrl()}/share/${shareTargetId}`; await loadFriends(); }); @@ -154,5 +190,56 @@ document.getElementById('logoutBtn').addEventListener('click', async () => { window.location.href = '/'; }); +// --- Sichern & Wiederherstellen --- +function showTransferMsg(text, isError) { + const box = document.getElementById('transferMsg'); + box.innerHTML = isError ? `

${text}

` : `

${text}

`; +} + +document.getElementById('bookExportBtn').addEventListener('click', () => { + window.location.href = '/api/book/export'; +}); + +document.getElementById('bookImportBtn').addEventListener('click', async () => { + const fileInput = document.getElementById('bookImportFile'); + const file = fileInput.files[0]; + if (!file) { showTransferMsg('Bitte zuerst eine ZIP-Datei auswählen.', true); return; } + const replaceAll = document.getElementById('bookImportReplace').checked; + if (replaceAll && !confirm('Wirklich ALLE bestehenden Freunde vor dem Import löschen?')) return; + + const fd = new FormData(); + fd.append('file', file); + fd.append('replaceAll', replaceAll ? 'true' : 'false'); + showTransferMsg('Importiere...', false); + const res = await fetch('/api/book/import', { method: 'POST', body: fd }); + const data = await res.json(); + if (res.ok) { + showTransferMsg(`${data.imported.length} Freund(e) importiert: ${data.imported.join(', ') || '–'}`, false); + fileInput.value = ''; + loadFriends(); + } else { + showTransferMsg(data.error || 'Import fehlgeschlagen.', true); + } +}); + +document.getElementById('friendImportBtn').addEventListener('click', async () => { + const fileInput = document.getElementById('friendImportFile'); + const file = fileInput.files[0]; + if (!file) { showTransferMsg('Bitte zuerst eine ZIP-Datei auswählen.', true); return; } + const fd = new FormData(); + fd.append('file', file); + showTransferMsg('Importiere...', false); + const res = await fetch('/api/friends/import', { method: 'POST', body: fd }); + const data = await res.json(); + if (res.ok) { + showTransferMsg(`"${data.friend.name}" wurde importiert.`, false); + fileInput.value = ''; + loadFriends(); + } else { + showTransferMsg(data.error || 'Import fehlgeschlagen.', true); + } +}); + checkSession(); +loadSettings(); loadFriends(); diff --git a/backend/public/admin/index.html b/backend/public/admin/index.html index 4a20b8b..9902c7d 100644 --- a/backend/public/admin/index.html +++ b/backend/public/admin/index.html @@ -17,7 +17,18 @@
-
+
+

Einstellungen

+ +
+ + +
+

Leer lassen, um automatisch die aktuell aufgerufene Adresse zu verwenden.

+
+
+ +

Neuen Freund anlegen

@@ -26,6 +37,33 @@
+
+

Sichern & Wiederherstellen

+
+
+

Ganzes Buch

+ +
+
+ + + + +
+
+
+
+

Einzelnen Freund importieren

+

Legt einen neuen Freund aus einer zuvor exportierten ZIP-Datei an.

+ + +
+
+
+

Deine Freunde

diff --git a/backend/public/book/book.css b/backend/public/book/book.css index e6c76f4..2337373 100644 --- a/backend/public/book/book.css +++ b/backend/public/book/book.css @@ -38,6 +38,7 @@ .scrap-item.audio-note { width: 220px; padding: 1.2em; background: #e8f3ec; text-align: center; } .scrap-item.audio-note audio { width: 100%; margin-top: 0.6em; } +.audio-cover { width: 100%; height: 150px; object-fit: cover; border-radius: 6px; margin-bottom: 0.6em; } .scrap-caption { font-family: 'Caveat', cursive; font-size: 1.2rem; text-align: center; margin-top: 0.4em; color: var(--ink-soft); } diff --git a/backend/public/book/book.js b/backend/public/book/book.js index acec4b1..314c4ca 100644 --- a/backend/public/book/book.js +++ b/backend/public/book/book.js @@ -76,6 +76,7 @@ function renderPage() { } if (b.type === 'audio') { return `
+ ${b.coverFilename ? `` : ''}
🎙️ ${b.caption ? escapeHtml(b.caption) : 'Sprachnachricht'}
`; diff --git a/backend/public/friend/friend.js b/backend/public/friend/friend.js index 3f98610..864a741 100644 --- a/backend/public/friend/friend.js +++ b/backend/public/friend/friend.js @@ -160,16 +160,19 @@ document.querySelectorAll('.tab-btn').forEach((btn) => { document.getElementById('blockType').value = selectedType; const fileWrap = document.getElementById('blockFileWrap'); const textWrap = document.getElementById('blockTextWrap'); + const coverWrap = document.getElementById('blockCoverWrap'); const fileLabel = document.getElementById('blockFileLabel'); const fileInput = document.getElementById('blockFile'); if (selectedType === 'text') { fileWrap.style.display = 'none'; textWrap.style.display = 'block'; + coverWrap.style.display = 'none'; } else { fileWrap.style.display = 'block'; textWrap.style.display = 'none'; fileLabel.textContent = selectedType === 'image' ? 'Bild auswählen' : 'MP3 auswählen'; fileInput.accept = selectedType === 'image' ? 'image/*' : 'audio/*'; + coverWrap.style.display = selectedType === 'audio' ? 'block' : 'none'; } }); }); @@ -185,12 +188,17 @@ document.getElementById('blockForm').addEventListener('submit', async (e) => { const file = document.getElementById('blockFile').files[0]; if (!file) { alert('Bitte eine Datei auswählen.'); return; } fd.append('file', file); + if (selectedType === 'audio') { + const coverFile = document.getElementById('blockCover').files[0]; + if (coverFile) fd.append('cover', coverFile); + } } const res = await fetch(`/api/friends/${friendId}/blocks`, { method: 'POST', body: fd }); const data = await res.json(); if (res.ok) { currentFriend = data.friend; document.getElementById('blockForm').reset(); + document.getElementById('blockCoverWrap').style.display = selectedType === 'audio' ? 'block' : 'none'; renderBlocks(); } else { alert(data.error || 'Hinzufügen fehlgeschlagen.'); @@ -209,6 +217,9 @@ function renderBlocks() { if (b.type === 'image') { inner += ``; } else if (b.type === 'audio') { + inner += b.coverFilename + ? `` + : ''; inner += ``; } else { inner += ``; diff --git a/backend/public/friend/index.html b/backend/public/friend/index.html index 4fc4f81..9a305c3 100644 --- a/backend/public/friend/index.html +++ b/backend/public/friend/index.html @@ -67,6 +67,11 @@
+ diff --git a/backend/src/pdf.js b/backend/src/pdf.js index b1f7632..59a925d 100644 --- a/backend/src/pdf.js +++ b/backend/src/pdf.js @@ -50,7 +50,9 @@ function buildFriendPageHtml(friend, folder) { `; } if (b.type === 'audio') { + const coverUri = b.coverFilename ? fileToDataUri(path.join(uploadsDir, b.coverFilename)) : null; return `
+ ${coverUri ? `` : ''}
🎵 Audio-Erinnerung${b.caption ? ': ' + esc(b.caption) : ''}
(im Web-Freundesbuch anhörbar)
`; } @@ -135,7 +137,8 @@ function buildFullHtml(friends) { .polaroid { padding: 4mm 4mm 10mm; width: 58mm; } .polaroid img { width: 100%; height: 48mm; object-fit: cover; display: block; } .text-block { width: 58mm; min-height: 40mm; background: #fef7d6; font-family: Georgia, serif; font-size: 12px; } - .audio-block { width: 58mm; min-height: 30mm; background: #e8f3ec; display: flex; align-items: center; justify-content: center; text-align: center; font-size: 13px; } + .audio-block { width: 58mm; min-height: 30mm; background: #e8f3ec; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; font-size: 13px; padding: 3mm; } + .audio-cover { width: 100%; height: 30mm; object-fit: cover; border-radius: 4px; margin-bottom: 2mm; } .cap { font-family: Georgia, serif; font-size: 11px; color: #6b4f8c; margin-top: 2mm; text-align: center; } diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index b3d9e82..70e629e 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -1,13 +1,25 @@ const express = require('express'); +const multer = require('multer'); const { sha256, safeEqual } = require('../utils/hash'); const storage = require('../utils/storage'); const { requireAdmin } = require('../auth'); const { generateBookPdf } = require('../pdf'); +const { exportBookZip, importFriendFromZipBuffer, importBookZipBuffer } = require('../transfer'); const router = express.Router(); const ADMIN_PASSWORD_HASH = (process.env.ADMIN_PASSWORD_HASH || '').trim().toLowerCase(); +const zipUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 300 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + const ok = /zip/i.test(file.mimetype) || /\.zip$/i.test(file.originalname || ''); + if (ok) return cb(null, true); + cb(new Error('Bitte eine ZIP-Datei hochladen.')); + }, +}); + // --- Admin Login --- router.post('/login', (req, res) => { const { password } = req.body || {}; @@ -58,6 +70,18 @@ router.post('/friends', requireAdmin, (req, res) => { res.json({ friend: meta }); }); +// --- Einzelnen Freund aus ZIP importieren (legt einen neuen Freund an) --- +router.post('/friends/import', requireAdmin, zipUpload.single('file'), (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Keine ZIP-Datei erhalten.' }); + try { + const meta = importFriendFromZipBuffer(req.file.buffer); + res.json({ friend: meta }); + } catch (err) { + console.error('Freund-Import fehlgeschlagen:', err); + res.status(400).json({ error: err.message || 'Import fehlgeschlagen.' }); + } +}); + router.delete('/friends/:id', requireAdmin, (req, res) => { const folder = storage.findFolderById(req.params.id); if (!folder) return res.status(404).json({ error: 'Nicht gefunden.' }); @@ -87,6 +111,18 @@ router.post('/friends/:id/share', requireAdmin, (req, res) => { res.json({ friend: meta }); }); +// --- Einstellungen (z. B. öffentliche Basis-URL für Freigabe-Links hinter einem Reverse-Proxy) --- +router.get('/settings', requireAdmin, (req, res) => { + res.json({ settings: storage.readSettings() }); +}); + +router.put('/settings', requireAdmin, (req, res) => { + const current = storage.readSettings(); + const publicBaseUrl = typeof req.body.publicBaseUrl === 'string' ? req.body.publicBaseUrl.trim().replace(/\/+$/, '') : current.publicBaseUrl; + const updated = storage.writeSettings({ ...current, publicBaseUrl }); + res.json({ settings: updated }); +}); + // --- Freundesbuch-Ansicht (aggregiert, nur Admin) --- router.get('/book', requireAdmin, (req, res) => { const friends = storage.getAllFriendsMeta().map((f) => ({ @@ -108,4 +144,30 @@ router.get('/book/pdf', requireAdmin, async (req, res) => { } }); +// --- Ganzes Buch als ZIP exportieren (Backup) --- +router.get('/book/export', requireAdmin, (req, res) => { + try { + const buffer = exportBookZip(); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', 'attachment; filename="freundesbuch-backup.zip"'); + res.send(buffer); + } catch (err) { + console.error('Buch-Export fehlgeschlagen:', err); + res.status(500).json({ error: 'Export fehlgeschlagen.' }); + } +}); + +// --- Ganzes Buch aus ZIP importieren --- +router.post('/book/import', requireAdmin, zipUpload.single('file'), (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Keine ZIP-Datei erhalten.' }); + const replaceAll = req.body.replaceAll === 'true' || req.body.replaceAll === true; + try { + const imported = importBookZipBuffer(req.file.buffer, { replaceAll }); + res.json({ imported }); + } catch (err) { + console.error('Buch-Import fehlgeschlagen:', err); + res.status(400).json({ error: err.message || 'Import fehlgeschlagen.' }); + } +}); + module.exports = router; diff --git a/backend/src/routes/friend.js b/backend/src/routes/friend.js index 9b47324..33dc0ce 100644 --- a/backend/src/routes/friend.js +++ b/backend/src/routes/friend.js @@ -3,9 +3,11 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const multer = require('multer'); +const NodeID3 = require('node-id3'); const { sha256, safeEqual } = require('../utils/hash'); const storage = require('../utils/storage'); const { requireAdminOrFriend } = require('../auth'); +const { exportFriendZip } = require('../transfer'); const router = express.Router(); @@ -95,30 +97,67 @@ router.post('/friends/:id/profileimage', upload.single('image'), (req, res) => { res.json({ friend: meta }); }); -// --- Blöcke: Bild, Text, Audio --- -router.post('/friends/:id/blocks', upload.single('file'), (req, res) => { - const meta = req.friendMeta; - const type = req.body.type; - if (!['image', 'text', 'audio'].includes(type)) { - return res.status(400).json({ error: 'Unbekannter Block-Typ.' }); +// --- Blöcke: Bild, Text, Audio (Audio optional mit eigenem Cover) --- +router.post( + '/friends/:id/blocks', + upload.fields([{ name: 'file', maxCount: 1 }, { name: 'cover', maxCount: 1 }]), + (req, res) => { + const meta = req.friendMeta; + const type = req.body.type; + if (!['image', 'text', 'audio'].includes(type)) { + return res.status(400).json({ error: 'Unbekannter Block-Typ.' }); + } + const file = req.files && req.files.file && req.files.file[0]; + const coverFile = req.files && req.files.cover && req.files.cover[0]; + + const block = { + id: crypto.randomBytes(5).toString('hex'), + type, + caption: req.body.caption || '', + createdAt: new Date().toISOString(), + order: meta.blocks.length, + }; + + if (type === 'text') { + block.content = req.body.content || ''; + } else { + if (!file) return res.status(400).json({ error: 'Keine Datei erhalten.' }); + block.filename = file.filename; + + if (type === 'audio') { + const uploadsDir = path.join(storage.friendFolderPath(req.friendFolder), 'uploads'); + if (coverFile) { + block.coverFilename = coverFile.filename; + } else { + // Kein Cover hochgeladen -> versuchen, ein in der MP3 eingebettetes Cover zu extrahieren + const embedded = extractEmbeddedCover(path.join(uploadsDir, file.filename)); + if (embedded) { + const coverName = `${block.id}-cover.${embedded.ext}`; + fs.writeFileSync(path.join(uploadsDir, coverName), embedded.buffer); + block.coverFilename = coverName; + } + } + } + } + + meta.blocks.push(block); + storage.writeMeta(req.friendFolder, meta); + res.json({ friend: meta }); } - const block = { - id: crypto.randomBytes(5).toString('hex'), - type, - caption: req.body.caption || '', - createdAt: new Date().toISOString(), - order: meta.blocks.length, - }; - if (type === 'text') { - block.content = req.body.content || ''; - } else { - if (!req.file) return res.status(400).json({ error: 'Keine Datei erhalten.' }); - block.filename = req.file.filename; +); + +function extractEmbeddedCover(mp3Path) { + try { + const tags = NodeID3.read(mp3Path); + const image = tags && tags.image; + if (!image || !image.imageBuffer) return null; + const mime = image.mime || 'image/jpeg'; + const ext = mime.includes('png') ? 'png' : 'jpg'; + return { buffer: image.imageBuffer, ext }; + } catch (e) { + return null; // keine oder unlesbare ID3-Tags -> einfach kein Cover } - meta.blocks.push(block); - storage.writeMeta(req.friendFolder, meta); - res.json({ friend: meta }); -}); +} router.put('/friends/:id/blocks/:blockId', (req, res) => { const meta = req.friendMeta; @@ -136,10 +175,9 @@ router.delete('/friends/:id/blocks/:blockId', (req, res) => { const idx = meta.blocks.findIndex((b) => b.id === req.params.blockId); if (idx === -1) return res.status(404).json({ error: 'Block nicht gefunden.' }); const [removed] = meta.blocks.splice(idx, 1); - if (removed.filename) { - const p = path.join(storage.friendFolderPath(req.friendFolder), 'uploads', removed.filename); - fs.rm(p, { force: true }, () => {}); - } + const uploadsDir = path.join(storage.friendFolderPath(req.friendFolder), 'uploads'); + if (removed.filename) fs.rm(path.join(uploadsDir, removed.filename), { force: true }, () => {}); + if (removed.coverFilename) fs.rm(path.join(uploadsDir, removed.coverFilename), { force: true }, () => {}); storage.writeMeta(req.friendFolder, meta); res.json({ friend: meta }); }); @@ -156,4 +194,18 @@ router.post('/friends/:id/blocks/reorder', (req, res) => { res.json({ friend: meta }); }); +// --- Export: einzelnen Freund als ZIP herunterladen --- +router.get('/friends/:id/export', (req, res) => { + try { + const buffer = exportFriendZip(req.friendFolder); + if (!buffer) return res.status(404).json({ error: 'Freund nicht gefunden.' }); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${req.friendFolder}.zip"`); + res.send(buffer); + } catch (err) { + console.error('Export fehlgeschlagen:', err); + res.status(500).json({ error: 'Export fehlgeschlagen.' }); + } +}); + module.exports = router; diff --git a/backend/src/transfer.js b/backend/src/transfer.js new file mode 100644 index 0000000..ed8a23d --- /dev/null +++ b/backend/src/transfer.js @@ -0,0 +1,126 @@ +const fs = require('fs'); +const path = require('path'); +const AdmZip = require('adm-zip'); +const storage = require('./utils/storage'); + +// --- Einzelnen Freund als ZIP exportieren --- +function exportFriendZip(folderName) { + const meta = storage.readMeta(folderName); + if (!meta) return null; + const zip = new AdmZip(); + zip.addFile('meta.json', Buffer.from(JSON.stringify(meta, null, 2), 'utf8')); + + const uploadsDir = path.join(storage.friendFolderPath(folderName), 'uploads'); + if (fs.existsSync(uploadsDir)) { + fs.readdirSync(uploadsDir).forEach((file) => { + zip.addLocalFile(path.join(uploadsDir, file), 'uploads'); + }); + } + return zip.toBuffer(); +} + +// Kopiert die "uploads/*"-Einträge einer Zip-Gruppe in den uploads-Ordner eines neu +// angelegten Freundes. `entries` sind bereits auf diese Gruppe gefiltert. +function copyUploadEntries(entries, uploadsPrefix, targetUploadsDir) { + fs.mkdirSync(targetUploadsDir, { recursive: true }); + entries.forEach((entry) => { + if (entry.isDirectory) return; + if (!entry.entryName.startsWith(uploadsPrefix)) return; + const filename = path.basename(entry.entryName); + if (!filename) return; + fs.writeFileSync(path.join(targetUploadsDir, filename), entry.getData()); + }); +} + +// --- Einzelnen Freund aus ZIP importieren (legt immer einen NEUEN Freund an) --- +function importFriendFromZipBuffer(buffer) { + const zip = new AdmZip(buffer); + const entries = zip.getEntries(); + const metaEntry = entries.find((e) => e.entryName === 'meta.json' || e.entryName.endsWith('/meta.json')); + if (!metaEntry) throw new Error('Keine meta.json in der ZIP-Datei gefunden.'); + const importedMeta = JSON.parse(metaEntry.getData().toString('utf8')); + + const newMeta = storage.createFriend(importedMeta.name || 'Importierter Freund'); + const targetUploadsDir = path.join(storage.friendFolderPath(newMeta.folderName), 'uploads'); + + // uploads-Präfix ermitteln (falls meta.json in einem Unterordner lag) + const prefix = metaEntry.entryName.includes('/') + ? metaEntry.entryName.slice(0, metaEntry.entryName.lastIndexOf('/') + 1) + 'uploads/' + : 'uploads/'; + copyUploadEntries(entries, prefix, targetUploadsDir); + + newMeta.profileImage = importedMeta.profileImage || null; + newMeta.steckbrief = importedMeta.steckbrief || {}; + newMeta.blocks = importedMeta.blocks || []; + // Freigabe wird beim Import bewusst zurückgesetzt (Sicherheit: kein altes Passwort übernehmen) + newMeta.share = { enabled: false, passwordHash: null }; + storage.writeMeta(newMeta.folderName, newMeta); + return newMeta; +} + +// --- Ganzes Buch als ZIP exportieren --- +function exportBookZip() { + const zip = new AdmZip(); + storage.listFriendFolders().forEach((folderName) => { + const meta = storage.readMeta(folderName); + if (!meta) return; + zip.addFile(`${folderName}/meta.json`, Buffer.from(JSON.stringify(meta, null, 2), 'utf8')); + const uploadsDir = path.join(storage.friendFolderPath(folderName), 'uploads'); + if (fs.existsSync(uploadsDir)) { + fs.readdirSync(uploadsDir).forEach((file) => { + zip.addLocalFile(path.join(uploadsDir, file), `${folderName}/uploads`); + }); + } + }); + return zip.toBuffer(); +} + +// --- Ganzes Buch aus ZIP importieren --- +function importBookZipBuffer(buffer, { replaceAll } = {}) { + const zip = new AdmZip(buffer); + const entries = zip.getEntries(); + + const groups = {}; + entries.forEach((entry) => { + const parts = entry.entryName.split('/'); + if (parts.length < 2) return; + const top = parts[0]; + if (!groups[top]) groups[top] = []; + groups[top].push(entry); + }); + + const groupNames = Object.keys(groups); + if (!groupNames.length) throw new Error('Die ZIP-Datei enthält kein erkennbares Freundesbuch.'); + + if (replaceAll) { + storage.listFriendFolders().forEach((folderName) => storage.deleteFriend(folderName)); + } + + const imported = []; + groupNames.forEach((top) => { + const groupEntries = groups[top]; + const metaEntry = groupEntries.find((e) => e.entryName === `${top}/meta.json`); + if (!metaEntry) return; // kein gültiger Freundes-Ordner in der ZIP + const importedMeta = JSON.parse(metaEntry.getData().toString('utf8')); + + const newMeta = storage.createFriend(importedMeta.name || 'Importierter Freund'); + const targetUploadsDir = path.join(storage.friendFolderPath(newMeta.folderName), 'uploads'); + copyUploadEntries(groupEntries, `${top}/uploads/`, targetUploadsDir); + + newMeta.profileImage = importedMeta.profileImage || null; + newMeta.steckbrief = importedMeta.steckbrief || {}; + newMeta.blocks = importedMeta.blocks || []; + newMeta.share = { enabled: false, passwordHash: null }; + storage.writeMeta(newMeta.folderName, newMeta); + imported.push(newMeta.name); + }); + + return imported; +} + +module.exports = { + exportFriendZip, + importFriendFromZipBuffer, + exportBookZip, + importBookZipBuffer, +}; diff --git a/backend/src/utils/storage.js b/backend/src/utils/storage.js index 899de2a..9ea358d 100644 --- a/backend/src/utils/storage.js +++ b/backend/src/utils/storage.js @@ -97,6 +97,34 @@ function getAllFriendsMeta() { .sort((a, b) => a.name.localeCompare(b.name, 'de')); } +// --- Einstellungen (z. B. öffentliche Basis-URL hinter einem Reverse-Proxy) --- +function settingsPath() { + return path.join(DATA_DIR, 'settings.json'); +} + +function readSettings() { + ensureBaseDirs(); + const p = settingsPath(); + let fromFile = {}; + if (fs.existsSync(p)) { + try { + fromFile = JSON.parse(fs.readFileSync(p, 'utf8')); + } catch (e) { + fromFile = {}; + } + } + return { + publicBaseUrl: (process.env.PUBLIC_BASE_URL || '').trim(), + ...fromFile, + }; +} + +function writeSettings(settings) { + ensureBaseDirs(); + fs.writeFileSync(settingsPath(), JSON.stringify(settings, null, 2), 'utf8'); + return settings; +} + module.exports = { DATA_DIR, FRIENDS_DIR, @@ -112,4 +140,6 @@ module.exports = { createFriend, deleteFriend, getAllFriendsMeta, + readSettings, + writeSettings, }; diff --git a/docker-compose.yml b/docker-compose.yml index 1eb6889..feeb763 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,5 +17,9 @@ services: SESSION_SECRET: "bitte-durch-einen-langen-zufaelligen-string-ersetzen" PORT: "3000" DATA_DIR: "/data" + # Optional: öffentliche Basis-URL hinter einem Reverse-Proxy, z. B. https://freundesbuch.example.com + # Wird für die Freigabe-Links verwendet, damit dort nicht die lokale Server-Adresse steht. + # Kann alternativ (oder zusätzlich) im Admin-Bereich unter "Einstellungen" gesetzt werden. + PUBLIC_BASE_URL: "" volumes: - ./data:/data