init push

This commit is contained in:
nadja
2026-08-25 20:29:14 +02:00
commit 2bb9aab82c
42 changed files with 3966 additions and 0 deletions

4
backend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
data.json
recipes.db*
.env

12
backend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3001
CMD ["node", "server.js"]

42
backend/data.json Normal file
View File

@@ -0,0 +1,42 @@
{
"users": [
{
"id": 1,
"username": "Duda",
"password": "$2a$10$CZau8N7du7NSSn5Vvzb7dOma089slkoX.2rqjryod3AlZ2DOY9uAe",
"is_admin": 1,
"created_at": "2026-08-25 15:45:56"
},
{
"id": 3,
"username": "admin",
"password": "$2a$10$nEk2A5poqbBKGYHOeJ/Bkub1LqTohLeanHNhxHbXMtIRQ3.mb4jwS",
"is_admin": 1,
"created_at": "2026-08-25 18:18:57"
}
],
"recipes": [],
"invite_tokens": [
{
"id": 1,
"token": "50015008cb92902ba41e716702b90467",
"created_by": 1,
"used_by": null,
"created_at": "2026-08-25 15:50:10",
"used_at": null
},
{
"id": 2,
"token": "1c781250ad364d850b6e9084d3fd3d9c",
"created_by": 1,
"used_by": 2,
"created_at": "2026-08-25 16:02:44",
"used_at": "2026-08-25 16:03:26"
}
],
"_counters": {
"users": 3,
"recipes": 1,
"invite_tokens": 2
}
}

202
backend/db.js Normal file
View File

@@ -0,0 +1,202 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_FILE = process.env.DB_PATH || join(__dirname, 'data.json');
function loadDB() {
if (existsSync(DB_FILE)) {
return JSON.parse(readFileSync(DB_FILE, 'utf-8'));
}
return { users: [], recipes: [], invite_tokens: [], _counters: { users: 0, recipes: 0, invite_tokens: 0 } };
}
function saveDB(data) {
writeFileSync(DB_FILE, JSON.stringify(data, null, 2));
}
let db = loadDB();
function nextId(table) {
db._counters[table] = (db._counters[table] || 0) + 1;
return db._counters[table];
}
function now() {
return new Date().toISOString().replace('T', ' ').slice(0, 19);
}
export function getDb() {
return db;
}
export function getAllRecipes(search, category) {
let results = [...db.recipes];
if (search) {
const term = search.toLowerCase();
results = results.filter(r =>
(r.name && r.name.toLowerCase().includes(term)) ||
(r.description && r.description.toLowerCase().includes(term)) ||
(r.ingredients && r.ingredients.toLowerCase().includes(term))
);
}
if (category && category !== 'Alle') {
results = results.filter(r => r.category === category);
}
results.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
return results;
}
export function getRecipeById(id) {
return db.recipes.find(r => r.id === Number(id)) || undefined;
}
export function createRecipe(recipe) {
const id = nextId('recipes');
const nowStr = now();
const row = {
id,
name: recipe.name,
description: recipe.description || '',
ingredients: JSON.stringify(recipe.ingredients || []),
steps: JSON.stringify(recipe.steps || []),
category: recipe.category || 'Sonstiges',
tiktok_url: recipe.tiktok_url || null,
tiktok_author: recipe.tiktok_author || null,
thumbnail_url: recipe.thumbnail_url || null,
user_id: recipe.user_id || null,
created_at: nowStr,
updated_at: nowStr,
};
db.recipes.push(row);
saveDB(db);
return row;
}
export function updateRecipe(id, recipe) {
const idx = db.recipes.findIndex(r => r.id === Number(id));
if (idx === -1) return undefined;
db.recipes[idx] = {
...db.recipes[idx],
name: recipe.name,
description: recipe.description || '',
ingredients: JSON.stringify(recipe.ingredients || []),
steps: JSON.stringify(recipe.steps || []),
category: recipe.category || 'Sonstiges',
tiktok_url: recipe.tiktok_url || null,
tiktok_author: recipe.tiktok_author || null,
thumbnail_url: recipe.thumbnail_url || null,
updated_at: now(),
};
saveDB(db);
return db.recipes[idx];
}
export function deleteRecipe(id) {
const idx = db.recipes.findIndex(r => r.id === Number(id));
if (idx === -1) return;
db.recipes.splice(idx, 1);
saveDB(db);
}
export function getCategories() {
const counts = {};
for (const r of db.recipes) {
counts[r.category] = (counts[r.category] || 0) + 1;
}
return Object.entries(counts)
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count);
}
export function getUserByUsername(username) {
return db.users.find(u => u.username === username) || undefined;
}
export function getUserById(id) {
return db.users.find(u => u.id === Number(id)) || undefined;
}
export function createUser(username, hashedPassword, isAdmin = false) {
const id = nextId('users');
const user = {
id,
username,
password: hashedPassword,
is_admin: isAdmin ? 1 : 0,
created_at: now(),
};
db.users.push(user);
saveDB(db);
return { id, username, is_admin: user.is_admin, created_at: user.created_at };
}
export function updateUserCredentials(userId, { username, password }) {
const user = db.users.find(u => u.id === Number(userId));
if (!user) return null;
if (username) user.username = username;
if (password) user.password = password;
saveDB(db);
return { id: user.id, username: user.username, is_admin: user.is_admin, created_at: user.created_at };
}
export function createInviteToken(token, createdBy) {
const id = nextId('invite_tokens');
db.invite_tokens.push({
id,
token,
created_by: createdBy,
used_by: null,
created_at: now(),
used_at: null,
});
saveDB(db);
}
export function getInviteToken(token) {
return db.invite_tokens.find(i => i.token === token) || undefined;
}
export function useInviteToken(token, userId) {
const inv = db.invite_tokens.find(i => i.token === token);
if (inv) {
inv.used_by = userId;
inv.used_at = now();
saveDB(db);
}
}
export function getInvitesByUser(userId) {
return db.invite_tokens
.filter(i => i.created_by === Number(userId))
.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''))
.map(i => {
const usedByUser = i.used_by ? db.users.find(u => u.id === i.used_by) : null;
return { ...i, used_by_username: usedByUser ? usedByUser.username : null };
});
}
export function getAllUsers() {
return db.users
.map(u => ({ id: u.id, username: u.username, is_admin: u.is_admin, created_at: u.created_at }))
.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
}
export function deleteUser(id) {
const idx = db.users.findIndex(u => u.id === Number(id));
if (idx === -1) return false;
db.users.splice(idx, 1);
db.invite_tokens = db.invite_tokens.filter(i => i.created_by !== Number(id));
saveDB(db);
return true;
}
export function getInviteStats() {
const total = db.invite_tokens.length;
const used = db.invite_tokens.filter(i => i.used_by !== null).length;
return { total, used, available: total - used };
}

