This commit is contained in:
nadja
2026-11-01 20:14:25 +01:00
parent 828c38c779
commit c4685c2152
1686 changed files with 570761 additions and 560 deletions

145
backend/dist/analytics-db.js vendored Normal file
View File

@@ -0,0 +1,145 @@
import Database from 'better-sqlite3';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_FILE = process.env.ANALYTICS_PATH || join(__dirname, 'analytics.db');
const db = new Database(DB_FILE);
db.pragma('journal_mode = WAL');
// Tabelle erstellen falls nicht vorhanden
db.exec(`
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT,
user_id INTEGER,
timestamp TEXT,
metadata TEXT
);
`);
function now() {
return new Date().toISOString().replace('T', ' ').slice(0, 19);
}
export function trackEvent(type, userId, metadata = {}) {
const timestamp = now();
const metaStr = JSON.stringify(metadata);
const stmt = db.prepare('INSERT INTO events (type, user_id, timestamp, metadata) VALUES (?, ?, ?, ?)');
const info = stmt.run(type, userId || null, timestamp, metaStr);
return {
id: info.lastInsertRowid,
type,
user_id: userId || null,
timestamp,
...metadata,
};
}
export function getEvents({ from, to, type, user_id, limit = 100 } = {}) {
let query = 'SELECT * FROM events WHERE 1=1';
const params = [];
if (from) {
query += ' AND timestamp >= ?';
params.push(from);
}
if (to) {
query += ' AND timestamp <= ?';
params.push(to);
}
if (type) {
query += ' AND type = ?';
params.push(type);
}
if (user_id) {
query += ' AND user_id = ?';
params.push(Number(user_id));
}
query += ' ORDER BY timestamp DESC LIMIT ?';
params.push(limit);
const rows = db.prepare(query).all(...params);
return rows.map(r => {
let meta = {};
try {
meta = JSON.parse(r.metadata || '{}');
}
catch { }
return {
id: r.id,
type: r.type,
user_id: r.user_id,
timestamp: r.timestamp,
...meta,
};
});
}
export function getOverview() {
const today = new Date().toISOString().slice(0, 10);
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const allPageviews = db.prepare("SELECT timestamp FROM events WHERE type = 'page_view'").all();
const pageviewsToday = allPageviews.filter(e => e.timestamp.startsWith(today)).length;
const pageviewsYesterday = allPageviews.filter(e => e.timestamp.startsWith(yesterday)).length;
const pageviewsWeek = allPageviews.filter(e => e.timestamp.slice(0, 10) >= weekAgo).length;
const pageviewsTotal = allPageviews.length;
const activeUsersTotal = db.prepare("SELECT COUNT(DISTINCT user_id) as count FROM events WHERE user_id IS NOT NULL").get().count;
const activeUsersToday = db.prepare("SELECT COUNT(DISTINCT user_id) as count FROM events WHERE user_id IS NOT NULL AND timestamp LIKE ?").get(`${today}%`).count;
const recipesCreated = db.prepare("SELECT COUNT(*) as count FROM events WHERE type = 'recipe_created'").get().count;
const recipesUpdated = db.prepare("SELECT COUNT(*) as count FROM events WHERE type = 'recipe_updated'").get().count;
const recipesDeleted = db.prepare("SELECT COUNT(*) as count FROM events WHERE type = 'recipe_deleted'").get().count;
const logins = db.prepare("SELECT COUNT(*) as count FROM events WHERE type = 'user_login'").get().count;
return {
pageviews: { today: pageviewsToday, yesterday: pageviewsYesterday, week: pageviewsWeek, total: pageviewsTotal },
activeUsers: { total: activeUsersTotal, today: activeUsersToday },
recipes: { created: recipesCreated, updated: recipesUpdated, deleted: recipesDeleted },
logins,
};
}
export function getPageviewsByDay(from, to) {
let query = "SELECT SUBSTR(timestamp, 1, 10) as day, COUNT(*) as count FROM events WHERE type = 'page_view'";
const params = [];
if (from) {
query += " AND SUBSTR(timestamp, 1, 10) >= ?";
params.push(from);
}
if (to) {
query += " AND SUBSTR(timestamp, 1, 10) <= ?";
params.push(to);
}
query += " GROUP BY day ORDER BY day ASC";
const rows = db.prepare(query).all(...params);
return rows.map(r => ({ date: r.day, count: r.count }));
}
export function getTopUsers(limit = 10) {
const rows = db.prepare(`
SELECT user_id,
SUM(CASE WHEN type = 'user_login' THEN 1 ELSE 0 END) as logins,
SUM(CASE WHEN type = 'recipe_created' THEN 1 ELSE 0 END) as recipes_created,
SUM(CASE WHEN type = 'recipe_updated' THEN 1 ELSE 0 END) as recipes_updated,
COUNT(*) as total_events
FROM events
WHERE user_id IS NOT NULL
GROUP BY user_id
ORDER BY total_events DESC
LIMIT ?
`).all(limit);
return rows;
}
export function getTopRecipes(limit = 10) {
const pageviews = db.prepare("SELECT metadata FROM events WHERE type = 'page_view'").all();
const recipeViews = {};
for (const row of pageviews) {
try {
const meta = JSON.parse(row.metadata || '{}');
if (meta.path && meta.path.startsWith('/recipe/')) {
const id = meta.path.split('/')[2];
if (id) {
recipeViews[id] = (recipeViews[id] || 0) + 1;
}
}
}
catch { }
}
return Object.entries(recipeViews)
.map(([recipe_id, views]) => ({ recipe_id: Number(recipe_id), views }))
.sort((a, b) => b.views - a.views)
.slice(0, limit);
}
export function getRecentActivity(limit = 50) {
return getEvents({ limit });
}

