init push
This commit is contained in:
4
backend/.dockerignore
Normal file
4
backend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
data.json
|
||||
recipes.db*
|
||||
.env
|
||||
12
backend/Dockerfile
Normal file
12
backend/Dockerfile
Normal 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
42
backend/data.json
Normal 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
202
backend/db.js
Normal 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
1073
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
backend/package.json
Normal file
16
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
25
backend/prompts/extractRecipe.js
Normal file
25
backend/prompts/extractRecipe.js
Normal 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
BIN
backend/recipes.db-shm
Normal file
Binary file not shown.
BIN
backend/recipes.db-wal
Normal file
BIN
backend/recipes.db-wal
Normal file
Binary file not shown.
214
backend/routes/auth.js
Normal file
214
backend/routes/auth.js
Normal 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
91
backend/routes/recipes.js
Normal 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
30
backend/server.js
Normal 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
39
backend/services/ai.js
Normal 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);
|
||||
}
|
||||
20
backend/services/tiktok.js
Normal file
20
backend/services/tiktok.js
Normal 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 || '',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user