reverseproxy
This commit is contained in:
24
README.md
24
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).
|
- Jeder Freund hat ein **Profilbild** und einen **Steckbrief** (klassische Freundebuch-Felder).
|
||||||
- Beliebig viele **Blöcke** pro Freund: 🖼️ Bild, 📝 Text, 🎙️ Audio (MP3 o. ä.) – alles wird
|
- Beliebig viele **Blöcke** pro Freund: 🖼️ Bild, 📝 Text, 🎙️ Audio (MP3 o. ä.) – alles wird
|
||||||
im jeweiligen Freundes-Ordner gespeichert.
|
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.
|
- **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
|
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.
|
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
|
- **Freundesbuch-Ansicht**: verspieltes Scrapbook-Layout mit schräg angeordneten
|
||||||
Polaroid-Fotos, Washi-Tape-Optik usw. – seitenweise durchblätterbar.
|
Polaroid-Fotos, Washi-Tape-Optik usw. – seitenweise durchblätterbar.
|
||||||
- **PDF-Export** des gesamten Freundesbuchs (serverseitig über Puppeteer/Chromium gerendert).
|
- **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://<server>:3000`.
|
Die Anwendung läuft danach auf `http://<server>:3000`.
|
||||||
|
|
||||||
4. Alle Daten (Freunde-Ordner, Fotos, Texte, Audios) landen im lokalen Ordner `./data`,
|
4. **Reverse-Proxy / öffentliche URL (optional)**: Läuft der Server hinter einem Reverse-Proxy
|
||||||
der als Volume eingebunden ist – so bleiben sie auch bei Container-Updates erhalten.
|
(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
|
## 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.
|
vergebenen Freigabe-Passwort an und kann **nur** dort etwas eintragen.
|
||||||
- **/book** – die verspielte Freundesbuch-Ansicht mit Blätterfunktion und PDF-Export
|
- **/book** – die verspielte Freundesbuch-Ansicht mit Blätterfunktion und PDF-Export
|
||||||
(Button oben rechts).
|
(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
|
## Hinweise
|
||||||
|
|
||||||
|
|||||||
32
backend/package-lock.json
generated
32
backend/package-lock.json
generated
@@ -8,9 +8,11 @@
|
|||||||
"name": "freundesbuch-backend",
|
"name": "freundesbuch-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"adm-zip": "^0.5.16",
|
||||||
"cookie-session": "^2.1.0",
|
"cookie-session": "^2.1.0",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.0.0",
|
||||||
|
"node-id3": "^0.2.6",
|
||||||
"puppeteer": "^22.15.0"
|
"puppeteer": "^22.15.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -115,6 +117,15 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
@@ -1469,6 +1480,27 @@
|
|||||||
"node": ">= 0.4.0"
|
"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": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
"start": "node server.js"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"adm-zip": "^0.5.16",
|
||||||
"cookie-session": "^2.1.0",
|
"cookie-session": "^2.1.0",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.0.0",
|
||||||
|
"node-id3": "^0.2.6",
|
||||||
"puppeteer": "^22.15.0"
|
"puppeteer": "^22.15.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,3 +44,16 @@
|
|||||||
display: flex; align-items: center; justify-content: center; z-index: 50; padding: 1em;
|
display: flex; align-items: center; justify-content: center; z-index: 50; padding: 1em;
|
||||||
}
|
}
|
||||||
.modal { width: 100%; max-width: 420px; }
|
.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; }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
let friends = [];
|
let friends = [];
|
||||||
let shareTargetId = null;
|
let shareTargetId = null;
|
||||||
|
let settings = { publicBaseUrl: '' };
|
||||||
|
|
||||||
async function checkSession() {
|
async function checkSession() {
|
||||||
const res = await fetch('/api/session');
|
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() {
|
async function loadFriends() {
|
||||||
const res = await fetch('/api/friends');
|
const res = await fetch('/api/friends');
|
||||||
if (res.status === 401) { window.location.href = '/'; return; }
|
if (res.status === 401) { window.location.href = '/'; return; }
|
||||||
@@ -37,6 +69,7 @@ function renderFriends() {
|
|||||||
<div class="friend-actions">
|
<div class="friend-actions">
|
||||||
<a class="btn" href="/friend/${f.id}">✏️ Bearbeiten</a>
|
<a class="btn" href="/friend/${f.id}">✏️ Bearbeiten</a>
|
||||||
<button class="secondary" data-share="${f.id}">🔗 Freigabe</button>
|
<button class="secondary" data-share="${f.id}">🔗 Freigabe</button>
|
||||||
|
<button class="secondary" data-export="${f.id}">⬇️ Export</button>
|
||||||
<button class="danger" data-delete="${f.id}">🗑️</button>
|
<button class="danger" data-delete="${f.id}">🗑️</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -49,6 +82,9 @@ function renderFriends() {
|
|||||||
grid.querySelectorAll('[data-delete]').forEach((btn) => {
|
grid.querySelectorAll('[data-delete]').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => deleteFriend(btn.dataset.delete));
|
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,
|
// 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');
|
const linkBox = document.getElementById('shareLinkBox');
|
||||||
if (f && f.shareEnabled) {
|
if (f && f.shareEnabled) {
|
||||||
linkBox.style.display = 'block';
|
linkBox.style.display = 'block';
|
||||||
document.getElementById('shareLinkInput').value = `${window.location.origin}/share/${id}`;
|
document.getElementById('shareLinkInput').value = `${baseUrl()}/share/${id}`;
|
||||||
} else {
|
} else {
|
||||||
linkBox.style.display = 'none';
|
linkBox.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -127,7 +163,7 @@ document.getElementById('shareEnableBtn').addEventListener('click', async () =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.getElementById('shareLinkBox').style.display = 'block';
|
document.getElementById('shareLinkBox').style.display = 'block';
|
||||||
document.getElementById('shareLinkInput').value = `${window.location.origin}/share/${shareTargetId}`;
|
document.getElementById('shareLinkInput').value = `${baseUrl()}/share/${shareTargetId}`;
|
||||||
await loadFriends();
|
await loadFriends();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,5 +190,56 @@ document.getElementById('logoutBtn').addEventListener('click', async () => {
|
|||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Sichern & Wiederherstellen ---
|
||||||
|
function showTransferMsg(text, isError) {
|
||||||
|
const box = document.getElementById('transferMsg');
|
||||||
|
box.innerHTML = isError ? `<p class="error-msg">${text}</p>` : `<p class="saved-msg2 show">${text}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
checkSession();
|
||||||
|
loadSettings();
|
||||||
loadFriends();
|
loadFriends();
|
||||||
|
|||||||
@@ -17,7 +17,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<section class="card" id="createCard">
|
<section class="card" id="settingsCard">
|
||||||
|
<h2>Einstellungen</h2>
|
||||||
|
<label for="publicBaseUrl">Öffentliche URL (für Freigabe-Links hinter einem Reverse-Proxy)</label>
|
||||||
|
<div class="inline-form">
|
||||||
|
<input type="text" id="publicBaseUrl" placeholder="https://freundesbuch.example.com" />
|
||||||
|
<button type="button" id="saveSettingsBtn">Speichern</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin-top:0.4em;">Leer lassen, um automatisch die aktuell aufgerufene Adresse zu verwenden.</p>
|
||||||
|
<div id="settingsSaved" class="saved-msg2"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="createCard" style="margin-top:1.4em;">
|
||||||
<h2>Neuen Freund anlegen</h2>
|
<h2>Neuen Freund anlegen</h2>
|
||||||
<form id="createForm" class="inline-form">
|
<form id="createForm" class="inline-form">
|
||||||
<input type="text" id="newName" placeholder="Name des Freundes" required />
|
<input type="text" id="newName" placeholder="Name des Freundes" required />
|
||||||
@@ -26,6 +37,33 @@
|
|||||||
<div id="createErr"></div>
|
<div id="createErr"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="transferCard" style="margin-top:1.4em;">
|
||||||
|
<h2>Sichern & Wiederherstellen</h2>
|
||||||
|
<div class="transfer-row">
|
||||||
|
<div>
|
||||||
|
<h3 style="font-size:1.2rem;">Ganzes Buch</h3>
|
||||||
|
<button type="button" id="bookExportBtn" class="secondary">⬇️ Buch exportieren (ZIP)</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="bookImportFile">Buch importieren</label>
|
||||||
|
<input type="file" id="bookImportFile" accept=".zip" />
|
||||||
|
<label style="font-weight:normal; display:flex; align-items:center; gap:0.4em; margin-top:0.4em;">
|
||||||
|
<input type="checkbox" id="bookImportReplace" style="width:auto;" />
|
||||||
|
Bestehende Freunde vorher löschen
|
||||||
|
</label>
|
||||||
|
<button type="button" id="bookImportBtn" class="secondary" style="margin-top:0.4em;">⬆️ Importieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr style="border:none; border-top:1px solid var(--line); margin:1.2em 0;" />
|
||||||
|
<div>
|
||||||
|
<h3 style="font-size:1.2rem;">Einzelnen Freund importieren</h3>
|
||||||
|
<p class="muted">Legt einen neuen Freund aus einer zuvor exportierten ZIP-Datei an.</p>
|
||||||
|
<input type="file" id="friendImportFile" accept=".zip" />
|
||||||
|
<button type="button" id="friendImportBtn" class="secondary" style="margin-top:0.4em;">⬆️ Importieren</button>
|
||||||
|
</div>
|
||||||
|
<div id="transferMsg"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section style="margin-top:2em;">
|
<section style="margin-top:2em;">
|
||||||
<h2>Deine Freunde</h2>
|
<h2>Deine Freunde</h2>
|
||||||
<div id="friendGrid" class="friend-grid"></div>
|
<div id="friendGrid" class="friend-grid"></div>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
|
|
||||||
.scrap-item.audio-note { width: 220px; padding: 1.2em; background: #e8f3ec; text-align: center; }
|
.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; }
|
.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); }
|
.scrap-caption { font-family: 'Caveat', cursive; font-size: 1.2rem; text-align: center; margin-top: 0.4em; color: var(--ink-soft); }
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ function renderPage() {
|
|||||||
}
|
}
|
||||||
if (b.type === 'audio') {
|
if (b.type === 'audio') {
|
||||||
return `<div class="scrap-item audio-note" style="transform: rotate(${rot * 0.5}deg);">
|
return `<div class="scrap-item audio-note" style="transform: rotate(${rot * 0.5}deg);">
|
||||||
|
${b.coverFilename ? `<img class="audio-cover" src="${uploadUrl(f.folderName, b.coverFilename)}" />` : ''}
|
||||||
<div>🎙️ ${b.caption ? escapeHtml(b.caption) : 'Sprachnachricht'}</div>
|
<div>🎙️ ${b.caption ? escapeHtml(b.caption) : 'Sprachnachricht'}</div>
|
||||||
<audio controls src="${uploadUrl(f.folderName, b.filename)}"></audio>
|
<audio controls src="${uploadUrl(f.folderName, b.filename)}"></audio>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -160,16 +160,19 @@ document.querySelectorAll('.tab-btn').forEach((btn) => {
|
|||||||
document.getElementById('blockType').value = selectedType;
|
document.getElementById('blockType').value = selectedType;
|
||||||
const fileWrap = document.getElementById('blockFileWrap');
|
const fileWrap = document.getElementById('blockFileWrap');
|
||||||
const textWrap = document.getElementById('blockTextWrap');
|
const textWrap = document.getElementById('blockTextWrap');
|
||||||
|
const coverWrap = document.getElementById('blockCoverWrap');
|
||||||
const fileLabel = document.getElementById('blockFileLabel');
|
const fileLabel = document.getElementById('blockFileLabel');
|
||||||
const fileInput = document.getElementById('blockFile');
|
const fileInput = document.getElementById('blockFile');
|
||||||
if (selectedType === 'text') {
|
if (selectedType === 'text') {
|
||||||
fileWrap.style.display = 'none';
|
fileWrap.style.display = 'none';
|
||||||
textWrap.style.display = 'block';
|
textWrap.style.display = 'block';
|
||||||
|
coverWrap.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
fileWrap.style.display = 'block';
|
fileWrap.style.display = 'block';
|
||||||
textWrap.style.display = 'none';
|
textWrap.style.display = 'none';
|
||||||
fileLabel.textContent = selectedType === 'image' ? 'Bild auswählen' : 'MP3 auswählen';
|
fileLabel.textContent = selectedType === 'image' ? 'Bild auswählen' : 'MP3 auswählen';
|
||||||
fileInput.accept = selectedType === 'image' ? 'image/*' : 'audio/*';
|
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];
|
const file = document.getElementById('blockFile').files[0];
|
||||||
if (!file) { alert('Bitte eine Datei auswählen.'); return; }
|
if (!file) { alert('Bitte eine Datei auswählen.'); return; }
|
||||||
fd.append('file', file);
|
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 res = await fetch(`/api/friends/${friendId}/blocks`, { method: 'POST', body: fd });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
currentFriend = data.friend;
|
currentFriend = data.friend;
|
||||||
document.getElementById('blockForm').reset();
|
document.getElementById('blockForm').reset();
|
||||||
|
document.getElementById('blockCoverWrap').style.display = selectedType === 'audio' ? 'block' : 'none';
|
||||||
renderBlocks();
|
renderBlocks();
|
||||||
} else {
|
} else {
|
||||||
alert(data.error || 'Hinzufügen fehlgeschlagen.');
|
alert(data.error || 'Hinzufügen fehlgeschlagen.');
|
||||||
@@ -209,6 +217,9 @@ function renderBlocks() {
|
|||||||
if (b.type === 'image') {
|
if (b.type === 'image') {
|
||||||
inner += `<img src="${uploadUrl(b.filename)}" />`;
|
inner += `<img src="${uploadUrl(b.filename)}" />`;
|
||||||
} else if (b.type === 'audio') {
|
} else if (b.type === 'audio') {
|
||||||
|
inner += b.coverFilename
|
||||||
|
? `<img src="${uploadUrl(b.coverFilename)}" style="margin-bottom:0.4em;" />`
|
||||||
|
: '';
|
||||||
inner += `<audio controls src="${uploadUrl(b.filename)}"></audio>`;
|
inner += `<audio controls src="${uploadUrl(b.filename)}"></audio>`;
|
||||||
} else {
|
} else {
|
||||||
inner += `<textarea data-content-for="${b.id}">${escapeHtml(b.content)}</textarea>`;
|
inner += `<textarea data-content-for="${b.id}">${escapeHtml(b.content)}</textarea>`;
|
||||||
|
|||||||
@@ -67,6 +67,11 @@
|
|||||||
<label>Text</label>
|
<label>Text</label>
|
||||||
<textarea id="blockContent" placeholder="Schreib etwas Schönes..."></textarea>
|
<textarea id="blockContent" placeholder="Schreib etwas Schönes..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="blockCoverWrap" style="display:none; margin-top:0.8em;">
|
||||||
|
<label>Cover-Bild (optional)</label>
|
||||||
|
<input type="file" id="blockCover" accept="image/*" />
|
||||||
|
<p class="muted" style="margin:0.3em 0 0; font-size:0.82rem;">Wird kein Cover ausgewählt, versuchen wir automatisch ein in der MP3 eingebettetes Cover zu verwenden.</p>
|
||||||
|
</div>
|
||||||
<label style="margin-top:0.8em;">Bildunterschrift (optional)</label>
|
<label style="margin-top:0.8em;">Bildunterschrift (optional)</label>
|
||||||
<input type="text" id="blockCaption" placeholder="z. B. Sommer 2026" />
|
<input type="text" id="blockCaption" placeholder="z. B. Sommer 2026" />
|
||||||
<button type="submit" style="margin-top:0.8em;">+ Hinzufügen</button>
|
<button type="submit" style="margin-top:0.8em;">+ Hinzufügen</button>
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ function buildFriendPageHtml(friend, folder) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
if (b.type === 'audio') {
|
if (b.type === 'audio') {
|
||||||
|
const coverUri = b.coverFilename ? fileToDataUri(path.join(uploadsDir, b.coverFilename)) : null;
|
||||||
return `<div class="pdf-block audio-block" style="transform: rotate(${rot * 0.5}deg)">
|
return `<div class="pdf-block audio-block" style="transform: rotate(${rot * 0.5}deg)">
|
||||||
|
${coverUri ? `<img class="audio-cover" src="${coverUri}" />` : ''}
|
||||||
<div class="audio-note">🎵 Audio-Erinnerung${b.caption ? ': ' + esc(b.caption) : ''}<br/><small>(im Web-Freundesbuch anhörbar)</small></div>
|
<div class="audio-note">🎵 Audio-Erinnerung${b.caption ? ': ' + esc(b.caption) : ''}<br/><small>(im Web-Freundesbuch anhörbar)</small></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -135,7 +137,8 @@ function buildFullHtml(friends) {
|
|||||||
.polaroid { padding: 4mm 4mm 10mm; width: 58mm; }
|
.polaroid { padding: 4mm 4mm 10mm; width: 58mm; }
|
||||||
.polaroid img { width: 100%; height: 48mm; object-fit: cover; display: block; }
|
.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; }
|
.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; }
|
.cap { font-family: Georgia, serif; font-size: 11px; color: #6b4f8c; margin-top: 2mm; text-align: center; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
const { sha256, safeEqual } = require('../utils/hash');
|
const { sha256, safeEqual } = require('../utils/hash');
|
||||||
const storage = require('../utils/storage');
|
const storage = require('../utils/storage');
|
||||||
const { requireAdmin } = require('../auth');
|
const { requireAdmin } = require('../auth');
|
||||||
const { generateBookPdf } = require('../pdf');
|
const { generateBookPdf } = require('../pdf');
|
||||||
|
const { exportBookZip, importFriendFromZipBuffer, importBookZipBuffer } = require('../transfer');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const ADMIN_PASSWORD_HASH = (process.env.ADMIN_PASSWORD_HASH || '').trim().toLowerCase();
|
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 ---
|
// --- Admin Login ---
|
||||||
router.post('/login', (req, res) => {
|
router.post('/login', (req, res) => {
|
||||||
const { password } = req.body || {};
|
const { password } = req.body || {};
|
||||||
@@ -58,6 +70,18 @@ router.post('/friends', requireAdmin, (req, res) => {
|
|||||||
res.json({ friend: meta });
|
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) => {
|
router.delete('/friends/:id', requireAdmin, (req, res) => {
|
||||||
const folder = storage.findFolderById(req.params.id);
|
const folder = storage.findFolderById(req.params.id);
|
||||||
if (!folder) return res.status(404).json({ error: 'Nicht gefunden.' });
|
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 });
|
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) ---
|
// --- Freundesbuch-Ansicht (aggregiert, nur Admin) ---
|
||||||
router.get('/book', requireAdmin, (req, res) => {
|
router.get('/book', requireAdmin, (req, res) => {
|
||||||
const friends = storage.getAllFriendsMeta().map((f) => ({
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
|
const NodeID3 = require('node-id3');
|
||||||
const { sha256, safeEqual } = require('../utils/hash');
|
const { sha256, safeEqual } = require('../utils/hash');
|
||||||
const storage = require('../utils/storage');
|
const storage = require('../utils/storage');
|
||||||
const { requireAdminOrFriend } = require('../auth');
|
const { requireAdminOrFriend } = require('../auth');
|
||||||
|
const { exportFriendZip } = require('../transfer');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -95,30 +97,67 @@ router.post('/friends/:id/profileimage', upload.single('image'), (req, res) => {
|
|||||||
res.json({ friend: meta });
|
res.json({ friend: meta });
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Blöcke: Bild, Text, Audio ---
|
// --- Blöcke: Bild, Text, Audio (Audio optional mit eigenem Cover) ---
|
||||||
router.post('/friends/:id/blocks', upload.single('file'), (req, res) => {
|
router.post(
|
||||||
const meta = req.friendMeta;
|
'/friends/:id/blocks',
|
||||||
const type = req.body.type;
|
upload.fields([{ name: 'file', maxCount: 1 }, { name: 'cover', maxCount: 1 }]),
|
||||||
if (!['image', 'text', 'audio'].includes(type)) {
|
(req, res) => {
|
||||||
return res.status(400).json({ error: 'Unbekannter Block-Typ.' });
|
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,
|
function extractEmbeddedCover(mp3Path) {
|
||||||
caption: req.body.caption || '',
|
try {
|
||||||
createdAt: new Date().toISOString(),
|
const tags = NodeID3.read(mp3Path);
|
||||||
order: meta.blocks.length,
|
const image = tags && tags.image;
|
||||||
};
|
if (!image || !image.imageBuffer) return null;
|
||||||
if (type === 'text') {
|
const mime = image.mime || 'image/jpeg';
|
||||||
block.content = req.body.content || '';
|
const ext = mime.includes('png') ? 'png' : 'jpg';
|
||||||
} else {
|
return { buffer: image.imageBuffer, ext };
|
||||||
if (!req.file) return res.status(400).json({ error: 'Keine Datei erhalten.' });
|
} catch (e) {
|
||||||
block.filename = req.file.filename;
|
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) => {
|
router.put('/friends/:id/blocks/:blockId', (req, res) => {
|
||||||
const meta = req.friendMeta;
|
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);
|
const idx = meta.blocks.findIndex((b) => b.id === req.params.blockId);
|
||||||
if (idx === -1) return res.status(404).json({ error: 'Block nicht gefunden.' });
|
if (idx === -1) return res.status(404).json({ error: 'Block nicht gefunden.' });
|
||||||
const [removed] = meta.blocks.splice(idx, 1);
|
const [removed] = meta.blocks.splice(idx, 1);
|
||||||
if (removed.filename) {
|
const uploadsDir = path.join(storage.friendFolderPath(req.friendFolder), 'uploads');
|
||||||
const p = path.join(storage.friendFolderPath(req.friendFolder), 'uploads', removed.filename);
|
if (removed.filename) fs.rm(path.join(uploadsDir, removed.filename), { force: true }, () => {});
|
||||||
fs.rm(p, { force: true }, () => {});
|
if (removed.coverFilename) fs.rm(path.join(uploadsDir, removed.coverFilename), { force: true }, () => {});
|
||||||
}
|
|
||||||
storage.writeMeta(req.friendFolder, meta);
|
storage.writeMeta(req.friendFolder, meta);
|
||||||
res.json({ friend: meta });
|
res.json({ friend: meta });
|
||||||
});
|
});
|
||||||
@@ -156,4 +194,18 @@ router.post('/friends/:id/blocks/reorder', (req, res) => {
|
|||||||
res.json({ friend: meta });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
126
backend/src/transfer.js
Normal file
126
backend/src/transfer.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
@@ -97,6 +97,34 @@ function getAllFriendsMeta() {
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
.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 = {
|
module.exports = {
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
FRIENDS_DIR,
|
FRIENDS_DIR,
|
||||||
@@ -112,4 +140,6 @@ module.exports = {
|
|||||||
createFriend,
|
createFriend,
|
||||||
deleteFriend,
|
deleteFriend,
|
||||||
getAllFriendsMeta,
|
getAllFriendsMeta,
|
||||||
|
readSettings,
|
||||||
|
writeSettings,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,5 +17,9 @@ services:
|
|||||||
SESSION_SECRET: "bitte-durch-einen-langen-zufaelligen-string-ersetzen"
|
SESSION_SECRET: "bitte-durch-einen-langen-zufaelligen-string-ersetzen"
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
DATA_DIR: "/data"
|
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:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
|||||||
Reference in New Issue
Block a user