1073
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": "tiktok-recipe-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"node-fetch": "^3.3.2"
}
}

View File

@@ -0,0 +1,25 @@
export const SYSTEM_PROMPT = `Du bist ein Assistent der aus TikTok-Videobeschreibungen Rezepte extrahiert.
Du antwortest NUR mit validem JSON, keinem anderen Text.
Das JSON muss dieses Schema haben:
{
"name": "Name des Rezepts",
"description": "Kurze Beschreibung des Gerichts",
"category": "Frühstück|Hauptgericht|Dessert|Snack|Getränk|Sonstiges",
"ingredients": [
{ "name": "Zutat", "amount": "Menge", "unit": "Einheit" }
],
"steps": [
"Schritt 1 Beschreibung",
"Schritt 2 Beschreibung"
]
}
Regeln:
- Extrahiere alle Zutaten mit Menge und Einheit aus dem Text
- Wenn keine Menge angegeben ist, setze amount auf ""
- Wenn keine Einheit angegeben ist, setze unit auf ""
- Kategorie muss eines dieser sein: Frühstück, Hauptgericht, Dessert, Snack, Getränk, Sonstiges
- steps ist eine sortierte Liste der Zubereitungsschritte
- Wenn du die Zubereitung nicht erkennen kannst, gib einen sinnvollen Standard basierend auf dem Rezeptnamen
- Gib NUR das JSON-Objekt zurück, kein Markdown, kein Code-Block`;

BIN
backend/recipes.db-shm Normal file

Binary file not shown.

BIN
backend/recipes.db-wal Normal file

Binary file not shown.

214
backend/routes/auth.js Normal file
View File

