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 = 2; 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); db.pragma('foreign_keys = ON'); 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(); const isSingleUnitParam = lowerKey === 's' && paramValue === 'single_unit'; if ( FACEBOOK_TRACKING_PARAM_PREFIXES.some((prefix) => lowerKey.startsWith(prefix)) || lowerKey === 'set' || lowerKey === 'comment_id' || lowerKey === 'hoisted_section_header_type' || isSingleUnitParam ) { 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 post_urls ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT NOT NULL, url TEXT NOT NULL UNIQUE, is_primary INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE ); `); db.exec(` CREATE INDEX IF NOT EXISTS idx_post_urls_post_id ON post_urls(post_id); `); db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_post_urls_primary ON post_urls(post_id) WHERE is_primary = 1; `); db.prepare(` INSERT OR IGNORE INTO post_urls (post_id, url, is_primary) SELECT id, url, 1 FROM posts `).run(); 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('ai_credentials', 'last_used_at', 'last_used_at DATETIME'); ensureColumn('ai_credentials', 'last_success_at', 'last_success_at DATETIME'); ensureColumn('ai_credentials', 'last_error_message', 'last_error_message TEXT'); ensureColumn('ai_credentials', 'last_error_at', 'last_error_at DATETIME'); ensureColumn('ai_credentials', 'last_status_code', 'last_status_code INTEGER'); ensureColumn('ai_credentials', 'last_rate_limit_remaining', 'last_rate_limit_remaining TEXT'); ensureColumn('ai_credentials', 'rate_limit_reset_at', 'rate_limit_reset_at DATETIME'); ensureColumn('ai_credentials', 'auto_disabled', 'auto_disabled INTEGER DEFAULT 0'); ensureColumn('ai_credentials', 'auto_disabled_reason', 'auto_disabled_reason TEXT'); ensureColumn('ai_credentials', 'auto_disabled_until', 'auto_disabled_until DATETIME'); ensureColumn('ai_credentials', 'usage_24h_count', 'usage_24h_count INTEGER DEFAULT 0'); ensureColumn('ai_credentials', 'usage_24h_reset_at', 'usage_24h_reset_at DATETIME'); ensureColumn('search_seen_posts', 'manually_hidden', 'manually_hidden INTEGER NOT NULL DEFAULT 0'); db.exec(` CREATE TABLE IF NOT EXISTS ai_usage_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, credential_id INTEGER NOT NULL, event_type TEXT NOT NULL, status_code INTEGER, message TEXT, metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (credential_id) REFERENCES ai_credentials(id) ON DELETE CASCADE ); `); db.exec(` CREATE INDEX IF NOT EXISTS idx_ai_usage_events_credential_created ON ai_usage_events(credential_id, created_at DESC); `); 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 truncateString(value, maxLength) { if (typeof value !== 'string') { return value; } if (!Number.isFinite(maxLength) || maxLength <= 0) { return value; } return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; } function ensureIsoDate(value) { if (!value) { return null; } if (value instanceof Date) { const time = value.getTime(); return Number.isNaN(time) ? null : value.toISOString(); } if (typeof value === 'number') { return ensureIsoDate(new Date(value)); } const date = new Date(value); const time = date.getTime(); return Number.isNaN(time) ? null : date.toISOString(); } function parseRetryAfter(value) { if (!value && value !== 0) { return null; } if (typeof value === 'number') { return value >= 0 ? Math.round(value) : null; } const numeric = Number(value); if (!Number.isNaN(numeric)) { return numeric >= 0 ? Math.round(numeric) : null; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return null; } const diffSeconds = Math.round((date.getTime() - Date.now()) / 1000); return diffSeconds >= 0 ? diffSeconds : null; } function parseRateLimitReset(value) { if (!value && value !== 0) { return null; } if (value instanceof Date) { return Number.isNaN(value.getTime()) ? null : value; } if (typeof value === 'number') { if (value > 1e12) { return new Date(value); } if (value > 1e9) { return new Date(value * 1000); } return new Date(Date.now() + value * 1000); } const numeric = Number(value); if (!Number.isNaN(numeric)) { if (numeric > 1e12) { return new Date(numeric); } if (numeric > 1e9) { return new Date(numeric * 1000); } return new Date(Date.now() + numeric * 1000); } const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; } function extractRateLimitInfo(response, provider) { const info = { provider: provider || null }; if (!response || !response.headers || typeof response.headers.get !== 'function') { return info; } const headersOfInterest = {}; const captureKeys = new Set([ 'retry-after', 'x-ratelimit-remaining', 'x-ratelimit-remaining-requests', 'x-ratelimit-reset', 'x-ratelimit-reset-requests', 'x-ratelimit-limit', 'x-rate-limit-reset', 'x-rate-limit-remaining' ]); try { response.headers.forEach((value, key) => { const normalizedKey = key.toLowerCase(); if (captureKeys.has(normalizedKey)) { headersOfInterest[normalizedKey] = value; } }); } catch (error) { console.warn('Failed to iterate rate limit headers:', error.message); } if (Object.keys(headersOfInterest).length) { info.headers = headersOfInterest; } const retryAfterHeader = response.headers.get('retry-after'); const retryAfterSeconds = parseRetryAfter(retryAfterHeader); if (retryAfterSeconds !== null) { info.retryAfterSeconds = retryAfterSeconds; } const remainingHeader = response.headers.get('x-ratelimit-remaining-requests') || response.headers.get('x-ratelimit-remaining') || response.headers.get('x-rate-limit-remaining'); if (remainingHeader !== null && remainingHeader !== undefined) { info.rateLimitRemaining = remainingHeader; } const resetHeader = response.headers.get('x-ratelimit-reset-requests') || response.headers.get('x-ratelimit-reset') || response.headers.get('x-rate-limit-reset'); const resetDate = parseRateLimitReset(resetHeader); if (resetDate) { info.rateLimitResetAt = resetDate.toISOString(); } return info; } const RATE_LIMIT_KEYWORDS = ['rate limit', 'ratelimit', 'quota', 'limit', 'too many requests', 'insufficient_quota', 'billing', 'exceeded', 'exceed']; function determineAutoDisable(error) { if (!error) { return null; } const status = error.status || error.statusCode || null; const baseMessage = typeof error.message === 'string' ? error.message : ''; const errorDetails = error.apiError && typeof error.apiError === 'object' ? (error.apiError.error?.message || error.apiError.error || error.apiError.message || '') : ''; const combinedMessage = `${baseMessage} ${errorDetails}`.toLowerCase(); let isRateLimit = status === 429; if (!isRateLimit && status === 403) { isRateLimit = RATE_LIMIT_KEYWORDS.some(keyword => combinedMessage.includes(keyword)); } if (!isRateLimit && combinedMessage) { isRateLimit = RATE_LIMIT_KEYWORDS.some(keyword => combinedMessage.includes(keyword)); } if (!isRateLimit) { return null; } let retryAfterSeconds = typeof error.retryAfterSeconds === 'number' ? error.retryAfterSeconds : null; if ((!retryAfterSeconds || retryAfterSeconds <= 0) && error.rateLimitResetAt) { const resetDate = new Date(error.rateLimitResetAt); if (!Number.isNaN(resetDate.getTime())) { retryAfterSeconds = Math.round((resetDate.getTime() - Date.now()) / 1000); } } if (!retryAfterSeconds || retryAfterSeconds < 0) { retryAfterSeconds = 900; // 15 minutes fallback } if (retryAfterSeconds < 10) { return null; } const untilDate = new Date(Date.now() + retryAfterSeconds * 1000); const reason = status ? `Rate limit erreicht (HTTP ${status})` : 'Rate limit erreicht'; return { reason, seconds: retryAfterSeconds, until: untilDate }; } function recordAIUsageEvent(credentialId, eventType, options = {}) { if (!credentialId || !eventType) { return; } try { const { statusCode = null, message = null, metadata = null } = options; let metadataJson = null; if (metadata && typeof metadata === 'object') { try { metadataJson = JSON.stringify(metadata); } catch (error) { metadataJson = null; } } else if (typeof metadata === 'string') { metadataJson = metadata; } db.prepare(` INSERT INTO ai_usage_events (credential_id, event_type, status_code, message, metadata) VALUES (?, ?, ?, ?, ?) `).run( credentialId, eventType, statusCode !== undefined ? statusCode : null, message ? truncateString(message, 512) : null, metadataJson ); } catch (error) { console.warn('Failed to record AI usage event:', error.message); } } function updateCredentialUsageOnSuccess(credentialId, info = {}) { if (!credentialId) { return; } try { const row = db.prepare(` SELECT usage_24h_count, usage_24h_reset_at, auto_disabled, auto_disabled_reason, auto_disabled_until, rate_limit_reset_at FROM ai_credentials WHERE id = ? `).get(credentialId) || {}; const now = new Date(); const nowIso = now.toISOString(); let usageCount = Number(row.usage_24h_count) || 0; let usageResetDate = row.usage_24h_reset_at ? new Date(row.usage_24h_reset_at) : null; if (usageResetDate && usageResetDate <= now) { usageCount = 0; usageResetDate = null; } let rateLimitResetIso = ensureIsoDate(info.rateLimitResetAt || row.rate_limit_reset_at); if (rateLimitResetIso) { const rateReset = new Date(rateLimitResetIso); if (rateReset <= now) { rateLimitResetIso = null; } } if (!usageResetDate) { if (rateLimitResetIso) { usageResetDate = new Date(rateLimitResetIso); } else { usageResetDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); } } else if (rateLimitResetIso) { const rateReset = new Date(rateLimitResetIso); if (rateReset < usageResetDate) { usageResetDate = rateReset; } } usageCount += 1; const shouldClearAutoDisable = row.auto_disabled === 1 && (!row.auto_disabled_reason || String(row.auto_disabled_reason).startsWith('AUTO:')); const data = { id: credentialId, last_used_at: nowIso, last_success_at: nowIso, last_status_code: 200, last_rate_limit_remaining: info.rateLimitRemaining || null, rate_limit_reset_at: rateLimitResetIso, usage_24h_count: usageCount, usage_24h_reset_at: usageResetDate ? usageResetDate.toISOString() : null, auto_disabled: shouldClearAutoDisable ? 0 : row.auto_disabled || 0, auto_disabled_reason: shouldClearAutoDisable ? null : row.auto_disabled_reason || null, auto_disabled_until: shouldClearAutoDisable ? null : row.auto_disabled_until || null }; db.prepare(` UPDATE ai_credentials SET last_used_at = @last_used_at, last_success_at = @last_success_at, last_status_code = @last_status_code, last_rate_limit_remaining = @last_rate_limit_remaining, rate_limit_reset_at = @rate_limit_reset_at, usage_24h_count = @usage_24h_count, usage_24h_reset_at = @usage_24h_reset_at, auto_disabled = @auto_disabled, auto_disabled_reason = @auto_disabled_reason, auto_disabled_until = @auto_disabled_until, updated_at = CURRENT_TIMESTAMP WHERE id = @id `).run(data); recordAIUsageEvent(credentialId, 'success', { statusCode: 200, message: 'Kommentar erfolgreich generiert', metadata: { rateLimitRemaining: info.rateLimitRemaining || null, rateLimitResetAt: rateLimitResetIso, usage24hCount: usageCount } }); } catch (error) { console.warn('Failed to update credential success stats:', error.message); } } function updateCredentialUsageOnError(credentialId, error) { if (!credentialId || !error) { return { autoDisabled: false, autoDisabledUntil: null }; } let decision = null; try { decision = determineAutoDisable(error); const now = new Date(); const nowIso = now.toISOString(); const rateLimitResetIso = ensureIsoDate(error.rateLimitResetAt); const rateLimitRemaining = error.rateLimitRemaining !== undefined && error.rateLimitRemaining !== null ? String(error.rateLimitRemaining) : null; const updateData = { id: credentialId, last_used_at: nowIso, last_error_message: truncateString(error.message || 'Unbekannter Fehler', 512), last_error_at: nowIso, last_status_code: error.status || error.statusCode || null, last_rate_limit_remaining: rateLimitRemaining, rate_limit_reset_at: rateLimitResetIso, auto_disabled: decision ? 1 : null, auto_disabled_reason: decision ? `AUTO:${decision.reason}` : null, auto_disabled_until: decision ? decision.until.toISOString() : null }; db.prepare(` UPDATE ai_credentials SET last_used_at = @last_used_at, last_error_message = @last_error_message, last_error_at = @last_error_at, last_status_code = @last_status_code, last_rate_limit_remaining = @last_rate_limit_remaining, rate_limit_reset_at = @rate_limit_reset_at, auto_disabled = CASE WHEN @auto_disabled IS NULL THEN auto_disabled ELSE @auto_disabled END, auto_disabled_reason = CASE WHEN @auto_disabled_reason IS NULL THEN auto_disabled_reason ELSE @auto_disabled_reason END, auto_disabled_until = CASE WHEN @auto_disabled_until IS NULL THEN auto_disabled_until ELSE @auto_disabled_until END, updated_at = CURRENT_TIMESTAMP WHERE id = @id `).run(updateData); const eventMetadata = { provider: error.provider || null, retryAfterSeconds: error.retryAfterSeconds || null, rateLimitResetAt: rateLimitResetIso, rateLimitRemaining, autoDisabled: Boolean(decision) }; recordAIUsageEvent(credentialId, 'error', { statusCode: error.status || error.statusCode || null, message: error.message || 'Unbekannter Fehler', metadata: eventMetadata }); if (decision) { recordAIUsageEvent(credentialId, 'auto_disabled', { statusCode: error.status || error.statusCode || null, message: decision.reason, metadata: { autoDisabledUntil: decision.until.toISOString(), retryAfterSeconds: decision.seconds } }); } } catch (updateError) { console.warn('Failed to update credential error stats:', updateError.message); } return { autoDisabled: Boolean(decision), autoDisabledUntil: decision ? decision.until.toISOString() : null }; } function reactivateExpiredCredentials() { try { const nowIso = new Date().toISOString(); const rows = db.prepare(` SELECT id, auto_disabled_until FROM ai_credentials WHERE auto_disabled = 1 AND auto_disabled_until IS NOT NULL AND auto_disabled_until <= ? `).all(nowIso); for (const row of rows) { db.prepare(` UPDATE ai_credentials SET auto_disabled = 0, auto_disabled_reason = NULL, auto_disabled_until = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? `).run(row.id); recordAIUsageEvent(row.id, 'auto_reenabled', { message: 'Automatisch wieder aktiviert', metadata: { previousUntil: row.auto_disabled_until } }); } } catch (error) { console.warn('Failed to reactivate credentials:', error.message); } } const CREDENTIAL_SELECT_BASE = ` SELECT c.id, c.name, c.provider, c.model, c.base_url, c.is_active, c.priority, c.auto_disabled, c.auto_disabled_reason, c.auto_disabled_until, c.last_used_at, c.last_success_at, c.last_error_message, c.last_error_at, c.last_status_code, c.last_rate_limit_remaining, c.rate_limit_reset_at, c.usage_24h_count, c.usage_24h_reset_at, c.created_at, c.updated_at, e.event_type AS latest_event_type, e.status_code AS latest_event_status_code, e.message AS latest_event_message, e.metadata AS latest_event_metadata, e.created_at AS latest_event_at FROM ai_credentials c LEFT JOIN ( SELECT e1.* FROM ai_usage_events e1 INNER JOIN ( SELECT credential_id, MAX(id) AS latest_id FROM ai_usage_events GROUP BY credential_id ) latest ON latest.credential_id = e1.credential_id AND latest.latest_id = e1.id ) e ON e.credential_id = c.id `; function fetchCredentialRows(options = {}) { const { where = '', params = [], orderBy = 'ORDER BY c.priority ASC, c.id ASC' } = options; const query = `${CREDENTIAL_SELECT_BASE} ${where ? where : ''} ${orderBy}`; return db.prepare(query).all(...params); } function formatCredentialRow(row) { if (!row) { return null; } const now = Date.now(); const formatted = { ...row }; formatted.is_active = Number(row.is_active) === 1 ? 1 : 0; formatted.auto_disabled = Number(row.auto_disabled) === 1; let cooldownSeconds = null; if (row.auto_disabled_until) { const until = Date.parse(row.auto_disabled_until); if (!Number.isNaN(until)) { const diffSeconds = Math.round((until - now) / 1000); cooldownSeconds = diffSeconds > 0 ? diffSeconds : 0; } } formatted.cooldown_remaining_seconds = cooldownSeconds; if (row.latest_event_type) { let metadata = null; if (row.latest_event_metadata) { try { metadata = JSON.parse(row.latest_event_metadata); } catch (error) { metadata = row.latest_event_metadata; } } formatted.latest_event = { type: row.latest_event_type, status_code: row.latest_event_status_code, message: row.latest_event_message, metadata, created_at: row.latest_event_at }; } else { formatted.latest_event = null; } formatted.status = formatted.is_active ? (formatted.auto_disabled ? 'cooldown' : 'active') : 'inactive'; delete formatted.latest_event_type; delete formatted.latest_event_status_code; delete formatted.latest_event_message; delete formatted.latest_event_metadata; delete formatted.latest_event_at; return formatted; } function getAllCredentialsFormatted() { return fetchCredentialRows().map(formatCredentialRow); } function getFormattedCredentialById(id) { const row = fetchCredentialRows({ where: 'WHERE c.id = ?', params: [id], orderBy: '' }); if (!row || !row.length) { return null; } return formatCredentialRow(row[0]); } 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 expandPhotoUrlHostVariants(url) { if (typeof url !== 'string' || !url) { return []; } try { const parsed = new URL(url); const hostname = parsed.hostname.toLowerCase(); if (!hostname.endsWith('facebook.com')) { return []; } const pathname = parsed.pathname.toLowerCase(); if (!pathname.startsWith('/photo')) { return []; } const protocol = parsed.protocol || 'https:'; const search = parsed.search || ''; const hosts = ['www.facebook.com', 'facebook.com', 'm.facebook.com']; const variants = []; for (const candidateHost of hosts) { if (candidateHost === hostname) { continue; } const candidateUrl = `${protocol}//${candidateHost}${parsed.pathname}${search}`; const normalized = normalizeFacebookPostUrl(candidateUrl); if (normalized && normalized !== url && !variants.includes(normalized)) { variants.push(normalized); } } return variants; } catch (error) { return []; } } function collectNormalizedFacebookUrls(primaryUrl, candidates = []) { const normalized = []; const pushNormalized = (value, expandVariants = true) => { const normalizedUrl = normalizeFacebookPostUrl(value); if (normalizedUrl && !normalized.includes(normalizedUrl)) { normalized.push(normalizedUrl); if (expandVariants) { const photoVariants = expandPhotoUrlHostVariants(normalizedUrl); for (const variant of photoVariants) { pushNormalized(variant, false); } } } }; if (primaryUrl) { pushNormalized(primaryUrl); } if (Array.isArray(candidates)) { for (const candidate of candidates) { pushNormalized(candidate); } } return normalized; } function collectPostAlternateUrls(primaryUrl, candidates = []) { const normalizedPrimary = normalizeFacebookPostUrl(primaryUrl); if (!normalizedPrimary) { return []; } const normalized = collectNormalizedFacebookUrls(normalizedPrimary, candidates); return normalized.filter(url => url !== normalizedPrimary); } const insertPostUrlStmt = db.prepare(` INSERT OR IGNORE INTO post_urls (post_id, url, is_primary) VALUES (?, ?, ?) `); const setPrimaryPostUrlStmt = db.prepare(` UPDATE post_urls SET is_primary = CASE WHEN url = ? THEN 1 ELSE 0 END WHERE post_id = ? `); const selectPostByPrimaryUrlStmt = db.prepare('SELECT * FROM posts WHERE url = ?'); const selectPostByAlternateUrlStmt = db.prepare(` SELECT p.* FROM post_urls pu JOIN posts p ON p.id = pu.post_id WHERE pu.url = ? LIMIT 1 `); const selectPostIdByPrimaryUrlStmt = db.prepare('SELECT id FROM posts WHERE url = ?'); const selectPostIdByAlternateUrlStmt = db.prepare('SELECT post_id FROM post_urls WHERE url = ?'); const selectAlternateUrlsForPostStmt = db.prepare(` SELECT url FROM post_urls WHERE post_id = ? AND is_primary = 0 ORDER BY created_at ASC `); function storePostUrls(postId, primaryUrl, additionalUrls = []) { if (!postId || !primaryUrl) { return; } const normalizedPrimary = normalizeFacebookPostUrl(primaryUrl); if (!normalizedPrimary) { return; } insertPostUrlStmt.run(postId, normalizedPrimary, 1); setPrimaryPostUrlStmt.run(normalizedPrimary, postId); if (Array.isArray(additionalUrls)) { for (const candidate of additionalUrls) { const normalized = normalizeFacebookPostUrl(candidate); if (!normalized || normalized === normalizedPrimary) { continue; } insertPostUrlStmt.run(postId, normalized, 0); } } } function findPostIdByUrl(normalizedUrl) { if (!normalizedUrl) { return null; } const primaryRow = selectPostIdByPrimaryUrlStmt.get(normalizedUrl); if (primaryRow && primaryRow.id) { return primaryRow.id; } const alternateRow = selectPostIdByAlternateUrlStmt.get(normalizedUrl); if (alternateRow && alternateRow.post_id) { return alternateRow.post_id; } return null; } function findPostByUrl(normalizedUrl) { if (!normalizedUrl) { return null; } const primary = selectPostByPrimaryUrlStmt.get(normalizedUrl); if (primary) { return primary; } const alternate = selectPostByAlternateUrlStmt.get(normalizedUrl); if (alternate) { return alternate; } return null; } 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 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) })); const alternateUrlRows = selectAlternateUrlsForPostStmt.all(post.id); const alternateUrls = alternateUrlRows.map(row => row.url); 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, alternate_urls: alternateUrls }; } // 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 = findPostByUrl(normalizedUrl); if (!post) { return res.json(null); } const alternates = collectPostAlternateUrls(post.url, [normalizedUrl]); if (alternates.length) { storePostUrls(post.id, post.url, alternates); } 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 trackedPost = null; for (const candidate of normalizedUrls) { const found = findPostByUrl(candidate); if (found) { trackedPost = found; break; } } if (trackedPost) { const alternateUrls = collectPostAlternateUrls(trackedPost.url, normalizedUrls); storePostUrls(trackedPost.id, trackedPost.url, alternateUrls); removeSearchSeenEntries([trackedPost.url, ...alternateUrls]); 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 alternateUrlsInput = Array.isArray(req.body.alternate_urls) ? req.body.alternate_urls : []; 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); const alternateUrls = collectPostAlternateUrls(normalizedUrl, alternateUrlsInput); storePostUrls(id, normalizedUrl, alternateUrls); removeSearchSeenEntries([normalizedUrl, ...alternateUrls]); 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 alternateUrlsInput = Array.isArray(req.body && req.body.alternate_urls) ? req.body.alternate_urls : []; const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!existingPost) { return res.status(404).json({ error: 'Post not found' }); } 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 = ?`); try { 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; } recalcCheckedCount(postId); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); const alternateCandidates = [...alternateUrlsInput]; if (existingPost.url && existingPost.url !== updatedPost.url) { alternateCandidates.push(existingPost.url); } const alternateUrls = collectPostAlternateUrls(updatedPost.url, alternateCandidates); storePostUrls(updatedPost.id, updatedPost.url, alternateUrls); const cleanupUrls = new Set([updatedPost.url]); alternateUrls.forEach(urlValue => cleanupUrls.add(urlValue)); if (normalizedUrlForCleanup && normalizedUrlForCleanup !== updatedPost.url) { cleanupUrls.add(normalizedUrlForCleanup); } removeSearchSeenEntries(Array.from(cleanupUrls)); 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 }); } }); app.post('/api/posts/:postId/urls', (req, res) => { try { const { postId } = req.params; const { urls } = req.body || {}; const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!post) { return res.status(404).json({ error: 'Post not found' }); } const candidateList = Array.isArray(urls) ? urls : []; const alternateUrls = collectPostAlternateUrls(post.url, candidateList); storePostUrls(post.id, post.url, alternateUrls); removeSearchSeenEntries([post.url, ...alternateUrls]); const storedAlternates = selectAlternateUrlsForPostStmt.all(post.id).map(row => row.url); res.json({ success: true, primary_url: post.url, alternate_urls: storedAlternates }); } 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 = findPostByUrl(normalizedUrl); if (!post) { return res.status(404).json({ error: 'Post not found' }); } const alternateUrls = collectPostAlternateUrls(post.url, [normalizedUrl]); storePostUrls(post.id, post.url, alternateUrls); removeSearchSeenEntries([post.url, ...alternateUrls]); // 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 existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!existingPost) { 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); const alternateCandidates = []; if (existingPost.url && existingPost.url !== normalizedUrl) { alternateCandidates.push(existingPost.url); } const alternateUrls = collectPostAlternateUrls(normalizedUrl, alternateCandidates); storePostUrls(postId, normalizedUrl, alternateUrls); removeSearchSeenEntries([normalizedUrl, ...alternateUrls]); 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 { reactivateExpiredCredentials(); const credentials = getAllCredentialsFormatted(); 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 = getFormattedCredentialById(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 = getFormattedCredentialById(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' }); } const isActiveInt = is_active ? 1 : 0; db.prepare(` UPDATE ai_credentials SET is_active = ?, updated_at = CURRENT_TIMESTAMP, auto_disabled = CASE WHEN ? = 1 THEN 0 ELSE auto_disabled END, auto_disabled_reason = CASE WHEN ? = 1 THEN NULL ELSE auto_disabled_reason END, auto_disabled_until = CASE WHEN ? = 1 THEN NULL ELSE auto_disabled_until END WHERE id = ? `).run(isActiveInt, isActiveInt, isActiveInt, isActiveInt, id); const credential = getFormattedCredentialById(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 = getAllCredentialsFormatted(); 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 = getFormattedCredentialById(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 = getFormattedCredentialById(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 = ''; let lastResponse = null; try { if (provider === 'gemini') { 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 }] }] }) } ); lastResponse = response; if (!response.ok) { let errorPayload = null; let message = response.statusText; try { errorPayload = await response.json(); message = errorPayload?.error?.message || message; } catch (jsonError) { try { const textBody = await response.text(); if (textBody) { message = textBody; } } catch (textError) { // ignore } } const rateInfo = extractRateLimitInfo(response, provider); const error = new Error(`Gemini API error: ${message}`); error.status = response.status; error.provider = provider; error.apiError = errorPayload; if (rateInfo.retryAfterSeconds !== undefined) { error.retryAfterSeconds = rateInfo.retryAfterSeconds; } if (rateInfo.rateLimitResetAt) { error.rateLimitResetAt = rateInfo.rateLimitResetAt; } if (rateInfo.rateLimitRemaining !== undefined) { error.rateLimitRemaining = rateInfo.rateLimitRemaining; } error.rateLimitHeaders = rateInfo.headers; throw error; } const data = await response.json(); comment = data.candidates?.[0]?.content?.parts?.[0]?.text || ''; } else if (provider === 'openai') { 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 }) }); lastResponse = response; if (!response.ok) { let errorPayload = null; let message = response.statusText; try { errorPayload = await response.json(); message = errorPayload?.error?.message || message; } catch (jsonError) { try { const textBody = await response.text(); if (textBody) { message = textBody; } } catch (textError) { // ignore } } const rateInfo = extractRateLimitInfo(response, provider); const error = new Error(`OpenAI API error: ${message}`); error.status = response.status; error.provider = provider; error.apiError = errorPayload; if (rateInfo.retryAfterSeconds !== undefined) { error.retryAfterSeconds = rateInfo.retryAfterSeconds; } if (rateInfo.rateLimitResetAt) { error.rateLimitResetAt = rateInfo.rateLimitResetAt; } if (rateInfo.rateLimitRemaining !== undefined) { error.rateLimitRemaining = rateInfo.rateLimitRemaining; } error.rateLimitHeaders = rateInfo.headers; throw error; } const data = await response.json(); comment = data.choices?.[0]?.message?.content || ''; } else if (provider === 'claude') { 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 }] }) }); lastResponse = response; if (!response.ok) { let errorPayload = null; let message = response.statusText; try { errorPayload = await response.json(); message = errorPayload?.error?.message || message; } catch (jsonError) { try { const textBody = await response.text(); if (textBody) { message = textBody; } } catch (textError) { // ignore } } const rateInfo = extractRateLimitInfo(response, provider); const error = new Error(`Claude API error: ${message}`); error.status = response.status; error.provider = provider; error.apiError = errorPayload; if (rateInfo.retryAfterSeconds !== undefined) { error.retryAfterSeconds = rateInfo.retryAfterSeconds; } if (rateInfo.rateLimitResetAt) { error.rateLimitResetAt = rateInfo.rateLimitResetAt; } if (rateInfo.rateLimitRemaining !== undefined) { error.rateLimitRemaining = rateInfo.rateLimitRemaining; } error.rateLimitHeaders = rateInfo.headers; throw error; } const data = await response.json(); comment = data.content?.[0]?.text || ''; } else { throw new Error(`Unsupported AI provider: ${provider}`); } const rateInfo = extractRateLimitInfo(lastResponse, provider); rateInfo.status = lastResponse ? lastResponse.status : null; return { comment: sanitizeAIComment(comment), rateInfo }; } catch (error) { if (error && !error.provider) { error.provider = provider; } throw error; } } 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' }); } reactivateExpiredCredentials(); // Get all active credentials, ordered by priority const credentials = db.prepare(` SELECT * FROM ai_credentials WHERE is_active = 1 AND COALESCE(auto_disabled, 0) = 0 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; const attemptDetails = []; for (const credential of orderedCredentials) { try { console.log(`Trying credential: ${credential.name} (ID: ${credential.id})`); const { comment, rateInfo } = await tryGenerateComment(credential, promptPrefix, postText); console.log(`Success with credential: ${credential.name}`); updateCredentialUsageOnSuccess(credential.id, rateInfo || {}); attemptDetails.push({ credentialId: credential.id, credentialName: credential.name, status: 'success', rateLimitRemaining: rateInfo?.rateLimitRemaining ?? null, rateLimitResetAt: rateInfo?.rateLimitResetAt ?? null }); return res.json({ comment, usedCredential: credential.name, usedCredentialId: credential.id, attempts: attemptDetails, rateLimitInfo: rateInfo || null }); } catch (error) { console.error(`Failed with credential ${credential.name}:`, error.message); lastError = error; const errorUpdate = updateCredentialUsageOnError(credential.id, error); attemptDetails.push({ credentialId: credential.id, credentialName: credential.name, status: 'error', message: error.message, statusCode: error.status || error.statusCode || null, autoDisabled: Boolean(errorUpdate.autoDisabled), autoDisabledUntil: errorUpdate.autoDisabledUntil || null }); // Continue to next credential } } // If we get here, all credentials failed const finalError = lastError || new Error('All AI credentials failed'); finalError.attempts = attemptDetails; throw finalError; } catch (error) { console.error('AI comment generation error:', error); if (error && error.attempts) { res.status(500).json({ error: error.message, attempts: error.attempts }); } else { 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}`); });