This commit is contained in:
2026-09-07 12:21:53 +02:00
commit 93051f791d
25 changed files with 4226 additions and 0 deletions

2366
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
backend/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "freundesbuch-backend",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"cookie-session": "^2.1.0",
"express": "^4.19.2",
"multer": "^2.0.0",
"puppeteer": "^22.15.0"
}
}

View File

@@ -0,0 +1,46 @@
.inline-form { display: flex; gap: 0.6em; flex-wrap: wrap; margin-top: 0.6em; }
.inline-form input { flex: 1; min-width: 220px; }
.friend-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.2em;
margin-top: 1em;
}
.friend-card {
background: var(--paper-2);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.2em;
position: relative;
text-align: center;
}
.friend-card .tape { top: -10px; left: 50%; transform: translateX(-50%) rotate(-3deg); }
.friend-thumb {
width: 84px; height: 84px; border-radius: 50%;
object-fit: cover; border: 4px solid #fff; box-shadow: var(--shadow);
margin-bottom: 0.5em;
}
.friend-thumb.placeholder {
display: flex; align-items: center; justify-content: center;
background: var(--rose); color: #fff; font-size: 1.8rem; font-family: 'Caveat', cursive;
}
.friend-card h3 { margin-bottom: 0.15em; }
.badge {
display: inline-block; font-size: 0.72rem; font-weight: 700; border-radius: 999px;
padding: 0.15em 0.7em; margin-bottom: 0.6em;
}
.badge.on { background: var(--sage); color: #245a3a; }
.badge.off { background: #eee; color: #888; }
.friend-actions { display: flex; gap: 0.4em; justify-content: center; flex-wrap: wrap; margin-top: 0.6em; }
.friend-actions button, .friend-actions a.btn { font-size: 0.85rem; padding: 0.5em 1em; }
.modal-backdrop {
position: fixed; inset: 0; background: rgba(75,59,107,0.35);
display: flex; align-items: center; justify-content: center; z-index: 50; padding: 1em;
}
.modal { width: 100%; max-width: 420px; }

View File

@@ -0,0 +1,158 @@
let friends = [];
let shareTargetId = null;
async function checkSession() {
const res = await fetch('/api/session');
const data = await res.json();
if (data.role !== 'admin') {
window.location.href = '/';
}
}
async function loadFriends() {
const res = await fetch('/api/friends');
if (res.status === 401) { window.location.href = '/'; return; }
const data = await res.json();
friends = data.friends || [];
renderFriends();
}
function renderFriends() {
const grid = document.getElementById('friendGrid');
const emptyMsg = document.getElementById('emptyMsg');
grid.innerHTML = '';
emptyMsg.style.display = friends.length ? 'none' : 'block';
friends.forEach((f) => {
const el = document.createElement('div');
el.className = 'friend-card';
const thumbHtml = f.profileImage
? `<img class="friend-thumb" src="/uploads/${encodeURIComponent(folderFor(f))}/${encodeURIComponent(f.profileImage)}" />`
: `<div class="friend-thumb placeholder">${(f.name || '?')[0].toUpperCase()}</div>`;
el.innerHTML = `
<div class="tape"></div>
${thumbHtml}
<h3>${escapeHtml(f.name)}</h3>
<span class="badge ${f.shareEnabled ? 'on' : 'off'}">${f.shareEnabled ? 'Freigegeben' : 'Nicht freigegeben'}</span>
<div class="friend-actions">
<a class="btn" href="/friend/${f.id}">✏️ Bearbeiten</a>
<button class="secondary" data-share="${f.id}">🔗 Freigabe</button>
<button class="danger" data-delete="${f.id}">🗑️</button>
</div>
`;
grid.appendChild(el);
});
grid.querySelectorAll('[data-share]').forEach((btn) => {
btn.addEventListener('click', () => openShareModal(btn.dataset.share));
});
grid.querySelectorAll('[data-delete]').forEach((btn) => {
btn.addEventListener('click', () => deleteFriend(btn.dataset.delete));
});
}
// wir brauchen den vollen Ordnernamen für Bild-URLs; da wir ihn nicht separat liefern,
// nutzen wir /uploads/<id>-Lookup über eine kleine Konvention: Backend akzeptiert auch
// reine IDs nicht direkt, daher holen wir folderName aus friend-detail bei Bedarf.
// Einfachere Lösung: Backend liefert profileImage-Pfad bereits vollständig auf.
function folderFor(f) {
return f.folderName || f.id;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
document.getElementById('createForm').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('newName').value.trim();
const errBox = document.getElementById('createErr');
errBox.innerHTML = '';
if (!name) return;
const res = await fetch('/api/friends', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (!res.ok) {
errBox.innerHTML = `<p class="error-msg">${data.error}</p>`;
return;
}
document.getElementById('newName').value = '';
loadFriends();
});
async function deleteFriend(id) {
const f = friends.find((x) => x.id === id);
if (!confirm(`"${f ? f.name : 'Freund'}" wirklich löschen? Alle Fotos und Einträge gehen dabei verloren.`)) return;
const res = await fetch(`/api/friends/${id}`, { method: 'DELETE' });
if (res.ok) loadFriends();
}
function openShareModal(id) {
shareTargetId = id;
const f = friends.find((x) => x.id === id);
document.getElementById('shareFriendName').textContent = f ? f.name : '';
document.getElementById('sharePw').value = '';
document.getElementById('shareErr').innerHTML = '';
const linkBox = document.getElementById('shareLinkBox');
if (f && f.shareEnabled) {
linkBox.style.display = 'block';
document.getElementById('shareLinkInput').value = `${window.location.origin}/share/${id}`;
} else {
linkBox.style.display = 'none';
}
document.getElementById('shareModal').style.display = 'flex';
}
document.getElementById('shareCloseBtn').addEventListener('click', () => {
document.getElementById('shareModal').style.display = 'none';
});
document.getElementById('shareEnableBtn').addEventListener('click', async () => {
const pw = document.getElementById('sharePw').value;
const errBox = document.getElementById('shareErr');
errBox.innerHTML = '';
const res = await fetch(`/api/friends/${shareTargetId}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, password: pw }),
});
const data = await res.json();
if (!res.ok) {
errBox.innerHTML = `<p class="error-msg">${data.error}</p>`;
return;
}
document.getElementById('shareLinkBox').style.display = 'block';
document.getElementById('shareLinkInput').value = `${window.location.origin}/share/${shareTargetId}`;
await loadFriends();
});
document.getElementById('shareDisableBtn').addEventListener('click', async () => {
const res = await fetch(`/api/friends/${shareTargetId}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: false }),
});
if (res.ok) {
document.getElementById('shareLinkBox').style.display = 'none';
await loadFriends();
}
});
document.getElementById('copyLinkBtn').addEventListener('click', () => {
const input = document.getElementById('shareLinkInput');
input.select();
navigator.clipboard.writeText(input.value).catch(() => {});
});
document.getElementById('logoutBtn').addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/';
});
checkSession();
loadFriends();

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Freundesbuch Admin</title>
<link rel="stylesheet" href="/assets/style.css" />
<link rel="stylesheet" href="/admin/admin.css" />
</head>
<body>
<div class="top-bar card" style="border-radius:0;">
<h1 style="margin:0;">📖 Freundesbuch</h1>
<div style="display:flex; gap:0.6em; flex-wrap:wrap;">
<a class="btn" href="/book" target="_blank" rel="noopener">🎠 Freundesbuch anschauen</a>
<button class="secondary" id="logoutBtn">Abmelden</button>
</div>
</div>
<div class="container">
<section class="card" id="createCard">
<h2>Neuen Freund anlegen</h2>
<form id="createForm" class="inline-form">
<input type="text" id="newName" placeholder="Name des Freundes" required />
<button type="submit">+ Anlegen</button>
</form>
<div id="createErr"></div>
</section>
<section style="margin-top:2em;">
<h2>Deine Freunde</h2>
<div id="friendGrid" class="friend-grid"></div>
<p id="emptyMsg" class="muted" style="display:none;">Noch keine Freunde angelegt leg oben den ersten an!</p>
</section>
</div>
<!-- Share-Dialog -->
<div id="shareModal" class="modal-backdrop" style="display:none;">
<div class="card modal">
<h3>Freigabe für <span id="shareFriendName"></span></h3>
<p class="muted">Vergib ein eigenes Passwort. Dein Freund kann sich über den Link damit anmelden und nur seine eigene Seite bearbeiten.</p>
<label for="sharePw">Freigabe-Passwort</label>
<input type="text" id="sharePw" placeholder="z. B. Sommerferien2026" />
<div style="display:flex; gap:0.6em; margin-top:1em; flex-wrap:wrap;">
<button id="shareEnableBtn">Freigabe aktivieren / Passwort setzen</button>
<button class="danger" id="shareDisableBtn">Freigabe beenden</button>
<button class="secondary" id="shareCloseBtn">Schließen</button>
</div>
<div id="shareLinkBox" style="margin-top:1em; display:none;">
<label>Link zum Teilen</label>
<div style="display:flex; gap:0.5em;">
<input type="text" id="shareLinkInput" readonly />
<button type="button" id="copyLinkBtn" class="secondary">Kopieren</button>
</div>
</div>
<div id="shareErr"></div>
</div>
</div>
<script src="/admin/admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,52 @@
.book-root { max-width: 1000px; margin: 0 auto; padding: 2em 1.2em 6em; min-height: 60vh; }
.friend-page { position: relative; }
.page-head { text-align: center; margin-bottom: 1.4em; position: relative; }
.page-head .tape { top: -10px; left: 50%; transform: translateX(-50%) rotate(-4deg); }
.page-profile-pic {
width: 150px; height: 150px; border-radius: 50%; object-fit: cover;
border: 8px solid #fff; box-shadow: var(--shadow); transform: rotate(-3deg);
}
.page-profile-pic.placeholder {
display: flex; align-items: center; justify-content: center;
background: var(--rose); color: #fff; font-size: 3rem; font-family: 'Caveat', cursive;
}
.page-head h1 { font-size: 3.2rem; margin-top: 0.3em; }
.steckbrief-card {
background: var(--paper-2); border: 2px dashed var(--mustard); border-radius: 14px;
padding: 1.2em 1.6em; margin: 0 auto 2em; max-width: 640px; transform: rotate(-0.4deg);
box-shadow: var(--shadow);
}
.steckbrief-card h2 { color: var(--rust); }
.sb-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.6em 1.2em; }
.sb-row .sb-key { font-weight: 700; color: var(--ink-soft); font-size: 0.82rem; display: block; }
.sb-row .sb-val { font-size: 0.95rem; }
.scrap-grid {
display: flex; flex-wrap: wrap; gap: 2em 1.6em; justify-content: center; padding: 1em 0.5em;
}
.scrap-item { background: #fff; box-shadow: 0 6px 16px rgba(0,0,0,0.16); transition: transform 0.15s ease; }
.scrap-item:hover { transform: scale(1.04) rotate(0deg) !important; z-index: 2; }
.scrap-item.polaroid { padding: 10px 10px 30px; width: 220px; }
.scrap-item.polaroid img { width: 100%; height: 190px; object-fit: cover; display: block; }
.scrap-item.text-note { width: 220px; min-height: 160px; padding: 1.2em; background: #fef7d6; font-family: Georgia, serif; }
.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-caption { font-family: 'Caveat', cursive; font-size: 1.2rem; text-align: center; margin-top: 0.4em; color: var(--ink-soft); }
.book-nav {
position: fixed; bottom: 0; left: 0; right: 0;
background: var(--paper-2); border-top: 2px solid var(--line);
display: flex; align-items: center; justify-content: center; gap: 1.4em;
padding: 0.8em; box-shadow: 0 -4px 12px rgba(0,0,0,0.08);
}
#pageIndicator { font-family: 'Caveat', cursive; font-size: 1.3rem; color: var(--ink-soft); }
.empty-book { text-align: center; margin-top: 3em; }

147
backend/public/book/book.js Normal file
View File

@@ -0,0 +1,147 @@
let friends = [];
let currentIndex = 0;
const STECKBRIEF_LABELS = {
spitzname: 'Spitzname',
geburtstag: 'Geburtstag',
adresse: 'Adresse / Wohnort',
lieblingsfarbe: 'Lieblingsfarbe',
lieblingsessen: 'Lieblingsessen',
lieblingstier: 'Lieblingstier',
hobbys: 'Hobbys',
lieblingsmusik: 'Lieblingsmusik',
lieblingsfilm: 'Lieblingsfilm/-serie',
traumberuf: 'Traumberuf',
bestefreunde: 'Beste Freunde',
motto: 'Motto / Lebensweisheit',
sonstiges: 'Sonstiges',
};
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
function randomRotation(seed) {
const angles = [-6, -4, -2, 2, 4, 6, -3, 3, 5, -5];
return angles[seed % angles.length];
}
async function load() {
const res = await fetch('/api/book');
if (res.status === 401) { window.location.href = '/'; return; }
const data = await res.json();
friends = data.friends || [];
if (!friends.length) {
document.getElementById('bookRoot').innerHTML = `
<div class="empty-book">
<h2>Noch ist dieses Buch leer 📖</h2>
<p class="muted">Lege im Admin-Bereich Freunde an und teile den Link mit ihnen.</p>
</div>`;
return;
}
document.getElementById('bookNav').style.display = 'flex';
renderPage();
}
function uploadUrl(folder, filename) {
return `/uploads/${encodeURIComponent(folder)}/${encodeURIComponent(filename)}`;
}
function renderPage() {
const f = friends[currentIndex];
const root = document.getElementById('bookRoot');
const profileHtml = f.profileImage
? `<img class="page-profile-pic" src="${uploadUrl(f.folderName, f.profileImage)}" />`
: `<div class="page-profile-pic placeholder">${(f.name || '?')[0].toUpperCase()}</div>`;
const sbEntries = Object.entries(f.steckbrief || {}).filter(([, v]) => v && String(v).trim());
const sbHtml = sbEntries.length
? `<div class="sb-grid">${sbEntries.map(([k, v]) => `
<div class="sb-row"><span class="sb-key">${escapeHtml(STECKBRIEF_LABELS[k] || k)}</span><span class="sb-val">${escapeHtml(v)}</span></div>
`).join('')}</div>`
: `<p class="muted">Noch nichts eingetragen.</p>`;
const blocks = [...(f.blocks || [])].sort((a, b) => a.order - b.order);
const blocksHtml = blocks.length
? blocks.map((b, i) => {
const rot = randomRotation(i + f.id.length);
if (b.type === 'image') {
return `<div class="scrap-item polaroid" style="transform: rotate(${rot}deg);">
<img src="${uploadUrl(f.folderName, b.filename)}" />
${b.caption ? `<div class="scrap-caption">${escapeHtml(b.caption)}</div>` : ''}
</div>`;
}
if (b.type === 'audio') {
return `<div class="scrap-item audio-note" style="transform: rotate(${rot * 0.5}deg);">
<div>🎙️ ${b.caption ? escapeHtml(b.caption) : 'Sprachnachricht'}</div>
<audio controls src="${uploadUrl(f.folderName, b.filename)}"></audio>
</div>`;
}
return `<div class="scrap-item text-note" style="transform: rotate(${rot * 0.5}deg);">
<p>${escapeHtml(b.content).replace(/\n/g, '<br/>')}</p>
${b.caption ? `<div class="scrap-caption">${escapeHtml(b.caption)}</div>` : ''}
</div>`;
}).join('')
: '<p class="muted">Noch keine Erinnerungen hinzugefügt.</p>';
root.innerHTML = `
<div class="friend-page">
<div class="page-head">
<div class="tape"></div>
${profileHtml}
<h1>${escapeHtml(f.name)}</h1>
</div>
<div class="steckbrief-card">
<h2>Steckbrief</h2>
${sbHtml}
</div>
<div class="scrap-grid">
${blocksHtml}
</div>
</div>
`;
document.getElementById('pageIndicator').textContent = `Seite ${currentIndex + 1} von ${friends.length}`;
document.getElementById('prevBtn').disabled = currentIndex === 0;
document.getElementById('nextBtn').disabled = currentIndex === friends.length - 1;
}
document.getElementById('prevBtn').addEventListener('click', () => {
if (currentIndex > 0) { currentIndex--; renderPage(); window.scrollTo({ top: 0, behavior: 'smooth' }); }
});
document.getElementById('nextBtn').addEventListener('click', () => {
if (currentIndex < friends.length - 1) { currentIndex++; renderPage(); window.scrollTo({ top: 0, behavior: 'smooth' }); }
});
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') document.getElementById('prevBtn').click();
if (e.key === 'ArrowRight') document.getElementById('nextBtn').click();
});
document.getElementById('pdfBtn').addEventListener('click', async () => {
const btn = document.getElementById('pdfBtn');
btn.disabled = true;
btn.textContent = 'Erzeuge PDF...';
try {
const res = await fetch('/api/book/pdf');
if (!res.ok) throw new Error('PDF-Export fehlgeschlagen.');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'freundesbuch.pdf';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
alert(err.message);
} finally {
btn.disabled = false;
btn.textContent = '⬇️ Als PDF exportieren';
}
});
load();

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Freundesbuch Ansicht</title>
<link rel="stylesheet" href="/assets/style.css" />
<link rel="stylesheet" href="/book/book.css" />
</head>
<body>
<div class="top-bar card" style="border-radius:0;">
<h1 style="margin:0;">🎠 Freundesbuch</h1>
<div style="display:flex; gap:0.6em; flex-wrap:wrap;">
<button class="secondary" id="pdfBtn">⬇️ Als PDF exportieren</button>
<a class="btn secondary" href="/admin">← Zur Übersicht</a>
</div>
</div>
<div id="bookRoot" class="book-root">
<p class="muted" style="text-align:center; margin-top:3em;">Lädt...</p>
</div>
<div class="book-nav" id="bookNav" style="display:none;">
<button id="prevBtn" class="secondary">← Zurück</button>
<span id="pageIndicator"></span>
<button id="nextBtn" class="secondary">Weiter →</button>
</div>
<script src="/book/book.js"></script>
</body>
</html>