@@ -0,0 +1,214 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import {
getUserByUsername, getUserById, createUser, updateUserCredentials, deleteUser,
createInviteToken, getInviteToken, useInviteToken,
getInvitesByUser, getAllUsers, getInviteStats
} from '../db.js';
const router = Router();
const JWT_SECRET = process.env.JWT_SECRET || 'tiktok-rezepte-secret-key-change-in-production';
const JWT_EXPIRES_IN = '7d';
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
try {
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET);
const user = getUserByUsername(decoded.username);
if (!user) return res.status(401).json({ error: 'Benutzer nicht gefunden' });
req.user = user;
next();
} catch {
res.status(401).json({ error: 'Ungültiges Token' });
}
}
function adminMiddleware(req, res, next) {
if (!req.user || !req.user.is_admin) {
return res.status(403).json({ error: 'Keine Berechtigung' });
}
next();
}
router.post('/register', async (req, res) => {
try {
const { username, password, inviteToken } = req.body;
if (!username || !password || !inviteToken) {
return res.status(400).json({ error: 'Benutzername, Passwort und Einladungscode erforderlich' });
}
if (username.length < 3) {
return res.status(400).json({ error: 'Benutzername muss mindestens 3 Zeichen lang sein' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
}
const invite = getInviteToken(inviteToken);
if (!invite) {
return res.status(400).json({ error: 'Ungültiger Einladungscode' });
}
if (invite.used_by) {
return res.status(400).json({ error: 'Einladungscode wurde bereits verwendet' });
}
const existing = getUserByUsername(username);
if (existing) {
return res.status(409).json({ error: 'Benutzername ist bereits vergeben' });
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = createUser(username, hashedPassword);
useInviteToken(inviteToken, user.id);
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
res.status(201).json({ user, token });
} catch (error) {
res.status(500).json({ error: 'Registrierung fehlgeschlagen' });
}
});
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
}
const user = getUserByUsername(username);
if (!user) {
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
}
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
res.json({
user: { id: user.id, username: user.username, is_admin: !!user.is_admin, created_at: user.created_at },
token,
});
} catch (error) {
res.status(500).json({ error: 'Anmeldung fehlgeschlagen' });
}
});
router.get('/me', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
try {
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET);
const user = getUserByUsername(decoded.username);
if (!user) return res.status(401).json({ error: 'Benutzer nicht gefunden' });
res.json({ id: user.id, username: user.username, is_admin: !!user.is_admin, created_at: user.created_at });
} catch {
res.status(401).json({ error: 'Ungültiges Token' });
}
});
router.post('/invite', authMiddleware, adminMiddleware, (req, res) => {
try {
const { count = 1 } = req.body;
const amount = Math.min(Math.max(parseInt(count) || 1, 1), 20);
const tokens = [];
for (let i = 0; i < amount; i++) {
const token = crypto.randomBytes(16).toString('hex');
createInviteToken(token, req.user.id);
tokens.push(token);
}
res.json({ tokens });
} catch (error) {
res.status(500).json({ error: 'Einladung fehlgeschlagen' });
}
});
router.get('/invites', authMiddleware, adminMiddleware, (req, res) => {
try {
const invites = getInvitesByUser(req.user.id);
const stats = getInviteStats();
res.json({ invites, stats });
} catch (error) {
res.status(500).json({ error: 'Fehler beim Laden der Einladungen' });
}
});
router.get('/users', authMiddleware, adminMiddleware, (req, res) => {
try {
const users = getAllUsers();
res.json(users);
} catch (error) {
res.status(500).json({ error: 'Fehler beim Laden der Benutzer' });
}
});
router.delete('/users/:id', authMiddleware, adminMiddleware, (req, res) => {
try {
const userId = Number(req.params.id);
if (userId === req.user.id) {
return res.status(400).json({ error: 'Du kannst dich nicht selbst löschen' });
}
const user = getUserById(userId);
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
deleteUser(userId);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Fehler beim Löschen' });
}
});
router.put('/credentials', authMiddleware, async (req, res) => {
try {
const { currentPassword, newUsername, newPassword } = req.body;
const user = getUserByUsername(req.user.username);
if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
if (currentPassword) {
const valid = await bcrypt.compare(currentPassword, user.password);
if (!valid) return res.status(401).json({ error: 'Aktuelles Passwort ist falsch' });
}
if (newUsername && newUsername !== user.username) {
const exists = getUserByUsername(newUsername);
if (exists) return res.status(409).json({ error: 'Benutzername ist bereits vergeben' });
}
const updates = {};
if (newUsername) updates.username = newUsername;
if (newPassword) {
if (newPassword.length < 6) return res.status(400).json({ error: 'Neues Passwort muss mindestens 6 Zeichen lang sein' });
updates.password = await bcrypt.hash(newPassword, 10);
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'Keine Änderungen angegeben' });
}
const updated = updateUserCredentials(user.id, updates);
const token = jwt.sign({ id: updated.id, username: updated.username }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
res.json({ user: updated, token });
} catch (error) {
res.status(500).json({ error: 'Fehler beim Aktualisieren' });
}
});
export default router;

91
backend/routes/recipes.js Normal file
View File

