Updates
This commit is contained in:
145
backend/dist/analytics-db.js
vendored
Normal file
145
backend/dist/analytics-db.js
vendored
Normal 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
147
backend/dist/db.js
vendored
Normal 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
27
backend/dist/middleware.js
vendored
Normal 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
21
backend/dist/prompts/extractRecipe.js
vendored
Normal 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
32
backend/dist/routes/analytics.js
vendored
Normal 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
175
backend/dist/routes/auth.js
vendored
Normal 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
105
backend/dist/routes/recipes.js
vendored
Normal 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
64
backend/dist/server.js
vendored
Normal 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
62
backend/dist/services/ai.js
vendored
Normal 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
15
backend/dist/services/tiktok.js
vendored
Normal 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 || '',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user