147
backend/dist/db.js vendored Normal file
View File

@@ -0,0 +1,147 @@
import Database from 'better-sqlite3';
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, 'recipes.db');
const db = new Database(DB_FILE);
db.pragma('journal_mode = WAL');
function now() {
return new Date().toISOString().replace('T', ' ').slice(0, 19);
}
export function getDb() {
return db;
}
export function getAllRecipes(search, category, userId) {
let query = 'SELECT * FROM recipes WHERE 1=1';
const params = [];
if (search) {
query += ' AND (LOWER(name) LIKE ? OR LOWER(description) LIKE ? OR LOWER(ingredients) LIKE ?)';
const term = `%${search.toLowerCase()}%`;
params.push(term, term, term);
}
if (category && category !== 'Alle') {
query += ' AND category = ?';
params.push(category);
}
if (userId) {
query += ' AND user_id = ?';
params.push(Number(userId));
}
query += ' ORDER BY created_at DESC';
return db.prepare(query).all(...params);
}
export function getRecipeById(id) {
return db.prepare('SELECT * FROM recipes WHERE id = ?').get(Number(id));
}
export function createRecipe(recipe) {
const nowStr = now();
const stmt = db.prepare(`
INSERT INTO recipes (
name, description, ingredients, steps, category,
source, source_url, source_author, tiktok_url, tiktok_author,
thumbnail_url, user_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(recipe.name, recipe.description || '', typeof recipe.ingredients === 'string' ? recipe.ingredients : JSON.stringify(recipe.ingredients || []), typeof recipe.steps === 'string' ? recipe.steps : JSON.stringify(recipe.steps || []), recipe.category || 'Sonstiges', recipe.source || 'tiktok', recipe.source_url || recipe.tiktok_url || null, recipe.source_author || recipe.tiktok_author || null, recipe.tiktok_url || recipe.source_url || null, recipe.tiktok_author || recipe.source_author || null, recipe.thumbnail_url || null, recipe.user_id || null, nowStr, nowStr);
return getRecipeById(info.lastInsertRowid);
}
export function updateRecipe(id, recipe) {
const existing = getRecipeById(id);
if (!existing)
return undefined;
const nowStr = now();
const stmt = db.prepare(`
UPDATE recipes SET
name = ?, description = ?, ingredients = ?, steps = ?, category = ?,
source = ?, source_url = ?, source_author = ?, tiktok_url = ?, tiktok_author = ?,
thumbnail_url = ?, updated_at = ?
WHERE id = ?
`);
stmt.run(recipe.name !== undefined ? recipe.name : existing.name, recipe.description !== undefined ? recipe.description : existing.description, recipe.ingredients !== undefined ? (typeof recipe.ingredients === 'string' ? recipe.ingredients : JSON.stringify(recipe.ingredients)) : existing.ingredients, recipe.steps !== undefined ? (typeof recipe.steps === 'string' ? recipe.steps : JSON.stringify(recipe.steps)) : existing.steps, recipe.category || existing.category, recipe.source || existing.source, recipe.source_url || recipe.tiktok_url || existing.source_url, recipe.source_author || recipe.tiktok_author || existing.source_author, recipe.tiktok_url || recipe.source_url || existing.tiktok_url, recipe.tiktok_author || recipe.source_author || existing.tiktok_author, recipe.thumbnail_url !== undefined ? recipe.thumbnail_url : existing.thumbnail_url, nowStr, Number(id));
return getRecipeById(id);
}
export function deleteRecipe(id) {
db.prepare('DELETE FROM recipes WHERE id = ?').run(Number(id));
}
export function getCategories() {
return db.prepare(`
SELECT category, COUNT(*) as count
FROM recipes
GROUP BY category
ORDER BY count DESC
`).all();
}
export function getUserByUsername(username) {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
}
export function getUserById(id) {
return db.prepare('SELECT * FROM users WHERE id = ?').get(Number(id));
}
export function createUser(username, hashedPassword, isAdmin = false) {
const nowStr = now();
const stmt = db.prepare('INSERT INTO users (username, password, is_admin, created_at) VALUES (?, ?, ?, ?)');
const info = stmt.run(username, hashedPassword, isAdmin ? 1 : 0, nowStr);
return { id: info.lastInsertRowid, username, is_admin: isAdmin ? 1 : 0, created_at: nowStr };
}
export function updateUserCredentials(userId, { username, password }) {
const user = getUserById(userId);
if (!user)
return null;
let query = 'UPDATE users SET ';
const params = [];
const updates = [];
if (username) {
updates.push('username = ?');
params.push(username);
}
if (password) {
updates.push('password = ?');
params.push(password);
}
if (updates.length === 0)
return { id: user.id, username: user.username, is_admin: user.is_admin, created_at: user.created_at };
query += updates.join(', ') + ' WHERE id = ?';
params.push(Number(userId));
db.prepare(query).run(...params);
return getUserById(userId) || null;
}
export function createInviteToken(token, createdBy) {
const nowStr = now();
db.prepare('INSERT INTO invite_tokens (token, created_by, created_at) VALUES (?, ?, ?)').run(token, Number(createdBy), nowStr);
}
export function getInviteToken(token) {
return db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(token);
}
export function useInviteToken(token, userId) {
const nowStr = now();
db.prepare('UPDATE invite_tokens SET used_by = ?, used_at = ? WHERE token = ?').run(Number(userId), nowStr, token);
}
export function getInvitesByUser(userId) {
return db.prepare(`
SELECT i.*, u.username as used_by_username
FROM invite_tokens i
LEFT JOIN users u ON i.used_by = u.id
WHERE i.created_by = ?
ORDER BY i.created_at DESC
`).all(Number(userId));
}
export function getAllUsers() {
return db.prepare('SELECT id, username, is_admin, created_at FROM users ORDER BY created_at DESC').all();
}
export function deleteUser(id) {
const userId = Number(id);
const user = getUserById(userId);
if (!user)
return false;
const deleteTrans = db.transaction(() => {
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
db.prepare('DELETE FROM invite_tokens WHERE created_by = ?').run(userId);
});
deleteTrans();
return true;
}
export function getInviteStats() {
const total = db.prepare('SELECT COUNT(*) as count FROM invite_tokens').get().count;
const used = db.prepare('SELECT COUNT(*) as count FROM invite_tokens WHERE used_by IS NOT NULL').get().count;
return { total, used, available: total - used };
}

27
backend/dist/middleware.js vendored Normal file
View File

@@ -0,0 +1,27 @@
import jwt from 'jsonwebtoken';
import { getUserByUsername } from './db.js';
const JWT_SECRET = process.env.JWT_SECRET || 'tiktok-rezepte-secret-key-change-in-production';
export 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' });
}
}
export function adminMiddleware(req, res, next) {
if (!req.user || !req.user.is_admin) {
return res.status(403).json({ error: 'Keine Berechtigung' });
}
next();
}

21
backend/dist/prompts/extractRecipe.js vendored Normal file
View File

@@ -0,0 +1,21 @@
export const SYSTEM_PROMPT = `Du bist ein Rezept-Parser. Extrahiere Rezepte aus Text und antworte NUR mit einem einzigen JSON-Objekt.
JSON-Format:
{
"name": "Name des Rezepts",
"description": "Kurze Beschreibung (1-2 Sätze)",
"ingredients": ["Menge Zutat", "Menge Zutat"],
"steps": ["Schritt 1", "Schritt 2"],
"category": "Kategorie"
}
WICHTIGE REGELN:
- Jede Zutat MUSS eine Mengenangabe UND den Namen der Zutat enthalten
- Zutatenformat: "Menge Einheit Zutat" (z.B. "125 ml Milch", "150 g Mehl", "4 Bananen")
- Jede Zutat aus dem Originaltext als EINEN Eintrag in der ingredients-Array
- KEINE leeren Strings in der ingredients-Array
- "etwas Öl" oder "Etwas Öl" sind gültige Zutaten mit Mengenangabe
- Steps als klare Schritte mit der Zubereitung
- Kategorie: Hauptgericht, Dessert, Snack, Getränk oder Sonstiges
- Alle Texte auf Deutsch
- NUR das JSON ausgeben, kein anderer Text, kein Markdown, kein Code-Block`;

32
backend/dist/routes/analytics.js vendored Normal file
View File

@@ -0,0 +1,32 @@
import { Router } from 'express';
import { getOverview, getPageviewsByDay, getTopUsers, getTopRecipes, getEvents } from '../analytics-db.js';
import { getUserById } from '../db.js';
const router = Router();
router.get('/overview', (req, res) => {
res.json(getOverview());
});
router.get('/pageviews', (req, res) => {
const { from, to } = req.query;
res.json(getPageviewsByDay(from, to));
});
router.get('/users', (req, res) => {
const topUsers = getTopUsers(20);
const enriched = topUsers.map(u => {
const user = getUserById(u.user_id);
return { ...u, username: user ? user.username : `User #${u.user_id}` };
});
res.json(enriched);
});
router.get('/recipes', (req, res) => {
res.json(getTopRecipes(20));
});
router.get('/activity', (req, res) => {
const { user_id, limit } = req.query;
const events = getEvents({ user_id, limit: limit ? Number(limit) : 50 });
const enriched = events.map(e => {
const user = e.user_id ? getUserById(e.user_id) : null;
return { ...e, username: user ? user.username : null };
});
res.json(enriched);
});
export default router;

