const express = require('express'); const cors = require('cors'); const Database = require('better-sqlite3'); const { v4: uuidv4 } = require('uuid'); const path = require('path'); const fs = require('fs'); const app = express(); const PORT = process.env.PORT || 3000; const MAX_PROFILES = 5; const DEFAULT_PROFILE_NAMES = { 1: 'Profil 1', 2: 'Profil 2', 3: 'Profil 3', 4: 'Profil 4', 5: 'Profil 5' }; const PROFILE_SCOPE_COOKIE = 'fb_tracker_scope'; const PROFILE_SCOPE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days const FACEBOOK_TRACKING_PARAM_PREFIXES = ['__cft__', '__tn__', '__eep__', 'mibextid']; const SEARCH_POST_HIDE_THRESHOLD = 3; const SEARCH_POST_RETENTION_DAYS = 90; const screenshotDir = path.join(__dirname, 'data', 'screenshots'); if (!fs.existsSync(screenshotDir)) { fs.mkdirSync(screenshotDir, { recursive: true }); } // Middleware - Enhanced CORS for extension app.use(cors({ origin: (origin, callback) => { callback(null, origin || false); }, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true })); // Allow larger payloads because screenshots from high-res monitors can easily exceed 10 MB app.use(express.json({ limit: '30mb' })); // Additional CORS headers for extension compatibility app.use((req, res, next) => { const origin = req.headers.origin; const host = req.headers.host; const fallbackOrigin = host ? `${isSecureRequest(req) ? 'https' : 'http'}://${host}` : '*'; res.header('Access-Control-Allow-Origin', origin || fallbackOrigin); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.header('Access-Control-Allow-Credentials', 'true'); if (req.method === 'OPTIONS') { res.sendStatus(204); return; } next(); }); // Assign per-browser profile scopes via cookies app.use(ensureProfileScope); // Database setup const dbPath = path.join(__dirname, 'data', 'tracker.db'); const db = new Database(dbPath); function parseCookies(header) { if (!header || typeof header !== 'string') { return {}; } return header.split(';').reduce((acc, part) => { const index = part.indexOf('='); if (index === -1) { const key = part.trim(); if (key) { acc[key] = ''; } return acc; } const key = part.slice(0, index).trim(); const value = part.slice(index + 1).trim(); if (key) { acc[key] = decodeURIComponent(value); } return acc; }, {}); } function isSecureRequest(req) { if (req.secure) { return true; } const forwardedProto = req.headers['x-forwarded-proto']; if (typeof forwardedProto === 'string') { return forwardedProto.split(',').map(value => value.trim().toLowerCase()).includes('https'); } return false; } function buildScopeCookieValue(scopeId, req) { const secure = isSecureRequest(req); const attributes = [ `${PROFILE_SCOPE_COOKIE}=${encodeURIComponent(scopeId)}`, 'Path=/', `Max-Age=${PROFILE_SCOPE_MAX_AGE}` ]; if (secure) { attributes.push('Secure', 'SameSite=None'); } else { attributes.push('SameSite=Lax'); } return attributes.join('; '); } function appendScopeCookie(res, value) { const existing = res.getHeader('Set-Cookie'); if (!existing) { res.setHeader('Set-Cookie', value); } else if (Array.isArray(existing)) { res.setHeader('Set-Cookie', [...existing, value]); } else { res.setHeader('Set-Cookie', [existing, value]); } } function ensureProfileScope(req, res, next) { const cookies = parseCookies(req.headers.cookie); let scopeId = cookies[PROFILE_SCOPE_COOKIE]; if (!scopeId) { scopeId = uuidv4(); } appendScopeCookie(res, buildScopeCookieValue(scopeId, req)); req.profileScope = scopeId; next(); } function getScopedProfileNumber(scopeId) { if (!scopeId) { return null; } const row = db.prepare('SELECT profile_number FROM profile_state_scoped WHERE scope_id = ?').get(scopeId); if (!row) { return null; } return sanitizeProfileNumber(row.profile_number); } function setScopedProfileNumber(scopeId, profileNumber) { if (!scopeId || !profileNumber) { return; } db.prepare(` INSERT INTO profile_state_scoped (scope_id, profile_number) VALUES (?, ?) ON CONFLICT(scope_id) DO UPDATE SET profile_number = excluded.profile_number `).run(scopeId, profileNumber); } function clampTargetCount(value) { const parsed = parseInt(value, 10); if (Number.isNaN(parsed)) { return 1; } return Math.min(MAX_PROFILES, Math.max(1, parsed)); } function validateTargetCount(value) { const parsed = parseInt(value, 10); if (Number.isNaN(parsed) || parsed < 1 || parsed > MAX_PROFILES) { return null; } return parsed; } function sanitizeProfileNumber(value) { const parsed = parseInt(value, 10); if (Number.isNaN(parsed) || parsed < 1 || parsed > MAX_PROFILES) { return null; } return parsed; } function normalizeDeadline(value) { if (!value && value !== 0) { return null; } if (value instanceof Date) { if (!Number.isNaN(value.getTime())) { return value.toISOString(); } return null; } if (typeof value === 'string') { const trimmed = value.trim(); if (!trimmed) { return null; } const parsed = new Date(trimmed); if (!Number.isNaN(parsed.getTime())) { return parsed.toISOString(); } return null; } if (typeof value === 'number') { const parsed = new Date(value); if (!Number.isNaN(parsed.getTime())) { return parsed.toISOString(); } return null; } return null; } function getProfileName(profileNumber) { return DEFAULT_PROFILE_NAMES[profileNumber] || `Profil ${profileNumber}`; } function normalizeCreatorName(value) { if (typeof value !== 'string') { return null; } const trimmed = value.trim(); if (!trimmed) { return null; } return trimmed.slice(0, 160); } function normalizeFacebookPostUrl(rawValue) { if (typeof rawValue !== 'string') { return null; } let value = rawValue.trim(); if (!value) { return null; } const trackingIndex = value.indexOf('__cft__'); if (trackingIndex !== -1) { value = value.slice(0, trackingIndex); } value = value.replace(/[?&]$/, ''); let parsed; try { parsed = new URL(value); } catch (error) { try { parsed = new URL(value, 'https://www.facebook.com'); } catch (fallbackError) { return null; } } if (!parsed.hostname.toLowerCase().endsWith('facebook.com')) { return null; } const cleanedParams = new URLSearchParams(); parsed.searchParams.forEach((paramValue, paramKey) => { const lowerKey = paramKey.toLowerCase(); if (FACEBOOK_TRACKING_PARAM_PREFIXES.some((prefix) => lowerKey.startsWith(prefix)) || lowerKey === 'set' || lowerKey === 'comment_id') { return; } if (lowerKey === 'hoisted_section_header_type') { return; } cleanedParams.append(paramKey, paramValue); }); const multiPermalinkId = cleanedParams.get('multi_permalinks'); if (multiPermalinkId) { cleanedParams.delete('multi_permalinks'); const groupMatch = parsed.pathname.match(/^\/groups\/([A-Za-z0-9\.\-_]+)/); if (groupMatch && /^[0-9]+$/.test(multiPermalinkId)) { parsed.pathname = `/groups/${groupMatch[1]}/posts/${multiPermalinkId}`; } else if (groupMatch) { parsed.pathname = `/groups/${groupMatch[1]}/permalink/${multiPermalinkId}`; } } const search = cleanedParams.toString(); const formatted = `${parsed.origin}${parsed.pathname}${search ? `?${search}` : ''}`; return formatted.replace(/[?&]$/, ''); } function getRequiredProfiles(targetCount) { const count = clampTargetCount(targetCount); return Array.from({ length: count }, (_, index) => index + 1); } function buildProfileStatuses(requiredProfiles, checks) { const validChecks = checks .map(check => { const profileNumber = sanitizeProfileNumber(check.profile_number); if (!profileNumber) { return null; } return { ...check, profile_number: profileNumber, profile_name: getProfileName(profileNumber) }; }) .filter(Boolean); const completedSet = new Set(validChecks.map(check => check.profile_number)); const checkByProfile = new Map(validChecks.map(check => [check.profile_number, check])); const statuses = requiredProfiles.map((profileNumber, index) => { const prerequisites = requiredProfiles.slice(0, index); const prerequisitesMet = prerequisites.every(num => completedSet.has(num)); const isChecked = completedSet.has(profileNumber); return { profile_number: profileNumber, profile_name: getProfileName(profileNumber), status: isChecked ? 'done' : (prerequisitesMet ? 'available' : 'locked'), checked_at: isChecked && checkByProfile.get(profileNumber) ? checkByProfile.get(profileNumber).checked_at : null }; }); return { statuses, completedChecks: validChecks, completedSet }; } function recalcCheckedCount(postId) { const post = db.prepare('SELECT id, target_count, checked_count FROM posts WHERE id = ?').get(postId); if (!post) { return null; } const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ?').all(postId); const requiredProfiles = getRequiredProfiles(post.target_count); const { statuses } = buildProfileStatuses(requiredProfiles, checks); const checkedCount = statuses.filter(status => status.status === 'done').length; const updates = []; const params = []; if (post.checked_count !== checkedCount) { updates.push('checked_count = ?'); params.push(checkedCount); } if (post.target_count !== requiredProfiles.length) { updates.push('target_count = ?'); params.push(requiredProfiles.length); } if (updates.length) { updates.push('last_change = CURRENT_TIMESTAMP'); params.push(postId); db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`).run(...params); } return checkedCount; } // Initialize database tables db.exec(` CREATE TABLE IF NOT EXISTS posts ( id TEXT PRIMARY KEY, url TEXT NOT NULL UNIQUE, title TEXT, target_count INTEGER NOT NULL, checked_count INTEGER DEFAULT 0, screenshot_path TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_change DATETIME DEFAULT CURRENT_TIMESTAMP ); `); db.exec(` CREATE TABLE IF NOT EXISTS checks ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT NOT NULL, profile_number INTEGER, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (post_id) REFERENCES posts(id) ); `); db.exec(` CREATE TABLE IF NOT EXISTS profile_state ( id INTEGER PRIMARY KEY CHECK (id = 1), profile_number INTEGER NOT NULL ); `); db.exec(` CREATE TABLE IF NOT EXISTS profile_state_scoped ( scope_id TEXT PRIMARY KEY, profile_number INTEGER NOT NULL ); `); db.exec(` CREATE TABLE IF NOT EXISTS ai_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), active_credential_id INTEGER, prompt_prefix TEXT, enabled INTEGER DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); db.exec(` CREATE TABLE IF NOT EXISTS ai_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, provider TEXT NOT NULL, api_key TEXT NOT NULL, model TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); db.exec(` CREATE TABLE IF NOT EXISTS profile_friends ( id INTEGER PRIMARY KEY AUTOINCREMENT, profile_number INTEGER NOT NULL, friend_names TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(profile_number) ); `); db.exec(` CREATE TABLE IF NOT EXISTS search_seen_posts ( url TEXT PRIMARY KEY, seen_count INTEGER NOT NULL DEFAULT 1, manually_hidden INTEGER NOT NULL DEFAULT 0, first_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); db.exec(` CREATE INDEX IF NOT EXISTS idx_search_seen_posts_last_seen_at ON search_seen_posts(last_seen_at); `); const ensureColumn = (table, column, definition) => { const columns = db.prepare(`PRAGMA table_info(${table})`).all(); if (!columns.some(col => col.name === column)) { db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`); } }; ensureColumn('posts', 'checked_count', 'checked_count INTEGER DEFAULT 0'); ensureColumn('posts', 'screenshot_path', 'screenshot_path TEXT'); ensureColumn('posts', 'created_by_profile', 'created_by_profile INTEGER'); ensureColumn('posts', 'deadline_at', 'deadline_at DATETIME'); ensureColumn('posts', 'created_by_name', 'created_by_name TEXT'); ensureColumn('posts', 'last_change', 'last_change DATETIME'); ensureColumn('posts', 'is_successful', 'is_successful INTEGER DEFAULT 0'); ensureColumn('ai_settings', 'active_credential_id', 'active_credential_id INTEGER'); ensureColumn('ai_settings', 'prompt_prefix', 'prompt_prefix TEXT'); ensureColumn('ai_settings', 'enabled', 'enabled INTEGER DEFAULT 0'); ensureColumn('ai_credentials', 'is_active', 'is_active INTEGER DEFAULT 1'); ensureColumn('ai_credentials', 'priority', 'priority INTEGER DEFAULT 0'); ensureColumn('ai_credentials', 'base_url', 'base_url TEXT'); ensureColumn('search_seen_posts', 'manually_hidden', 'manually_hidden INTEGER NOT NULL DEFAULT 0'); db.prepare(` UPDATE posts SET last_change = COALESCE( last_change, (SELECT MAX(checked_at) FROM checks WHERE checks.post_id = posts.id), created_at, CURRENT_TIMESTAMP ) WHERE last_change IS NULL `).run(); function touchPost(postId) { if (!postId) { return; } try { db.prepare('UPDATE posts SET last_change = CURRENT_TIMESTAMP WHERE id = ?').run(postId); } catch (error) { console.warn(`Failed to update last_change for post ${postId}:`, error.message); } } function normalizeExistingPostUrls() { const rows = db.prepare('SELECT id, url FROM posts').all(); let updatedCount = 0; for (const row of rows) { const cleaned = normalizeFacebookPostUrl(row.url); if (!cleaned || cleaned === row.url) { continue; } const conflict = db.prepare('SELECT id FROM posts WHERE url = ?').get(cleaned); if (conflict && conflict.id !== row.id) { console.warn(`Skipping URL normalization for post ${row.id} due to existing post ${conflict.id}`); continue; } try { db.prepare('UPDATE posts SET url = ? WHERE id = ?').run(cleaned, row.id); updatedCount += 1; } catch (error) { if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { console.warn(`Skipping URL normalization for post ${row.id} because the cleaned URL is already used.`); continue; } console.warn(`Failed to normalize URL for post ${row.id}:`, error.message); } } if (updatedCount) { console.log(`Normalized URLs for ${updatedCount} stored posts.`); } } normalizeExistingPostUrls(); function cleanupExpiredSearchPosts() { try { const threshold = `-${SEARCH_POST_RETENTION_DAYS} day`; db.prepare(` DELETE FROM search_seen_posts WHERE last_seen_at < DATETIME('now', ?) `).run(threshold); } catch (error) { console.warn('Failed to cleanup expired search posts:', error.message); } } function collectNormalizedFacebookUrls(primaryUrl, candidates = []) { const normalized = []; const pushNormalized = (value) => { const normalizedUrl = normalizeFacebookPostUrl(value); if (normalizedUrl && !normalized.includes(normalizedUrl)) { normalized.push(normalizedUrl); } }; if (primaryUrl) { pushNormalized(primaryUrl); } if (Array.isArray(candidates)) { for (const candidate of candidates) { pushNormalized(candidate); } } return normalized; } function removeSearchSeenEntries(urls) { if (!Array.isArray(urls) || urls.length === 0) { return; } const uniqueValidUrls = Array.from(new Set(urls.filter(url => typeof url === 'string' && url.trim()))); if (!uniqueValidUrls.length) { return; } const stmt = db.prepare('DELETE FROM search_seen_posts WHERE url = ?'); const runDeletion = db.transaction((values) => { for (const value of values) { stmt.run(value); } }); try { runDeletion(uniqueValidUrls); } catch (error) { console.warn('Failed to remove search seen entries:', error.message); } } cleanupExpiredSearchPosts(); const selectSearchSeenStmt = db.prepare('SELECT url, seen_count, manually_hidden, first_seen_at, last_seen_at FROM search_seen_posts WHERE url = ?'); const insertSearchSeenStmt = db.prepare(` INSERT INTO search_seen_posts (url, seen_count, manually_hidden, first_seen_at, last_seen_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) `); const updateSearchSeenStmt = db.prepare(` UPDATE search_seen_posts SET seen_count = ?, manually_hidden = ?, last_seen_at = CURRENT_TIMESTAMP WHERE url = ? `); const deleteSearchSeenStmt = db.prepare('DELETE FROM search_seen_posts WHERE url = ?'); const selectTrackedPostStmt = db.prepare('SELECT id FROM posts WHERE url = ?'); const checkIndexes = db.prepare("PRAGMA index_list('checks')").all(); for (const idx of checkIndexes) { if (idx.unique) { // Skip auto indexes created from PRIMARY KEY/UNIQUE constraints; SQLite refuses to drop them if (idx.origin !== 'c' || (idx.name && idx.name.startsWith('sqlite_autoindex'))) { continue; } const info = db.prepare(`PRAGMA index_info('${idx.name}')`).all(); const columns = info.map(i => i.name).join(','); if (columns === 'post_id,profile_number' || columns === 'profile_number,post_id') { db.exec(`DROP INDEX IF EXISTS "${idx.name}"`); } } } function sqliteTimestampToUTC(timestamp) { if (!timestamp) { return null; } // SQLite CURRENT_TIMESTAMP returns UTC time in format "YYYY-MM-DD HH:MM:SS" // Convert to ISO-8601 with Z suffix to indicate UTC return timestamp.replace(' ', 'T') + 'Z'; } function mapPostRow(post) { if (!post) { return null; } const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ? ORDER BY checked_at ASC').all(post.id); const requiredProfiles = getRequiredProfiles(post.target_count); const { statuses, completedChecks } = buildProfileStatuses(requiredProfiles, checks); const checkedCount = statuses.filter(status => status.status === 'done').length; const screenshotFile = post.screenshot_path ? path.join(screenshotDir, post.screenshot_path) : null; const screenshotPath = screenshotFile && fs.existsSync(screenshotFile) ? `/api/posts/${post.id}/screenshot` : null; let postLastChange = post.last_change; if (post.checked_count !== checkedCount || post.target_count !== requiredProfiles.length) { const updates = []; const params = []; if (post.checked_count !== checkedCount) { updates.push('checked_count = ?'); params.push(checkedCount); } if (post.target_count !== requiredProfiles.length) { updates.push('target_count = ?'); params.push(requiredProfiles.length); } updates.push('last_change = CURRENT_TIMESTAMP'); params.push(post.id); db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`).run(...params); const refreshed = db.prepare('SELECT last_change FROM posts WHERE id = ?').get(post.id); if (refreshed && refreshed.last_change) { postLastChange = refreshed.last_change; } } const nextRequired = statuses.find(status => status.status === 'available'); const creatorProfile = sanitizeProfileNumber(post.created_by_profile); const creatorName = normalizeCreatorName(post.created_by_name); // Convert SQLite timestamps to UTC ISO-8601 format const checksWithUTC = completedChecks.map(check => ({ ...check, checked_at: sqliteTimestampToUTC(check.checked_at) })); const statusesWithUTC = statuses.map(status => ({ ...status, checked_at: sqliteTimestampToUTC(status.checked_at) })); return { ...post, created_at: sqliteTimestampToUTC(post.created_at), target_count: requiredProfiles.length, checked_count: checkedCount, last_change: sqliteTimestampToUTC(postLastChange), checks: checksWithUTC, is_complete: checkedCount >= requiredProfiles.length, screenshot_path: screenshotPath, required_profiles: requiredProfiles, profile_statuses: statusesWithUTC, next_required_profile: nextRequired ? nextRequired.profile_number : null, created_by_profile: creatorProfile, created_by_profile_name: creatorProfile ? getProfileName(creatorProfile) : null, created_by_name: creatorName, deadline_at: post.deadline_at || null }; } // Get all posts app.get('/api/posts', (req, res) => { try { const posts = db.prepare(` SELECT * FROM posts ORDER BY created_at DESC `).all(); res.json(posts.map(mapPostRow)); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get post by URL app.get('/api/posts/by-url', (req, res) => { try { const { url } = req.query; if (!url) { return res.status(400).json({ error: 'URL parameter required' }); } const normalizedUrl = normalizeFacebookPostUrl(url); if (!normalizedUrl) { return res.status(400).json({ error: 'URL parameter must be a valid Facebook link' }); } const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(normalizedUrl); if (!post) { return res.json(null); } res.json(mapPostRow(post)); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/search-posts', (req, res) => { try { const { url, candidates, skip_increment, force_hide } = req.body || {}; const normalizedUrls = collectNormalizedFacebookUrls(url, candidates); if (!normalizedUrls.length) { return res.status(400).json({ error: 'url must be a valid Facebook link' }); } cleanupExpiredSearchPosts(); let isTracked = false; for (const candidate of normalizedUrls) { const tracked = selectTrackedPostStmt.get(candidate); if (tracked) { isTracked = true; deleteSearchSeenStmt.run(candidate); } } if (isTracked) { return res.json({ seen_count: 0, should_hide: false, tracked: true }); } let existingRow = null; let existingUrl = null; for (const candidate of normalizedUrls) { const row = selectSearchSeenStmt.get(candidate); if (row) { existingRow = row; existingUrl = candidate; break; } } const targetUrl = existingUrl || normalizedUrls[0]; const existingManualHidden = existingRow ? !!existingRow.manually_hidden : false; if (force_hide) { const desiredCount = Math.max(existingRow ? existingRow.seen_count : 0, SEARCH_POST_HIDE_THRESHOLD); const urlsToUpdate = Array.from(new Set(normalizedUrls)); for (const candidate of urlsToUpdate) { const row = selectSearchSeenStmt.get(candidate); const candidateCount = row ? Math.max(row.seen_count, desiredCount) : desiredCount; if (row) { updateSearchSeenStmt.run(candidateCount, 1, candidate); } else { insertSearchSeenStmt.run(candidate, candidateCount, 1); } } return res.json({ seen_count: desiredCount, should_hide: true, manually_hidden: true }); } if (skip_increment) { if (!existingRow) { return res.json({ seen_count: 0, should_hide: false, manually_hidden: false }); } const seenCount = existingRow.seen_count; const shouldHide = seenCount >= SEARCH_POST_HIDE_THRESHOLD || existingManualHidden; return res.json({ seen_count: seenCount, should_hide: shouldHide, manually_hidden: existingManualHidden }); } let seenCount = existingRow ? existingRow.seen_count + 1 : 1; const manualHidden = existingManualHidden; if (existingRow) { updateSearchSeenStmt.run(seenCount, manualHidden ? 1 : 0, targetUrl); } else { insertSearchSeenStmt.run(targetUrl, seenCount, manualHidden ? 1 : 0); } const shouldHide = seenCount >= SEARCH_POST_HIDE_THRESHOLD || manualHidden; res.json({ seen_count: seenCount, should_hide: shouldHide, manually_hidden: manualHidden }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.delete('/api/search-posts', (req, res) => { try { db.prepare('DELETE FROM search_seen_posts').run(); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/profile-state', (req, res) => { try { const scopeId = req.profileScope; let profileNumber = getScopedProfileNumber(scopeId); if (!profileNumber) { profileNumber = 1; setScopedProfileNumber(scopeId, profileNumber); } res.json({ profile_number: profileNumber }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/profile-state', (req, res) => { try { const { profile_number } = req.body; if (typeof profile_number === 'undefined') { return res.status(400).json({ error: 'profile_number is required' }); } const parsed = parseInt(profile_number, 10); if (Number.isNaN(parsed) || parsed < 1 || parsed > 5) { return res.status(400).json({ error: 'profile_number must be between 1 and 5' }); } const scopeId = req.profileScope; const sanitized = sanitizeProfileNumber(parsed) || 1; setScopedProfileNumber(scopeId, sanitized); res.json({ profile_number: sanitized }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/posts/:postId/screenshot', (req, res) => { try { const { postId } = req.params; const { imageData } = req.body; const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!post) { return res.status(404).json({ error: 'Post not found' }); } if (!imageData || typeof imageData !== 'string') { return res.status(400).json({ error: 'imageData is required' }); } const match = imageData.match(/^data:(image\/\w+);base64,(.+)$/); if (!match) { return res.status(400).json({ error: 'Invalid image data format' }); } const mimeType = match[1]; const base64 = match[2]; const extension = mimeType === 'image/jpeg' ? 'jpg' : 'png'; const buffer = Buffer.from(base64, 'base64'); const fileName = `${postId}.${extension}`; const filePath = path.join(screenshotDir, fileName); if (post.screenshot_path && post.screenshot_path !== fileName) { const existingPath = path.join(screenshotDir, post.screenshot_path); if (fs.existsSync(existingPath)) { try { fs.unlinkSync(existingPath); } catch (error) { console.warn('Failed to remove previous screenshot:', error.message); } } } fs.writeFileSync(filePath, buffer); db.prepare('UPDATE posts SET screenshot_path = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(fileName, postId); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); res.json(mapPostRow(updatedPost)); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/posts/:postId/screenshot', (req, res) => { try { const { postId } = req.params; const post = db.prepare('SELECT screenshot_path FROM posts WHERE id = ?').get(postId); // Placeholder path (mounted in Docker container) const placeholderPath = path.join(__dirname, 'noScreenshot.png'); if (!post || !post.screenshot_path) { // Return placeholder image if (fs.existsSync(placeholderPath)) { res.set('Cache-Control', 'public, max-age=86400'); return res.sendFile(placeholderPath); } return res.status(404).json({ error: 'Screenshot not found' }); } const filePath = path.join(screenshotDir, post.screenshot_path); if (!fs.existsSync(filePath)) { // Return placeholder image if (fs.existsSync(placeholderPath)) { res.set('Cache-Control', 'public, max-age=86400'); return res.sendFile(placeholderPath); } return res.status(404).json({ error: 'Screenshot not found' }); } res.set('Cache-Control', 'no-store'); res.sendFile(filePath); } catch (error) { res.status(500).json({ error: error.message }); } }); // Create new post app.post('/api/posts', (req, res) => { try { const { url, title, target_count, created_by_profile, created_by_name, profile_number, deadline_at } = req.body; const validatedTargetCount = validateTargetCount(typeof target_count === 'undefined' ? 1 : target_count); const normalizedUrl = normalizeFacebookPostUrl(url); if (!normalizedUrl) { return res.status(400).json({ error: 'URL must be a valid Facebook link' }); } if (!validatedTargetCount) { return res.status(400).json({ error: 'target_count must be between 1 and 5' }); } const id = uuidv4(); let creatorProfile = sanitizeProfileNumber(created_by_profile); if (!creatorProfile) { creatorProfile = sanitizeProfileNumber(profile_number) || null; } let normalizedDeadline = null; if (typeof deadline_at !== 'undefined' && deadline_at !== null && String(deadline_at).trim() !== '') { normalizedDeadline = normalizeDeadline(deadline_at); if (!normalizedDeadline) { return res.status(400).json({ error: 'deadline_at must be a valid date string' }); } } const creatorDisplayName = normalizeCreatorName(created_by_name); const stmt = db.prepare(` INSERT INTO posts (id, url, title, target_count, checked_count, screenshot_path, created_by_profile, created_by_name, deadline_at, last_change) VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, CURRENT_TIMESTAMP) `); stmt.run(id, normalizedUrl, title || '', validatedTargetCount, creatorProfile, creatorDisplayName, normalizedDeadline); const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id); removeSearchSeenEntries([normalizedUrl]); res.json(mapPostRow(post)); } catch (error) { if (error.message.includes('UNIQUE constraint failed')) { res.status(409).json({ error: 'Post with this URL already exists' }); } else { res.status(500).json({ error: error.message }); } } }); app.put('/api/posts/:postId', (req, res) => { try { const { postId } = req.params; const { target_count, title, created_by_profile, created_by_name, deadline_at, url } = req.body || {}; const updates = []; const params = []; let normalizedUrlForCleanup = null; if (typeof target_count !== 'undefined') { const validatedTargetCount = validateTargetCount(target_count); if (!validatedTargetCount) { return res.status(400).json({ error: 'target_count must be between 1 and 5' }); } updates.push('target_count = ?'); params.push(validatedTargetCount); } if (typeof title !== 'undefined') { updates.push('title = ?'); params.push(title || ''); } if (typeof created_by_profile !== 'undefined') { const sanitized = sanitizeProfileNumber(created_by_profile); if (created_by_profile !== null && typeof created_by_profile !== 'undefined' && !sanitized) { return res.status(400).json({ error: 'created_by_profile must be between 1 and 5 or null' }); } updates.push('created_by_profile = ?'); params.push(sanitized || null); } if (typeof created_by_name !== 'undefined') { const normalizedName = normalizeCreatorName(created_by_name); updates.push('created_by_name = ?'); params.push(normalizedName); } if (typeof deadline_at !== 'undefined') { let normalizedDeadline = null; const rawDeadline = deadline_at; if (rawDeadline !== null && String(rawDeadline).trim() !== '') { normalizedDeadline = normalizeDeadline(rawDeadline); if (!normalizedDeadline) { return res.status(400).json({ error: 'deadline_at must be a valid date string' }); } } updates.push('deadline_at = ?'); params.push(normalizedDeadline); } if (typeof url !== 'undefined') { const normalizedUrl = normalizeFacebookPostUrl(url); if (!normalizedUrl) { return res.status(400).json({ error: 'url must be a valid Facebook link' }); } updates.push('url = ?'); params.push(normalizedUrl); normalizedUrlForCleanup = normalizedUrl; } if (!updates.length) { return res.status(400).json({ error: 'No valid fields to update' }); } updates.push('last_change = CURRENT_TIMESTAMP'); params.push(postId); const stmt = db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`); let result; try { result = stmt.run(...params); } catch (error) { if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { return res.status(409).json({ error: 'Post with this URL already exists' }); } throw error; } if (result.changes === 0) { return res.status(404).json({ error: 'Post not found' }); } recalcCheckedCount(postId); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (normalizedUrlForCleanup) { removeSearchSeenEntries([normalizedUrlForCleanup]); } res.json(mapPostRow(updatedPost)); } catch (error) { res.status(500).json({ error: error.message }); } }); // Check a post for a profile app.post('/api/posts/:postId/check', (req, res) => { try { const { postId } = req.params; const { profile_number } = req.body; const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!post) { return res.status(404).json({ error: 'Post not found' }); } // Check if deadline has passed if (post.deadline_at) { const deadline = new Date(post.deadline_at); if (new Date() > deadline) { return res.status(400).json({ error: 'Deadline ist abgelaufen' }); } } const requiredProfiles = getRequiredProfiles(post.target_count); let didChange = false; if (post.target_count !== requiredProfiles.length) { db.prepare('UPDATE posts SET target_count = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(requiredProfiles.length, postId); post.target_count = requiredProfiles.length; didChange = true; } let profileValue = sanitizeProfileNumber(profile_number); if (!profileValue) { const storedProfile = getScopedProfileNumber(req.profileScope); profileValue = storedProfile || requiredProfiles[0]; } if (!requiredProfiles.includes(profileValue)) { return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' }); } const existingCheck = db.prepare( 'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?' ).get(postId, profileValue); if (existingCheck) { const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); return res.json(mapPostRow(existingPost)); } // Allow creator to check immediately, regardless of profile number const isCreator = post.created_by_profile === profileValue; if (requiredProfiles.length > 0 && !isCreator) { const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); if (prerequisiteProfiles.length) { const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId); const completedSet = new Set( completedRows .map(row => sanitizeProfileNumber(row.profile_number)) .filter(Boolean) ); const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); if (missingPrerequisites.length) { return res.status(409).json({ error: 'Vorherige Profile müssen zuerst bestätigen.', missing_profiles: missingPrerequisites }); } } } const insertStmt = db.prepare('INSERT INTO checks (post_id, profile_number) VALUES (?, ?)'); insertStmt.run(postId, profileValue); didChange = true; recalcCheckedCount(postId); if (didChange) { touchPost(postId); } const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); res.json(mapPostRow(updatedPost)); } catch (error) { res.status(500).json({ error: error.message }); } }); // Check by URL (for web interface auto-check) app.post('/api/check-by-url', (req, res) => { try { const { url, profile_number } = req.body; if (!url) { return res.status(400).json({ error: 'URL is required' }); } const normalizedUrl = normalizeFacebookPostUrl(url); if (!normalizedUrl) { return res.status(400).json({ error: 'URL must be a valid Facebook link' }); } const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(normalizedUrl); if (!post) { return res.status(404).json({ error: 'Post not found' }); } // Check if deadline has passed if (post.deadline_at) { const deadline = new Date(post.deadline_at); if (new Date() > deadline) { return res.status(400).json({ error: 'Deadline ist abgelaufen' }); } } const requiredProfiles = getRequiredProfiles(post.target_count); let didChange = false; if (post.target_count !== requiredProfiles.length) { db.prepare('UPDATE posts SET target_count = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(requiredProfiles.length, post.id); post.target_count = requiredProfiles.length; didChange = true; } let profileValue = sanitizeProfileNumber(profile_number); if (!profileValue) { const storedProfile = getScopedProfileNumber(req.profileScope); profileValue = storedProfile || requiredProfiles[0]; } if (!requiredProfiles.includes(profileValue)) { return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' }); } const existingCheck = db.prepare( 'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?' ).get(post.id, profileValue); if (existingCheck) { const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(post.id); return res.json(mapPostRow(updatedPost)); } // Allow creator to check immediately, regardless of profile number const isCreator = post.created_by_profile === profileValue; if (requiredProfiles.length > 0 && !isCreator) { const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); if (prerequisiteProfiles.length) { const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(post.id); const completedSet = new Set( completedRows .map(row => sanitizeProfileNumber(row.profile_number)) .filter(Boolean) ); const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); if (missingPrerequisites.length) { return res.status(409).json({ error: 'Vorherige Profile müssen zuerst bestätigen.', missing_profiles: missingPrerequisites }); } } } db.prepare('INSERT INTO checks (post_id, profile_number) VALUES (?, ?)').run(post.id, profileValue); didChange = true; recalcCheckedCount(post.id); if (didChange) { touchPost(post.id); } const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(post.id); res.json(mapPostRow(updatedPost)); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/posts/:postId/profile-status', (req, res) => { try { const { postId } = req.params; const { profile_number, status } = req.body || {}; const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!post) { return res.status(404).json({ error: 'Post not found' }); } // Check if deadline has passed (only for setting to 'done') if (status === 'done' && post.deadline_at) { const deadline = new Date(post.deadline_at); if (new Date() > deadline) { return res.status(400).json({ error: 'Deadline ist abgelaufen' }); } } const requiredProfiles = getRequiredProfiles(post.target_count); let didChange = false; if (post.target_count !== requiredProfiles.length) { db.prepare('UPDATE posts SET target_count = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(requiredProfiles.length, post.id); post.target_count = requiredProfiles.length; didChange = true; } const profileValue = sanitizeProfileNumber(profile_number); if (!profileValue) { return res.status(400).json({ error: 'Valid profile_number required' }); } if (!requiredProfiles.includes(profileValue)) { return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' }); } const normalizedStatus = status === 'done' ? 'done' : 'pending'; if (normalizedStatus === 'done') { // Allow creator to check immediately, regardless of profile number const isCreator = post.created_by_profile === profileValue; if (!isCreator) { const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue)); if (prerequisiteProfiles.length) { const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId); const completedSet = new Set( completedRows .map(row => sanitizeProfileNumber(row.profile_number)) .filter(Boolean) ); const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num)); if (missingPrerequisites.length) { return res.status(409).json({ error: 'Vorherige Profile müssen zuerst bestätigen.', missing_profiles: missingPrerequisites }); } } } const existingCheck = db.prepare( 'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?' ).get(postId, profileValue); if (!existingCheck) { db.prepare('INSERT INTO checks (post_id, profile_number) VALUES (?, ?)').run(postId, profileValue); didChange = true; } } else { db.prepare('DELETE FROM checks WHERE post_id = ? AND profile_number = ?').run(postId, profileValue); didChange = true; } recalcCheckedCount(postId); if (didChange) { touchPost(postId); } const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); res.json(mapPostRow(updatedPost)); } catch (error) { res.status(500).json({ error: error.message }); } }); // Update post URL or success status app.patch('/api/posts/:postId', (req, res) => { try { const { postId } = req.params; const { url, is_successful } = req.body; // Check if post exists const post = db.prepare('SELECT id FROM posts WHERE id = ?').get(postId); if (!post) { return res.status(404).json({ error: 'Post not found' }); } if (url !== undefined) { const normalizedUrl = normalizeFacebookPostUrl(url); if (!normalizedUrl) { return res.status(400).json({ error: 'Invalid Facebook URL' }); } // Check for URL conflicts const conflict = db.prepare('SELECT id FROM posts WHERE url = ? AND id != ?').get(normalizedUrl, postId); if (conflict) { return res.status(409).json({ error: 'URL already used by another post' }); } // Update URL db.prepare('UPDATE posts SET url = ? WHERE id = ?').run(normalizedUrl, postId); removeSearchSeenEntries([normalizedUrl]); return res.json({ success: true, url: normalizedUrl }); } if (is_successful !== undefined) { const successValue = is_successful ? 1 : 0; db.prepare('UPDATE posts SET is_successful = ? WHERE id = ?').run(successValue, postId); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); return res.json(mapPostRow(updatedPost)); } return res.status(400).json({ error: 'No valid update parameter provided' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Delete post app.delete('/api/posts/:postId', (req, res) => { try { const { postId } = req.params; const post = db.prepare('SELECT screenshot_path FROM posts WHERE id = ?').get(postId); db.prepare('DELETE FROM checks WHERE post_id = ?').run(postId); const result = db.prepare('DELETE FROM posts WHERE id = ?').run(postId); if (result.changes === 0) { return res.status(404).json({ error: 'Post not found' }); } if (post && post.screenshot_path) { const filePath = path.join(screenshotDir, post.screenshot_path); if (fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); } catch (error) { console.warn('Failed to remove screenshot:', error.message); } } } res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // AI Credentials endpoints app.get('/api/ai-credentials', (req, res) => { try { const credentials = db.prepare('SELECT id, name, provider, model, base_url, is_active, priority, created_at, updated_at FROM ai_credentials ORDER BY priority ASC, id ASC').all(); res.json(credentials); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/ai-credentials', (req, res) => { try { const { name, provider, api_key, model, base_url } = req.body; const trimmedName = typeof name === 'string' ? name.trim() : ''; const trimmedProvider = typeof provider === 'string' ? provider.trim() : ''; const trimmedApiKey = typeof api_key === 'string' ? api_key.trim() : ''; const trimmedModel = typeof model === 'string' ? model.trim() : ''; const rawBaseUrl = typeof base_url === 'string' ? base_url.trim() : ''; const normalizedBaseUrl = (trimmedProvider === 'openai' && rawBaseUrl) ? rawBaseUrl.replace(/\/+$/, '') : ''; if (!trimmedName || !trimmedProvider) { return res.status(400).json({ error: 'Name und Provider sind erforderlich' }); } if (normalizedBaseUrl && !/^https?:\/\//i.test(normalizedBaseUrl)) { return res.status(400).json({ error: 'Basis-URL muss mit http:// oder https:// beginnen' }); } let finalApiKey = trimmedApiKey; if (!finalApiKey) { if (trimmedProvider === 'openai' && normalizedBaseUrl) { finalApiKey = ''; } else { return res.status(400).json({ error: 'API-Schlüssel wird benötigt' }); } } const result = db.prepare(` INSERT INTO ai_credentials (name, provider, api_key, model, base_url) VALUES (?, ?, ?, ?, ?) `).run( trimmedName, trimmedProvider, finalApiKey, trimmedModel || null, normalizedBaseUrl || null ); const credential = db.prepare('SELECT id, name, provider, model, base_url, is_active, created_at, updated_at FROM ai_credentials WHERE id = ?').get(result.lastInsertRowid); res.json(credential); } catch (error) { res.status(500).json({ error: error.message }); } }); app.put('/api/ai-credentials/:id', (req, res) => { try { const { id } = req.params; const { name, provider, api_key, model, base_url } = req.body; const credentialId = parseInt(id, 10); const existing = db.prepare('SELECT * FROM ai_credentials WHERE id = ?').get(credentialId); if (!existing) { return res.status(404).json({ error: 'Credential nicht gefunden' }); } const trimmedName = typeof name === 'string' ? name.trim() : existing.name; const trimmedProvider = typeof provider === 'string' ? provider.trim() : existing.provider; if (!trimmedName || !trimmedProvider) { return res.status(400).json({ error: 'Name und Provider sind erforderlich' }); } const trimmedModel = typeof model === 'string' ? model.trim() : existing.model || ''; const rawBaseUrl = typeof base_url === 'string' ? base_url.trim() : (existing.base_url || ''); const normalizedBaseUrl = (trimmedProvider === 'openai' && rawBaseUrl) ? rawBaseUrl.replace(/\/+$/, '') : ''; let apiKeyProvided = api_key !== undefined; let trimmedApiKey = typeof api_key === 'string' ? api_key.trim() : ''; if (!apiKeyProvided) { trimmedApiKey = existing.api_key; } else if (!trimmedApiKey && !(trimmedProvider === 'openai' && normalizedBaseUrl)) { return res.status(400).json({ error: 'API-Schlüssel wird benötigt' }); } if (normalizedBaseUrl && !/^https?:\/\//i.test(normalizedBaseUrl)) { return res.status(400).json({ error: 'Basis-URL muss mit http:// oder https:// beginnen' }); } if (trimmedProvider === 'openai' && !trimmedApiKey && !normalizedBaseUrl) { return res.status(400).json({ error: 'Für OpenAI wird ein API-Schlüssel benötigt, wenn keine Basis-URL angegeben ist' }); } db.prepare(` UPDATE ai_credentials SET name = ?, provider = ?, api_key = ?, model = ?, base_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `).run( trimmedName, trimmedProvider, trimmedApiKey, trimmedModel || null, normalizedBaseUrl || null, credentialId ); const credential = db.prepare('SELECT id, name, provider, model, base_url, is_active, created_at, updated_at FROM ai_credentials WHERE id = ?').get(credentialId); res.json(credential); } catch (error) { res.status(500).json({ error: error.message }); } }); app.patch('/api/ai-credentials/:id', (req, res) => { try { const { id } = req.params; const { is_active } = req.body; if (is_active === undefined) { return res.status(400).json({ error: 'is_active is required' }); } db.prepare(` UPDATE ai_credentials SET is_active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `).run(is_active, id); const credential = db.prepare('SELECT id, name, provider, model, base_url, is_active, priority, created_at, updated_at FROM ai_credentials WHERE id = ?').get(id); res.json(credential); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/ai-credentials/reorder', (req, res) => { try { const { order } = req.body; // Array of IDs in new order if (!Array.isArray(order)) { return res.status(400).json({ error: 'order must be an array' }); } // Update priorities based on order order.forEach((id, index) => { db.prepare('UPDATE ai_credentials SET priority = ? WHERE id = ?').run(index, id); }); const credentials = db.prepare('SELECT id, name, provider, model, base_url, is_active, priority, created_at, updated_at FROM ai_credentials ORDER BY priority ASC, id ASC').all(); res.json(credentials); } catch (error) { res.status(500).json({ error: error.message }); } }); app.delete('/api/ai-credentials/:id', (req, res) => { try { const { id } = req.params; // Check if this credential is active const settings = db.prepare('SELECT active_credential_id FROM ai_settings WHERE id = 1').get(); if (settings && settings.active_credential_id === parseInt(id)) { return res.status(400).json({ error: 'Cannot delete active credential' }); } db.prepare('DELETE FROM ai_credentials WHERE id = ?').run(id); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // AI Settings endpoints app.get('/api/ai-settings', (req, res) => { try { let settings = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); if (!settings) { settings = { id: 1, active_credential_id: null, prompt_prefix: 'Schreibe einen freundlichen, authentischen Kommentar auf Deutsch zu folgendem Facebook-Post. Der Kommentar soll natürlich wirken und maximal 2-3 Sätze lang sein:\n\n', enabled: 0, updated_at: null }; } // Get active credential if set let activeCredential = null; if (settings.active_credential_id) { activeCredential = db.prepare('SELECT id, name, provider, model FROM ai_credentials WHERE id = ?').get(settings.active_credential_id); } res.json({ ...settings, active_credential: activeCredential }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.put('/api/ai-settings', (req, res) => { try { const { active_credential_id, prompt_prefix, enabled } = req.body; const existing = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); if (existing) { db.prepare(` UPDATE ai_settings SET active_credential_id = ?, prompt_prefix = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1 `).run(active_credential_id || null, prompt_prefix, enabled ? 1 : 0); } else { db.prepare(` INSERT INTO ai_settings (id, active_credential_id, prompt_prefix, enabled, updated_at) VALUES (1, ?, ?, ?, CURRENT_TIMESTAMP) `).run(active_credential_id || null, prompt_prefix, enabled ? 1 : 0); } const updated = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); let activeCredential = null; if (updated.active_credential_id) { activeCredential = db.prepare('SELECT id, name, provider, model FROM ai_credentials WHERE id = ?').get(updated.active_credential_id); } res.json({ ...updated, active_credential: activeCredential }); } catch (error) { res.status(500).json({ error: error.message }); } }); function sanitizeAIComment(text) { if (!text) { return ''; } return text .replace(/[\s\S]*?<\/think>/gi, '') .replace(/\s{2,}/g, ' ') .trim(); } async function tryGenerateComment(credential, promptPrefix, postText) { const provider = credential.provider; const apiKey = credential.api_key; const model = credential.model; let comment = ''; if (provider === 'gemini') { // Gemini API const modelName = model || 'gemini-2.0-flash-exp'; const prompt = promptPrefix + postText; const response = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }) } ); if (!response.ok) { const errorData = await response.json(); throw new Error(`Gemini API error: ${errorData.error?.message || response.statusText}`); } const data = await response.json(); comment = data.candidates?.[0]?.content?.parts?.[0]?.text || ''; } else if (provider === 'openai') { // OpenAI/ChatGPT API const modelName = model || 'gpt-3.5-turbo'; const prompt = promptPrefix + postText; const baseUrl = (credential.base_url || 'https://api.openai.com/v1').trim().replace(/\/+$/, ''); const endpoint = baseUrl.endsWith('/chat/completions') ? baseUrl : `${baseUrl}/chat/completions`; const headers = { 'Content-Type': 'application/json' }; if (apiKey) { headers['Authorization'] = `Bearer ${apiKey}`; } const response = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ model: modelName, messages: [{ role: 'user', content: prompt }], max_tokens: 150 }) }); if (!response.ok) { const errorData = await response.json(); const message = errorData.error?.message || response.statusText; throw new Error(`OpenAI API error: ${message}`); } const data = await response.json(); comment = data.choices?.[0]?.message?.content || ''; } else if (provider === 'claude') { // Anthropic Claude API const modelName = model || 'claude-3-5-haiku-20241022'; const prompt = promptPrefix + postText; const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }, body: JSON.stringify({ model: modelName, max_tokens: 150, messages: [{ role: 'user', content: prompt }] }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(`Claude API error: ${errorData.error?.message || response.statusText}`); } const data = await response.json(); comment = data.content?.[0]?.text || ''; } else { throw new Error(`Unsupported AI provider: ${provider}`); } return sanitizeAIComment(comment); } app.post('/api/ai/generate-comment', async (req, res) => { try { const { postText, profileNumber, preferredCredentialId } = req.body; if (!postText) { return res.status(400).json({ error: 'postText is required' }); } const settings = db.prepare('SELECT * FROM ai_settings WHERE id = 1').get(); if (!settings || !settings.enabled) { return res.status(400).json({ error: 'AI comment generation is not enabled' }); } // Get all active credentials, ordered by priority const credentials = db.prepare('SELECT * FROM ai_credentials WHERE is_active = 1 ORDER BY priority ASC, id ASC').all(); if (!credentials || credentials.length === 0) { return res.status(400).json({ error: 'No active AI credentials available' }); } let orderedCredentials = credentials; if (typeof preferredCredentialId !== 'undefined' && preferredCredentialId !== null) { const parsedPreferredId = Number(preferredCredentialId); if (!Number.isNaN(parsedPreferredId)) { const idx = credentials.findIndex(credential => credential.id === parsedPreferredId); if (idx > 0) { const preferred = credentials[idx]; orderedCredentials = [preferred, ...credentials.slice(0, idx), ...credentials.slice(idx + 1)]; } } } let promptPrefix = settings.prompt_prefix || ''; // Get friend names for the profile if available if (profileNumber) { const friends = db.prepare('SELECT friend_names FROM profile_friends WHERE profile_number = ?').get(profileNumber); if (friends && friends.friend_names) { promptPrefix = promptPrefix.replace('{FREUNDE}', friends.friend_names); } else { promptPrefix = promptPrefix.replace('{FREUNDE}', ''); } } else { promptPrefix = promptPrefix.replace('{FREUNDE}', ''); } // Try each active credential until one succeeds let lastError = null; for (const credential of orderedCredentials) { try { console.log(`Trying credential: ${credential.name} (ID: ${credential.id})`); const comment = await tryGenerateComment(credential, promptPrefix, postText); console.log(`Success with credential: ${credential.name}`); return res.json({ comment, usedCredential: credential.name }); } catch (error) { console.error(`Failed with credential ${credential.name}:`, error.message); lastError = error; // Continue to next credential } } // If we get here, all credentials failed throw lastError || new Error('All AI credentials failed'); } catch (error) { console.error('AI comment generation error:', error); res.status(500).json({ error: error.message }); } }); // ============================================================================ // PROFILE FRIENDS API // ============================================================================ // Get friends for a profile app.get('/api/profile-friends/:profileNumber', (req, res) => { try { const profileNumber = parseInt(req.params.profileNumber); const friends = db.prepare('SELECT * FROM profile_friends WHERE profile_number = ?').get(profileNumber); if (!friends) { return res.json({ profile_number: profileNumber, friend_names: '' }); } res.json(friends); } catch (error) { console.error('Error fetching profile friends:', error); res.status(500).json({ error: error.message }); } }); // Update friends for a profile app.put('/api/profile-friends/:profileNumber', (req, res) => { try { const profileNumber = parseInt(req.params.profileNumber); const { friend_names } = req.body; if (friend_names === undefined) { return res.status(400).json({ error: 'friend_names is required' }); } const existing = db.prepare('SELECT * FROM profile_friends WHERE profile_number = ?').get(profileNumber); if (existing) { db.prepare('UPDATE profile_friends SET friend_names = ?, updated_at = CURRENT_TIMESTAMP WHERE profile_number = ?') .run(friend_names, profileNumber); } else { db.prepare('INSERT INTO profile_friends (profile_number, friend_names) VALUES (?, ?)') .run(profileNumber, friend_names); } const updated = db.prepare('SELECT * FROM profile_friends WHERE profile_number = ?').get(profileNumber); res.json(updated); } catch (error) { console.error('Error updating profile friends:', error); res.status(500).json({ error: error.message }); } }); // Health check app.get('/health', (req, res) => { res.json({ status: 'ok' }); }); app.listen(PORT, '0.0.0.0', () => { console.log(`Server running on port ${PORT}`); });