View File

@@ -0,0 +1,38 @@
.profile-preview {
width: 140px; height: 140px; border-radius: 50%;
object-fit: cover; border: 6px solid #fff; box-shadow: var(--shadow);
margin: 0.4em auto 0; display: block;
}
.profile-preview.placeholder {
display: flex; align-items: center; justify-content: center;
font-size: 2.4rem; background: var(--paper); color: var(--rose);
}
.steckbrief-form {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1em;
}
.saved-msg { margin-left: 0.8em; color: #3a8a5a; font-weight: 700; opacity: 0; transition: opacity 0.3s; }
.saved-msg.show { opacity: 1; }
.add-block-tabs { display: flex; gap: 0.5em; margin: 0.8em 0; flex-wrap: wrap; }
.tab-btn { background: #fff; color: var(--ink-soft); border: 2px solid var(--line); box-shadow: none; padding: 0.5em 1em; }
.tab-btn.active { background: var(--ink-soft); color: #fff; border-color: var(--ink-soft); }
.blocks-list { display: flex; flex-wrap: wrap; gap: 1.2em; margin-top: 1.4em; }
.block-item {
background: var(--paper-2); border-radius: 10px; box-shadow: var(--shadow);
padding: 1em; width: 220px; position: relative;
}
.block-item img { width: 100%; height: 140px; object-fit: cover; border-radius: 6px; }
.block-item audio { width: 100%; margin-top: 0.4em; }
.block-item textarea { min-height: 4em; font-size: 0.9rem; }
.block-item .block-caption { font-size: 0.82rem; margin-top: 0.5em; }
.block-item .block-actions { display: flex; justify-content: space-between; margin-top: 0.6em; gap: 0.4em; }
.block-item .block-actions button { font-size: 0.78rem; padding: 0.35em 0.7em; }
.block-type-tag {
position: absolute; top: -8px; left: 10px; font-size: 0.7rem; font-weight: 700;
background: var(--mustard); color: #533; padding: 0.1em 0.6em; border-radius: 999px;
}

View File

@@ -0,0 +1,270 @@
const STECKBRIEF_FIELDS = [
['spitzname', 'Spitzname'],
['geburtstag', 'Geburtstag'],
['adresse', 'Adresse / Wohnort'],
['lieblingsfarbe', 'Lieblingsfarbe'],
['lieblingsessen', 'Lieblingsessen'],
['lieblingstier', 'Lieblingstier'],
['hobbys', 'Hobbys'],
['lieblingsmusik', 'Lieblingsmusik'],
['lieblingsfilm', 'Lieblingsfilm/-serie'],
['traumberuf', 'Traumberuf'],
['bestefreunde', 'Beste Freunde'],
['motto', 'Motto / Lebensweisheit'],
['sonstiges', 'Sonstiges'],
];
const pathParts = window.location.pathname.split('/').filter(Boolean); // ['friend', ':id'] or ['share', ':id']
const mode = pathParts[0]; // 'friend' | 'share'
const friendId = pathParts[1];
let currentFriend = null;
let currentFolder = null;
let selectedType = 'image';
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
async function init() {
const sessionRes = await fetch('/api/session');
const session = await sessionRes.json();
const isAdmin = session.role === 'admin';
const isMatchingFriend = session.role === 'friend' && session.friendId === friendId;
document.getElementById('adminBackWrap').style.display = isAdmin ? 'block' : 'none';
if (isAdmin || isMatchingFriend) {
loadFriend();
} else if (mode === 'share') {
showLoginGate();
} else {
// /friend/:id ohne Admin-Session -> zur Startseite
window.location.href = '/';
}
}
function showLoginGate() {
document.getElementById('loginGate').style.display = 'block';
document.getElementById('editorRoot').style.display = 'none';
}
document.getElementById('gateForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('gatePassword').value;
const errBox = document.getElementById('gateErr');
errBox.innerHTML = '';
const res = await fetch(`/api/share/${friendId}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!res.ok) {
errBox.innerHTML = `<p class="error-msg">${data.error}</p>`;
return;
}
loadFriend();
});
async function loadFriend() {
const res = await fetch(`/api/friends/${friendId}`);
if (res.status === 401) { showLoginGate(); return; }
if (res.status === 404) { document.body.innerHTML = '<div class="container"><p class="error-msg">Diesen Freund gibt es nicht (mehr).</p></div>'; return; }
const data = await res.json();
currentFriend = data.friend;
currentFolder = currentFriend.folderName;
document.getElementById('loginGate').style.display = 'none';
document.getElementById('editorRoot').style.display = 'block';
document.getElementById('pageTitle').textContent = `📖 ${currentFriend.name}`;
renderProfileImage();
renderSteckbrief();
renderBlocks();
}
function uploadUrl(filename) {
return `/uploads/${encodeURIComponent(currentFolder)}/${encodeURIComponent(filename)}`;
}
function renderProfileImage() {
const img = document.getElementById('profileImg');
const placeholder = document.getElementById('profilePlaceholder');
if (currentFriend.profileImage) {
img.src = uploadUrl(currentFriend.profileImage) + `?t=${Date.now()}`;
img.style.display = 'block';
placeholder.style.display = 'none';
} else {
img.style.display = 'none';
placeholder.style.display = 'flex';
}
}
document.getElementById('profileInput').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const fd = new FormData();
fd.append('image', file);
const res = await fetch(`/api/friends/${friendId}/profileimage`, { method: 'POST', body: fd });
const data = await res.json();
if (res.ok) {
currentFriend = data.friend;
renderProfileImage();
} else {
alert(data.error || 'Upload fehlgeschlagen.');
}
});
function renderSteckbrief() {
const form = document.getElementById('steckbriefForm');
form.innerHTML = '';
STECKBRIEF_FIELDS.forEach(([key, label]) => {
const wrap = document.createElement('div');
const val = (currentFriend.steckbrief && currentFriend.steckbrief[key]) || '';
wrap.innerHTML = `<label>${label}</label><input type="text" data-key="${key}" value="${escapeHtml(val)}" />`;
form.appendChild(wrap);
});
}
document.getElementById('steckbriefForm').addEventListener('submit', async (e) => {
e.preventDefault();
const inputs = document.querySelectorAll('#steckbriefForm input');
const payload = {};
inputs.forEach((inp) => { payload[inp.dataset.key] = inp.value; });
const res = await fetch(`/api/friends/${friendId}/steckbrief`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok) {
currentFriend = data.friend;
const savedEl = document.getElementById('sbSaved');
savedEl.textContent = 'Gespeichert ✓';
savedEl.classList.add('show');
setTimeout(() => savedEl.classList.remove('show'), 1800);
} else {
alert(data.error || 'Speichern fehlgeschlagen.');
}
});
// --- Block-Typ-Auswahl ---
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
selectedType = btn.dataset.type;
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.toggle('active', b === btn));
document.getElementById('blockType').value = selectedType;
const fileWrap = document.getElementById('blockFileWrap');
const textWrap = document.getElementById('blockTextWrap');
const fileLabel = document.getElementById('blockFileLabel');
const fileInput = document.getElementById('blockFile');
if (selectedType === 'text') {
fileWrap.style.display = 'none';
textWrap.style.display = 'block';
} 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/*';
}
});
});
document.getElementById('blockForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData();
fd.append('type', selectedType);
fd.append('caption', document.getElementById('blockCaption').value);
if (selectedType === 'text') {
fd.append('content', document.getElementById('blockContent').value);
} else {
const file = document.getElementById('blockFile').files[0];
if (!file) { alert('Bitte eine Datei auswählen.'); return; }
fd.append('file', file);
}
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();
renderBlocks();
} else {
alert(data.error || 'Hinzufügen fehlgeschlagen.');
}
});
function renderBlocks() {
const list = document.getElementById('blocksList');
list.innerHTML = '';
const blocks = [...(currentFriend.blocks || [])].sort((a, b) => a.order - b.order);
blocks.forEach((b, idx) => {
const el = document.createElement('div');
el.className = 'block-item';
const tag = { image: '🖼️ Bild', text: '📝 Text', audio: '🎙️ Audio' }[b.type] || b.type;
let inner = `<div class="block-type-tag">${tag}</div>`;
if (b.type === 'image') {
inner += `<img src="${uploadUrl(b.filename)}" />`;
} else if (b.type === 'audio') {
inner += `<audio controls src="${uploadUrl(b.filename)}"></audio>`;
} else {
inner += `<textarea data-content-for="${b.id}">${escapeHtml(b.content)}</textarea>`;
}
inner += `<input type="text" class="block-caption" placeholder="Bildunterschrift" data-caption-for="${b.id}" value="${escapeHtml(b.caption)}" />`;
inner += `<div class="block-actions">
<button type="button" class="secondary" data-save="${b.id}">💾</button>
<button type="button" class="secondary" data-up="${b.id}" ${idx === 0 ? 'disabled' : ''}>↑</button>
<button type="button" class="secondary" data-down="${b.id}" ${idx === blocks.length - 1 ? 'disabled' : ''}>↓</button>
<button type="button" class="danger" data-del="${b.id}">🗑️</button>
</div>`;
el.innerHTML = inner;
list.appendChild(el);
});
list.querySelectorAll('[data-save]').forEach((btn) => btn.addEventListener('click', () => saveBlock(btn.dataset.save)));
list.querySelectorAll('[data-del]').forEach((btn) => btn.addEventListener('click', () => deleteBlock(btn.dataset.del)));
list.querySelectorAll('[data-up]').forEach((btn) => btn.addEventListener('click', () => moveBlock(btn.dataset.up, -1)));
list.querySelectorAll('[data-down]').forEach((btn) => btn.addEventListener('click', () => moveBlock(btn.dataset.down, 1)));
}
async function saveBlock(id) {
const captionInput = document.querySelector(`[data-caption-for="${id}"]`);
const contentInput = document.querySelector(`[data-content-for="${id}"]`);
const payload = { caption: captionInput ? captionInput.value : '' };
if (contentInput) payload.content = contentInput.value;
const res = await fetch(`/api/friends/${friendId}/blocks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok) { currentFriend = data.friend; renderBlocks(); }
}
async function deleteBlock(id) {
if (!confirm('Diesen Block wirklich löschen?')) return;
const res = await fetch(`/api/friends/${friendId}/blocks/${id}`, { method: 'DELETE' });
const data = await res.json();
if (res.ok) { currentFriend = data.friend; renderBlocks(); }
}
async function moveBlock(id, dir) {
const blocks = [...(currentFriend.blocks || [])].sort((a, b) => a.order - b.order);
const idx = blocks.findIndex((b) => b.id === id);
const swapIdx = idx + dir;
if (swapIdx < 0 || swapIdx >= blocks.length) return;
[blocks[idx], blocks[swapIdx]] = [blocks[swapIdx], blocks[idx]];
const order = blocks.map((b) => b.id);
const res = await fetch(`/api/friends/${friendId}/blocks/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order }),
});
const data = await res.json();
if (res.ok) { currentFriend = data.friend; renderBlocks(); }
}
init();

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Freundesbuch</title>
<link rel="stylesheet" href="/assets/style.css" />
<link rel="stylesheet" href="/friend/friend.css" />
</head>
<body>
<div id="loginGate" class="container" style="display:none; max-width:380px; text-align:center;">
<div class="card" style="position:relative;">
<div class="tape" style="top:-10px; left:50%; transform:translateX(-50%) rotate(-3deg);"></div>
<span style="font-size:2.6rem;">🔒</span>
<h1 id="gateFriendName">Seite gesperrt</h1>
<p class="muted">Gib das Freigabe-Passwort ein, um deine Seite zu bearbeiten.</p>
<form id="gateForm" style="display:flex; flex-direction:column; gap:0.8em; margin-top:1em;">
<input type="password" id="gatePassword" placeholder="Passwort" required autofocus />
<button type="submit">Öffnen</button>
</form>
<div id="gateErr"></div>
</div>
</div>
<div id="editorRoot" style="display:none;">
<div class="top-bar card" style="border-radius:0;">
<h1 style="margin:0;" id="pageTitle">📖 Freund</h1>
<div id="adminBackWrap"><a class="btn secondary" href="/admin">← Zur Übersicht</a></div>
</div>
<div class="container">
<section class="card" style="text-align:center; position:relative;">
<div class="tape" style="top:-10px; left:50%; transform:translateX(-50%) rotate(-2deg);"></div>
<h2>Profilbild</h2>
<img id="profileImg" class="profile-preview" style="display:none;" />
<div id="profilePlaceholder" class="profile-preview placeholder">📷</div>
<div style="margin-top:0.8em;">
<input type="file" id="profileInput" accept="image/*" />
</div>
</section>
<section class="card" style="margin-top:1.4em;">
<h2>Steckbrief</h2>
<form id="steckbriefForm" class="steckbrief-form"></form>
<button type="submit" form="steckbriefForm" style="margin-top:1em;">Steckbrief speichern</button>
<span id="sbSaved" class="saved-msg"></span>
</section>
<section class="card" style="margin-top:1.4em;">
<h2>Erinnerungen &amp; Blöcke</h2>
<p class="muted">Füge Bilder, Texte oder Sprachnachrichten hinzu.</p>
<div class="add-block-tabs">
<button type="button" class="tab-btn active" data-type="image">🖼️ Bild</button>
<button type="button" class="tab-btn" data-type="text">📝 Text</button>
<button type="button" class="tab-btn" data-type="audio">🎙️ Audio</button>
</div>
<form id="blockForm">
<input type="hidden" id="blockType" value="image" />
<div id="blockFileWrap">
<label id="blockFileLabel">Bild auswählen</label>
<input type="file" id="blockFile" accept="image/*" />
</div>
<div id="blockTextWrap" style="display:none;">
<label>Text</label>
<textarea id="blockContent" placeholder="Schreib etwas Schönes..."></textarea>
</div>
<label style="margin-top:0.8em;">Bildunterschrift (optional)</label>
<input type="text" id="blockCaption" placeholder="z. B. Sommer 2026" />
<button type="submit" style="margin-top:0.8em;">+ Hinzufügen</button>
</form>
<div id="blocksList" class="blocks-list"></div>
</section>
</div>
</div>
<script src="/friend/friend.js"></script>
</body>
</html>

View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Freundesbuch</title>
<link rel="stylesheet" href="/assets/style.css" />
<style>
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-card { width: 100%; max-width: 380px; text-align: center; position: relative; }
.login-card .tape { top: -12px; left: 50%; transform: translateX(-50%) rotate(-3deg); }
.book-emoji { font-size: 3rem; display: block; margin-bottom: 0.1em; }
form { margin-top: 1.2em; display: flex; flex-direction: column; gap: 0.8em; }
</style>
</head>
<body>
<div class="card login-card">
<div class="tape"></div>
<span class="book-emoji">📖</span>
<h1>Freundesbuch</h1>
<p class="muted">Gib das Passwort ein, um hineinzuschauen.</p>
<form id="loginForm">
<input type="password" id="password" placeholder="Passwort" autofocus required />
<button type="submit">Aufschlagen</button>
</form>
<div id="err"></div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
const errBox = document.getElementById('err');
errBox.innerHTML = '';
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!res.ok) {
errBox.innerHTML = `<p class="error-msg">${data.error || 'Fehler beim Anmelden.'}</p>`;
return;
}
window.location.href = '/admin';
} catch (err) {
errBox.innerHTML = '<p class="error-msg">Verbindung fehlgeschlagen.</p>';
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,126 @@
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Nunito:ital,wght@0,400;0,600;0,700;1,600&display=swap');
:root {
--paper: #faf3e6;
--paper-2: #fffdf7;
--ink: #4b3b6b;
--ink-soft: #6b4f8c;
--rust: #c9622f;
--mustard: #e8b84b;
--sage: #a8d5bA;
--rose: #e8a0bf;
--line: #d9c9a8;
--radius: 14px;
--shadow: 0 6px 18px rgba(75, 59, 107, 0.14);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
background: var(--paper);
background-image:
radial-gradient(circle at 8% 12%, rgba(232, 160, 191, 0.14), transparent 38%),
radial-gradient(circle at 92% 88%, rgba(168, 213, 186, 0.16), transparent 42%);
color: var(--ink);
font-family: 'Nunito', sans-serif;
font-size: 16px;
line-height: 1.5;
}
h1, h2, h3, .display {
font-family: 'Caveat', cursive;
color: var(--ink-soft);
font-weight: 700;
margin: 0 0 0.3em;
letter-spacing: 0.2px;
}
h1 { font-size: 2.6rem; }
h2 { font-size: 1.9rem; }
h3 { font-size: 1.5rem; }
a { color: var(--rust); }
button, .btn {
font-family: 'Nunito', sans-serif;
font-weight: 700;
border: none;
border-radius: 999px;
padding: 0.65em 1.4em;
cursor: pointer;
background: var(--ink-soft);
color: #fff;
box-shadow: var(--shadow);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
button:hover, .btn:hover { transform: translateY(-1px); }
button:active, .btn:active { transform: translateY(0); }
button:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
button.secondary { background: #fff; color: var(--ink-soft); border: 2px solid var(--ink-soft); box-shadow: none; }
button.danger { background: var(--rust); }
button.ghost { background: transparent; color: var(--ink-soft); box-shadow: none; padding: 0.4em 0.8em; }
input[type="text"], input[type="password"], textarea, select {
font-family: 'Nunito', sans-serif;
font-size: 1rem;
padding: 0.6em 0.8em;
border: 2px solid var(--line);
border-radius: 10px;
background: var(--paper-2);
color: var(--ink);
width: 100%;
}
textarea { resize: vertical; min-height: 5em; }
input:focus, textarea:focus, select:focus, button:focus-visible {
outline: 3px solid var(--rose);
outline-offset: 1px;
}
label { font-weight: 700; font-size: 0.85rem; display: block; margin-bottom: 0.3em; color: var(--ink-soft); }
.card {
background: var(--paper-2);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.4em;
}
.tape {
position: absolute;
width: 60px;
height: 22px;
background: rgba(232, 184, 75, 0.55);
box-shadow: 0 1px 2px rgba(0,0,0,0.08);
}
.error-msg {
background: #fce8e8;
color: #a33;
border-radius: 10px;
padding: 0.6em 1em;
font-weight: 600;
font-size: 0.9rem;
}
.muted { color: #9a8fb0; font-style: italic; }
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1em 1.5em;
flex-wrap: wrap;
gap: 0.6em;
}
.container {
max-width: 980px;
margin: 0 auto;
padding: 1em 1.2em 4em;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}

47
backend/server.js Normal file
View File

@@ -0,0 +1,47 @@
const path = require('path');
const express = require('express');
const cookieSession = require('cookie-session');
const storage = require('./src/utils/storage');
const adminRoutes = require('./src/routes/admin');
const friendRoutes = require('./src/routes/friend');
const uploadRoutes = require('./src/routes/uploads');
storage.ensureBaseDirs();
const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '2mb' }));
app.use(
cookieSession({
name: 'fb_session',
secret: process.env.SESSION_SECRET || 'bitte-in-der-docker-compose-aendern',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 Tage
sameSite: 'lax',
})
);
// --- API ---
app.use('/api', adminRoutes);
app.use('/api', friendRoutes);
app.use('/uploads', uploadRoutes);
// --- Frontend (statisch) ---
const PUBLIC_DIR = path.join(__dirname, 'public');
app.use('/assets', express.static(path.join(PUBLIC_DIR, 'shared')));
app.use('/admin', express.static(path.join(PUBLIC_DIR, 'admin')));
app.use('/friend', express.static(path.join(PUBLIC_DIR, 'friend')));
app.use('/book', express.static(path.join(PUBLIC_DIR, 'book')));
app.get('/', (req, res) => res.sendFile(path.join(PUBLIC_DIR, 'login', 'index.html')));
// Friend-Share-Link: /share/<id> -> gleiche Seite wie /friend, JS erkennt ID aus der URL
app.get('/share/:id', (req, res) => res.sendFile(path.join(PUBLIC_DIR, 'friend', 'index.html')));
app.get('/friend/:id', (req, res) => res.sendFile(path.join(PUBLIC_DIR, 'friend', 'index.html')));
app.get('/health', (req, res) => res.json({ ok: true }));
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Freundesbuch läuft auf Port ${PORT}`);
});

25
backend/src/auth.js Normal file
View File

@@ -0,0 +1,25 @@
function requireAdmin(req, res, next) {
if (req.session && req.session.role === 'admin') return next();
return res.status(401).json({ error: 'Nicht angemeldet.' });
}
// Zugriff erlaubt für Admin ODER für den Freund selbst (per Share-Link angemeldet)
function requireAdminOrFriend(getFriendId) {
return (req, res, next) => {
if (req.session && req.session.role === 'admin') return next();
const targetId = typeof getFriendId === 'function' ? getFriendId(req) : req.params.id;
if (req.session && req.session.role === 'friend' && req.session.friendId === targetId) {
return next();
}
return res.status(401).json({ error: 'Nicht angemeldet.' });
};
}
function requireAnySession(req, res, next) {
if (req.session && (req.session.role === 'admin' || req.session.role === 'friend')) {
return next();
}
return res.status(401).json({ error: 'Nicht angemeldet.' });
}
module.exports = { requireAdmin, requireAdminOrFriend, requireAnySession };

167
backend/src/pdf.js Normal file
View File

@@ -0,0 +1,167 @@
const fs = require('fs');
const path = require('path');
const puppeteer = require('puppeteer');
const storage = require('./utils/storage');
function fileToDataUri(fullPath) {
try {
const buf = fs.readFileSync(fullPath);
const ext = path.extname(fullPath).toLowerCase().replace('.', '');
const mime = { jpg: 'jpeg', jpeg: 'jpeg', png: 'png', gif: 'gif', webp: 'webp' }[ext] || 'jpeg';
return `data:image/${mime};base64,${buf.toString('base64')}`;
} catch (e) {
return null;
}
}
function esc(str) {
return String(str || '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
const ROTATIONS = [-4, 3, -2, 5, -3, 2, -5, 4];
function buildFriendPageHtml(friend, folder) {
const uploadsDir = path.join(storage.friendFolderPath(folder), 'uploads');
const profileUri = friend.profileImage ? fileToDataUri(path.join(uploadsDir, friend.profileImage)) : null;
const steckbriefRows = Object.entries(friend.steckbrief || {})
.filter(([, v]) => v && String(v).trim())
.map(([k, v]) => `<div class="sb-row"><span class="sb-key">${esc(k)}</span><span class="sb-val">${esc(v)}</span></div>`)
.join('');
const blocks = [...(friend.blocks || [])].sort((a, b) => a.order - b.order);
const blockHtml = blocks
.map((b, i) => {
const rot = ROTATIONS[i % ROTATIONS.length];
if (b.type === 'text') {
return `<div class="pdf-block text-block" style="transform: rotate(${rot * 0.4}deg)">
<p>${esc(b.content).replace(/\n/g, '<br/>')}</p>
${b.caption ? `<div class="cap">${esc(b.caption)}</div>` : ''}
</div>`;
}
if (b.type === 'image') {
const uri = fileToDataUri(path.join(uploadsDir, b.filename));
if (!uri) return '';
return `<div class="pdf-block image-block polaroid" style="transform: rotate(${rot}deg)">
<img src="${uri}" />
${b.caption ? `<div class="cap">${esc(b.caption)}</div>` : ''}
</div>`;
}
if (b.type === 'audio') {
return `<div class="pdf-block audio-block" style="transform: rotate(${rot * 0.5}deg)">
<div class="audio-note">&#127925; Audio-Erinnerung${b.caption ? ': ' + esc(b.caption) : ''}<br/><small>(im Web-Freundesbuch anh&ouml;rbar)</small></div>
</div>`;
}
return '';
})
.join('\n');
return `
<section class="page">
<div class="tape tape-tl"></div><div class="tape tape-tr"></div>
<header class="page-head">
${profileUri ? `<img class="profile-pic" src="${profileUri}" />` : '<div class="profile-pic placeholder">?</div>'}
<h1>${esc(friend.name)}</h1>
</header>
<div class="steckbrief">
<h2>Steckbrief</h2>
${steckbriefRows || '<p class="muted">Noch nichts eingetragen.</p>'}
</div>
<div class="blocks">
${blockHtml || '<p class="muted">Noch keine Erinnerungen hinzugef&uuml;gt.</p>'}
</div>
</section>`;
}
function buildFullHtml(friends) {
const pages = friends
.map((f) => buildFriendPageHtml(f, f.folderName))
.join('\n');
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<style>
@page { size: A4; margin: 0; }
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'Comic Sans MS', 'Segoe Print', 'Bradley Hand', cursive, sans-serif;
background: #faf3e6;
color: #3a3350;
}
.page {
position: relative;
width: 210mm;
min-height: 297mm;
padding: 18mm 16mm;
page-break-after: always;
background: #faf3e6;
background-image: radial-gradient(circle at 10% 10%, rgba(232,168,191,0.15), transparent 40%),
radial-gradient(circle at 90% 85%, rgba(168,213,186,0.18), transparent 45%);
overflow: hidden;
}
.page:last-child { page-break-after: auto; }
.tape { position: absolute; width: 70px; height: 26px; background: rgba(232,184,75,0.55); }
.tape-tl { top: 6mm; left: 20mm; transform: rotate(-8deg); }
.tape-tr { top: 6mm; right: 20mm; transform: rotate(9deg); }
.page-head { text-align: center; margin-bottom: 8mm; }
.profile-pic {
width: 42mm; height: 42mm; object-fit: cover; border-radius: 50%;
border: 6px solid #fff; box-shadow: 0 4px 14px rgba(0,0,0,0.18);
transform: rotate(-3deg);
}
.profile-pic.placeholder {
display: flex; align-items: center; justify-content: center;
font-size: 30px; color: #b9a9d8; background: #fff;
}
.page-head h1 {
font-size: 34px; margin: 6mm 0 0; color: #6b4f8c;
text-shadow: 1px 1px 0 rgba(255,255,255,0.6);
}
.steckbrief {
background: #fffdf7; border: 2px dashed #d9b568; border-radius: 10px;
padding: 6mm 8mm; margin-bottom: 8mm; transform: rotate(-0.6deg);
}
.steckbrief h2 { margin: 0 0 4mm; font-size: 20px; color: #b5652f; }
.sb-row { display: flex; gap: 6px; font-size: 13px; margin-bottom: 2mm; font-family: Georgia, serif; }
.sb-key { font-weight: bold; min-width: 32mm; color: #6b4f8c; }
.muted { color: #9a8fb0; font-style: italic; font-size: 12px; font-family: Georgia, serif; }
.blocks { display: flex; flex-wrap: wrap; gap: 6mm; }
.pdf-block { background: #fff; padding: 4mm; box-shadow: 0 3px 10px rgba(0,0,0,0.15); }
.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; }
.cap { font-family: Georgia, serif; font-size: 11px; color: #6b4f8c; margin-top: 2mm; text-align: center; }
</style>
</head>
<body>
${pages || '<section class="page"><h1>Noch keine Freunde angelegt.</h1></section>'}
</body>
</html>`;
}
async function generateBookPdf() {
const friends = storage.getAllFriendsMeta();
const html = buildFullHtml(friends);
const browser = await puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const buffer = await page.pdf({ format: 'A4', printBackground: true });
return buffer;
} finally {
await browser.close();
}
}
module.exports = { generateBookPdf, buildFullHtml };

111
backend/src/routes/admin.js Normal file
View File

@@ -0,0 +1,111 @@
const express = require('express');
const { sha256, safeEqual } = require('../utils/hash');
const storage = require('../utils/storage');
const { requireAdmin } = require('../auth');
const { generateBookPdf } = require('../pdf');
const router = express.Router();
const ADMIN_PASSWORD_HASH = (process.env.ADMIN_PASSWORD_HASH || '').trim().toLowerCase();
// --- Admin Login ---
router.post('/login', (req, res) => {
const { password } = req.body || {};
if (!password || !ADMIN_PASSWORD_HASH) {
return res.status(400).json({ error: 'Passwort erforderlich.' });
}
const hash = sha256(password);
if (!safeEqual(hash, ADMIN_PASSWORD_HASH)) {
return res.status(401).json({ error: 'Falsches Passwort.' });
}
req.session.role = 'admin';
req.session.friendId = null;
return res.json({ ok: true });
});
router.post('/logout', (req, res) => {
req.session = null;
res.json({ ok: true });
});
router.get('/session', (req, res) => {
if (req.session && req.session.role) {
return res.json({ role: req.session.role, friendId: req.session.friendId || null });
}
res.json({ role: null });
});
// --- Freunde-Verwaltung (nur Admin) ---
router.get('/friends', requireAdmin, (req, res) => {
const friends = storage.getAllFriendsMeta().map((f) => ({
id: f.id,
folderName: f.folderName,
name: f.name,
createdAt: f.createdAt,
profileImage: f.profileImage,
shareEnabled: !!(f.share && f.share.enabled),
blockCount: (f.blocks || []).length,
}));
res.json({ friends });
});
router.post('/friends', requireAdmin, (req, res) => {
const { name } = req.body || {};
if (!name || !String(name).trim()) {
return res.status(400).json({ error: 'Name erforderlich.' });
}
const meta = storage.createFriend(String(name).trim());
res.json({ friend: meta });
});
router.delete('/friends/:id', requireAdmin, (req, res) => {
const folder = storage.findFolderById(req.params.id);
if (!folder) return res.status(404).json({ error: 'Nicht gefunden.' });
storage.deleteFriend(folder);
res.json({ ok: true });
});
// Freigabe (Share-Link) verwalten
router.post('/friends/:id/share', requireAdmin, (req, res) => {
const folder = storage.findFolderById(req.params.id);
if (!folder) return res.status(404).json({ error: 'Nicht gefunden.' });
const meta = storage.readMeta(folder);
const { enabled, password } = req.body || {};
if (enabled === false) {
meta.share.enabled = false;
storage.writeMeta(folder, meta);
return res.json({ friend: meta });
}
if (!password || String(password).length < 4) {
return res.status(400).json({ error: 'Bitte ein Passwort mit mindestens 4 Zeichen vergeben.' });
}
meta.share.enabled = true;
meta.share.passwordHash = sha256(password);
storage.writeMeta(folder, meta);
res.json({ friend: meta });
});
// --- Freundesbuch-Ansicht (aggregiert, nur Admin) ---
router.get('/book', requireAdmin, (req, res) => {
const friends = storage.getAllFriendsMeta().map((f) => ({
...f,
share: { enabled: !!(f.share && f.share.enabled) }, // Passwort-Hash nie an den Client senden
}));
res.json({ friends });
});
router.get('/book/pdf', requireAdmin, async (req, res) => {
try {
const pdfBuffer = await generateBookPdf();
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="freundesbuch.pdf"');
res.send(pdfBuffer);
} catch (err) {
console.error('PDF-Export fehlgeschlagen:', err);
res.status(500).json({ error: 'PDF-Export fehlgeschlagen.' });
}
});
module.exports = router;

View File

@@ -0,0 +1,159 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const multer = require('multer');
const { sha256, safeEqual } = require('../utils/hash');
const storage = require('../utils/storage');
const { requireAdminOrFriend } = require('../auth');
const router = express.Router();
function loadFriendOr404(req, res, next) {
const folder = storage.findFolderById(req.params.id);
if (!folder) return res.status(404).json({ error: 'Freund nicht gefunden.' });
req.friendFolder = folder;
req.friendMeta = storage.readMeta(folder);
next();
}
// --- Login über Freigabe-Link (kein Admin nötig) ---
router.post('/share/:id/login', loadFriendOr404, (req, res) => {
const meta = req.friendMeta;
if (!meta.share || !meta.share.enabled || !meta.share.passwordHash) {
return res.status(403).json({ error: 'Diese Freigabe ist nicht (mehr) aktiv.' });
}
const { password } = req.body || {};
const hash = sha256(password || '');
if (!safeEqual(hash, meta.share.passwordHash)) {
return res.status(401).json({ error: 'Falsches Passwort.' });
}
req.session.role = 'friend';
req.session.friendId = meta.id;
res.json({ ok: true, name: meta.name });
});
// alles unterhalb: Admin ODER der eingeloggte Freund selbst
router.use('/friends/:id', loadFriendOr404, requireAdminOrFriend((req) => req.friendMeta.id));
router.get('/friends/:id', (req, res) => {
const meta = req.friendMeta;
res.json({ friend: { ...meta, share: { enabled: !!(meta.share && meta.share.enabled) } } });
});
router.put('/friends/:id/steckbrief', (req, res) => {
const meta = req.friendMeta;
meta.steckbrief = { ...meta.steckbrief, ...(req.body || {}) };
storage.writeMeta(req.friendFolder, meta);
res.json({ friend: meta });
});
// --- Datei-Uploads (Profilbild, Bild-/Audio-Blöcke) ---
function makeUpload() {
const storageEngine = multer.diskStorage({
destination: (req, file, cb) => {
const dir = path.join(storage.friendFolderPath(req.friendFolder), 'uploads');
fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (req, file, cb) => {
const id = crypto.randomBytes(6).toString('hex');
const ext = path.extname(file.originalname || '').toLowerCase() || guessExt(file.mimetype);
cb(null, `${id}${ext}`);
},
});
return multer({
storage: storageEngine,
limits: { fileSize: 40 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const allowed = /^image\/(png|jpe?g|gif|webp)$|^audio\/(mpeg|mp3|wav|ogg|x-m4a|mp4)$/i;
if (allowed.test(file.mimetype)) return cb(null, true);
cb(new Error('Dateityp nicht erlaubt.'));
},
});
}
function guessExt(mime) {
if (mime === 'image/jpeg') return '.jpg';
if (mime === 'image/png') return '.png';
if (mime === 'image/gif') return '.gif';
if (mime === 'image/webp') return '.webp';
if (mime.startsWith('audio/')) return '.mp3';
return '';
}
const upload = makeUpload();
router.post('/friends/:id/profileimage', upload.single('image'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Kein Bild erhalten.' });
const meta = req.friendMeta;
// altes Profilbild aufräumen
if (meta.profileImage) {
const oldPath = path.join(storage.friendFolderPath(req.friendFolder), 'uploads', meta.profileImage);
fs.rm(oldPath, { force: true }, () => {});
}
meta.profileImage = req.file.filename;
storage.writeMeta(req.friendFolder, meta);
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.' });
}
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;
}
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;
const block = meta.blocks.find((b) => b.id === req.params.blockId);
if (!block) return res.status(404).json({ error: 'Block nicht gefunden.' });
if (typeof req.body.content === 'string') block.content = req.body.content;
if (typeof req.body.caption === 'string') block.caption = req.body.caption;
if (typeof req.body.order === 'number') block.order = req.body.order;
storage.writeMeta(req.friendFolder, meta);
res.json({ friend: meta });
});
router.delete('/friends/:id/blocks/:blockId', (req, res) => {
const meta = req.friendMeta;
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 }, () => {});
}
storage.writeMeta(req.friendFolder, meta);
res.json({ friend: meta });
});
router.post('/friends/:id/blocks/reorder', (req, res) => {
const meta = req.friendMeta;
const order = req.body.order || []; // Array von Block-IDs in neuer Reihenfolge
order.forEach((blockId, i) => {
const b = meta.blocks.find((x) => x.id === blockId);
if (b) b.order = i;
});
meta.blocks.sort((a, b) => a.order - b.order);
storage.writeMeta(req.friendFolder, meta);
res.json({ friend: meta });
});
module.exports = router;

View File

@@ -0,0 +1,27 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const storage = require('../utils/storage');
const router = express.Router();
// /uploads/<folderName>/<filename>
router.get('/:folder/:filename', (req, res) => {
const { folder, filename } = req.params;
const session = req.session || {};
const isAdmin = session.role === 'admin';
const isMatchingFriend = session.role === 'friend' && folder.endsWith('-' + session.friendId);
if (!isAdmin && !isMatchingFriend) {
return res.status(401).send('Nicht angemeldet.');
}
const safeFolder = path.basename(folder);
const safeFile = path.basename(filename);
const filePath = path.join(storage.friendFolderPath(safeFolder), 'uploads', safeFile);
if (!fs.existsSync(filePath)) return res.status(404).send('Nicht gefunden.');
res.sendFile(filePath);
});
module.exports = router;

14
backend/src/utils/hash.js Normal file
View File

@@ -0,0 +1,14 @@
const crypto = require('crypto');
function sha256(input) {
return crypto.createHash('sha256').update(String(input), 'utf8').digest('hex');
}
function safeEqual(a, b) {
const bufA = Buffer.from(String(a || ''), 'utf8');
const bufB = Buffer.from(String(b || ''), 'utf8');
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
module.exports = { sha256, safeEqual };

View File

@@ -0,0 +1,115 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DATA_DIR = process.env.DATA_DIR || '/data';
const FRIENDS_DIR = path.join(DATA_DIR, 'friends');
function ensureBaseDirs() {
fs.mkdirSync(FRIENDS_DIR, { recursive: true });
}
function slugify(name) {
return String(name)
.trim()
.toLowerCase()
.replace(/[äÄ]/g, 'ae')
.replace(/[öÖ]/g, 'oe')
.replace(/[üÜ]/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40) || 'freund';
}
function randomId() {
return crypto.randomBytes(4).toString('hex');
}
function friendFolderPath(folderName) {
return path.join(FRIENDS_DIR, folderName);
}
function metaPath(folderName) {
return path.join(friendFolderPath(folderName), 'meta.json');
}
function listFriendFolders() {
ensureBaseDirs();
return fs
.readdirSync(FRIENDS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
}
function readMeta(folderName) {
const p = metaPath(folderName);
if (!fs.existsSync(p)) return null;
try {
return JSON.parse(fs.readFileSync(p, 'utf8'));
} catch (e) {
return null;
}
}
function writeMeta(folderName, meta) {
const p = metaPath(folderName);
fs.writeFileSync(p, JSON.stringify(meta, null, 2), 'utf8');
}
function findFolderById(id) {
return listFriendFolders().find((f) => f.endsWith('-' + id) || f === id);
}
function createFriend(name) {
ensureBaseDirs();
const id = randomId();
const folderName = `${slugify(name)}-${id}`;
const dir = friendFolderPath(folderName);
fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(path.join(dir, 'uploads'), { recursive: true });
const meta = {
id,
folderName,
name,
createdAt: new Date().toISOString(),
profileImage: null,
steckbrief: {},
blocks: [],
share: {
enabled: false,
passwordHash: null,
},
};
writeMeta(folderName, meta);
return meta;
}
function deleteFriend(folderName) {
const dir = friendFolderPath(folderName);
fs.rmSync(dir, { recursive: true, force: true });
}
function getAllFriendsMeta() {
return listFriendFolders()
.map((f) => readMeta(f))
.filter(Boolean)
.sort((a, b) => a.name.localeCompare(b.name, 'de'));
}
module.exports = {
DATA_DIR,
FRIENDS_DIR,
ensureBaseDirs,
slugify,
randomId,
friendFolderPath,
metaPath,
listFriendFolders,
readMeta,
writeMeta,
findFolderById,
createFriend,
deleteFriend,
getAllFriendsMeta,
};