175
backend/dist/routes/auth.js vendored Normal file
View File

@@ -0,0 +1,175 @@
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';
import { authMiddleware, adminMiddleware } from '../middleware.js';
import { trackEvent } from '../analytics-db.js';
const router = Router();
const JWT_SECRET = process.env.JWT_SECRET || 'tiktok-rezepte-secret-key-change-in-production';
const JWT_EXPIRES_IN = '7d';
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);
trackEvent('user_register', user.id, { username: user.username });
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 });
trackEvent('user_login', user.id, { username: user.username });
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);
if (!updated)
return res.status(500).json({ error: 'Aktualisierung fehlgeschlagen' });
const token = jwt.sign({ id: updated.id, username: updated.username }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
res.json({ user: { id: updated.id, username: updated.username, is_admin: !!updated.is_admin, created_at: updated.created_at }, token });
}
catch (error) {
res.status(500).json({ error: 'Fehler beim Aktualisieren' });
}
});
export default router;

105
backend/dist/routes/recipes.js vendored Normal file
View File

@@ -0,0 +1,105 @@
/// <reference path="../types.d.ts" />
import { Router } from 'express';
import { getAllRecipes, getRecipeById, createRecipe, updateRecipe, deleteRecipe, getCategories, getDb } from '../db.js';
import { getTikTokData } from '../services/tiktok.js';
import { isAiAvailable, extractRecipe } from '../services/ai.js';
import { trackEvent } from '../analytics-db.js';
const router = Router();
router.get('/', (req, res) => {
const { search, category, user_id } = req.query;
const recipes = getAllRecipes(search, category, user_id);
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('/authors', (req, res) => {
const db = getDb();
const recipes = db.prepare('SELECT DISTINCT user_id FROM recipes WHERE user_id IS NOT NULL').all();
const authorIds = recipes.map(r => r.user_id);
const authors = authorIds.map(id => {
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id);
return { id, username: user ? user.username : `User #${id}` };
}).sort((a, b) => a.username.localeCompare(b.username));
res.json(authors);
});
router.get('/ai-status', async (req, res) => {
const available = await isAiAvailable();
res.json({ ai: 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);
const userId = req.body.user_id || null;
trackEvent('recipe_created', userId, { recipe_id: recipe.id, recipe_name: recipe.name });
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);
trackEvent('recipe_updated', req.body.user_id || null, { recipe_id: Number(req.params.id), recipe_name: updated?.name });
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);
trackEvent('recipe_deleted', null, { recipe_id: Number(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 aiAvailable = await isAiAvailable();
let extracted = null;
let aiStatus = aiAvailable ? 'available' : 'unavailable';
let aiMessage = '';
if (aiAvailable && tiktokData.title) {
try {
extracted = await extractRecipe(tiktokData.title);
aiStatus = 'success';
}
catch (e) {
console.error('KI Extraktion fehlgeschlagen:', e.message);
aiStatus = 'error';
aiMessage = e.message;
}
}
else if (!aiAvailable) {
aiMessage = 'KI-Service nicht erreichbar';
}
else if (!tiktokData.title) {
aiMessage = 'Kein Titel im Video gefunden';
}
res.json({
tiktok: tiktokData,
ai: aiAvailable,
aiStatus,
aiMessage,
extracted,
});
}
catch (error) {
res.status(500).json({ error: error.message });
}
});
export default router;

64
backend/dist/server.js vendored Normal file
View File

@@ -0,0 +1,64 @@
import express from 'express';
import cors from 'cors';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import recipesRouter from './routes/recipes.js';
import authRouter from './routes/auth.js';
import analyticsRouter from './routes/analytics.js';
import { getUserByUsername, createUser } from './db.js';
import { trackEvent } from './analytics-db.js';
import { authMiddleware, adminMiddleware } from './middleware.js';
const app = express();
const PORT = process.env.PORT || 3001;
const JWT_SECRET = process.env.JWT_SECRET || 'tiktok-rezepte-secret-key-change-in-production';
app.use(cors());
app.use(express.json());
app.use((req, res, next) => {
if (req.method === 'OPTIONS' || !req.path.startsWith('/api/'))
return next();
if (req.path.startsWith('/api/analytics'))
return next();
try {
let userId = null;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
try {
const decoded = jwt.verify(authHeader.split(' ')[1], JWT_SECRET);
const user = getUserByUsername(decoded.username);
if (user)
userId = user.id;
}
catch { }
}
trackEvent('page_view', userId, { path: req.path, method: req.method });
}
catch { }
next();
});
app.use('/api/auth', authRouter);
app.use('/api/recipes', recipesRouter);
app.use('/api/analytics', authMiddleware, adminMiddleware, analyticsRouter);
app.use((err, req, res, next) => {
console.error('Server Fehler:', err.message);
if (!res.headersSent) {
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err.message);
});
process.on('unhandledRejection', (err) => {
console.error('Unhandled Rejection:', err?.message || err);
});
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(Number(PORT), () => {
console.log(`Backend läuft auf http://localhost:${PORT}`);
});

62
backend/dist/services/ai.js vendored Normal file
View File

@@ -0,0 +1,62 @@
import { SYSTEM_PROMPT } from '../prompts/extractRecipe.js';
const GROQ_API_KEY = process.env.GROQ_API_KEY;
const GROQ_MODEL = process.env.GROQ_MODEL || 'openai/gpt-oss-20b';
const GROQ_URL = 'https://api.groq.com/openai/v1/chat/completions';
export async function isAiAvailable() {
if (!GROQ_API_KEY)
return false;
try {
const res = await fetch(GROQ_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${GROQ_API_KEY}`,
},
body: JSON.stringify({
model: GROQ_MODEL,
messages: [{ role: 'user', content: 'ping' }],
max_tokens: 1,
}),
signal: AbortSignal.timeout(10000),
});
return res.ok;
}
catch {
return false;
}
}
export async function extractRecipe(description) {
const response = await fetch(GROQ_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${GROQ_API_KEY}`,
},
body: JSON.stringify({
model: GROQ_MODEL,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `Extrahiere ein Rezept aus dieser TikTok-Videobeschreibung:\n\n${description}` },
],
temperature: 0.1,
max_tokens: 2048,
}),
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Groq Fehler: ${response.status} - ${err}`);
}
const data = await response.json();
let text = data.choices[0].message.content.trim();
if (text.startsWith('```')) {
text = text.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
}
const jsonStart = text.indexOf('{');
const jsonEnd = text.lastIndexOf('}');
if (jsonStart === -1 || jsonEnd === -1 || jsonEnd <= jsonStart) {
throw new Error('Kein valides JSON in der Modell-Antwort');
}
text = text.slice(jsonStart, jsonEnd + 1);
return JSON.parse(text);
}

15
backend/dist/services/tiktok.js vendored Normal file
View File

@@ -0,0 +1,15 @@
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 || '',
};
}