@@ -0,0 +1,91 @@
import { Router } from 'express';
import {
getAllRecipes, getRecipeById, createRecipe,
updateRecipe, deleteRecipe, getCategories
} from '../db.js';
import { getTikTokData } from '../services/tiktok.js';
import { isOllamaAvailable, extractRecipe } from '../services/ai.js';
const router = Router();
router.get('/', (req, res) => {
const { search, category } = req.query;
const recipes = getAllRecipes(search, category);
res.json(recipes.map(r => ({
...r,
ingredients: JSON.parse(r.ingredients),
steps: JSON.parse(r.steps),
})));
});
router.get('/categories', (req, res) => {
res.json(getCategories());
});
router.get('/ai-status', async (req, res) => {
const available = await isOllamaAvailable();
res.json({ ollama: available });
});
router.get('/:id', (req, res) => {
const recipe = getRecipeById(req.params.id);
if (!recipe) return res.status(404).json({ error: 'Rezept nicht gefunden' });
res.json({
...recipe,
ingredients: JSON.parse(recipe.ingredients),
steps: JSON.parse(recipe.steps),
});
});
router.post('/', (req, res) => {
const recipe = createRecipe(req.body);
res.status(201).json(recipe);
});
router.put('/:id', (req, res) => {
const existing = getRecipeById(req.params.id);
if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden' });
const updated = updateRecipe(req.params.id, req.body);
res.json(updated);
});
router.delete('/:id', (req, res) => {
const existing = getRecipeById(req.params.id);
if (!existing) return res.status(404).json({ error: 'Rezept nicht gefunden' });
deleteRecipe(req.params.id);
res.json({ success: true });
});
router.post('/from-tiktok', async (req, res) => {
try {
const { url } = req.body;
if (!url) return res.status(400).json({ error: 'URL erforderlich' });
const tiktokData = await getTikTokData(url);
const ollamaAvailable = await isOllamaAvailable();
let extracted = null;
let aiStatus = ollamaAvailable ? 'available' : 'unavailable';
if (ollamaAvailable && tiktokData.title) {
try {
extracted = await extractRecipe(tiktokData.title);
aiStatus = 'success';
} catch (e) {
console.error('Ollama Extraktion fehlgeschlagen:', e.message);
aiStatus = 'error';
}
}
res.json({
tiktok: tiktokData,
ollama: ollamaAvailable,
aiStatus,
extracted,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

30
backend/server.js Normal file
View File

@@ -0,0 +1,30 @@
import express from 'express';
import cors from 'cors';
import bcrypt from 'bcryptjs';
import recipesRouter from './routes/recipes.js';
import authRouter from './routes/auth.js';
import { getUserByUsername, createUser } from './db.js';
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
app.use('/api/auth', authRouter);
app.use('/api/recipes', recipesRouter);
const adminUser = process.env.ADMIN_USER || 'admin';
const adminPass = process.env.ADMIN_PASS || 'admin123';
const existing = getUserByUsername(adminUser);
if (!existing) {
const hashed = bcrypt.hashSync(adminPass, 10);
createUser(adminUser, hashed, true);
console.log(`Admin erstellt: ${adminUser} / ${adminPass}`);
console.log('WICHTIG: Passwort nach dem ersten Login ändern!');
}
app.listen(PORT, () => {
console.log(`Backend läuft auf http://localhost:${PORT}`);
});

39
backend/services/ai.js Normal file
View File

@@ -0,0 +1,39 @@
import { SYSTEM_PROMPT } from '../prompts/extractRecipe.js';
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
const MODEL = process.env.OLLAMA_MODEL || 'mistral';
export async function isOllamaAvailable() {
try {
const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) });
return res.ok;
} catch {
return false;
}
}
export async function extractRecipe(description) {
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: MODEL,
system: SYSTEM_PROMPT,
prompt: `Extrahiere ein Rezept aus dieser TikTok-Videobeschreibung:\n\n${description}`,
stream: false,
}),
});
if (!response.ok) {
throw new Error(`Ollama Fehler: ${response.status}`);
}
const data = await response.json();
let text = data.response.trim();
if (text.startsWith('```')) {
text = text.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
}
return JSON.parse(text);
}

View File

@@ -0,0 +1,20 @@
import fetch from 'node-fetch';
export async function getTikTokData(url) {
const oembedUrl = `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`;
const response = await fetch(oembedUrl);
if (!response.ok) {
throw new Error(`TikTok oEmbed Fehler: ${response.status}`);
}
const data = await response.json();
return {
title: data.title || '',
author: data.author_name || '',
authorUrl: data.author_url || '',
thumbnail: data.thumbnail_url || '',
embedHtml: data.html || '',
};
}