Files
PostTracker/backend/server.js
2025-11-21 14:33:34 +01:00

3677 lines
111 KiB
JavaScript

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 crypto = require('crypto');
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 MAX_POST_TEXT_LENGTH = 4000;
const MIN_TEXT_HASH_LENGTH = 120;
const MAX_BOOKMARK_LABEL_LENGTH = 120;
const MAX_BOOKMARK_QUERY_LENGTH = 200;
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');
const SSE_RETRY_INTERVAL_MS = 5000;
const SSE_HEARTBEAT_INTERVAL_MS = 30000;
const sseClients = new Map();
let nextSseClientId = 1;
function scheduleAsync(fn) {
if (typeof setImmediate === 'function') {
setImmediate(fn);
} else {
setTimeout(fn, 0);
}
}
function removeSseClient(clientId) {
const client = sseClients.get(clientId);
if (!client) {
return;
}
sseClients.delete(clientId);
if (client.heartbeat) {
clearInterval(client.heartbeat);
}
}
function addSseClient(res) {
const clientId = nextSseClientId++;
const client = {
id: clientId,
res,
heartbeat: setInterval(() => {
if (res.writableEnded) {
removeSseClient(clientId);
return;
}
try {
res.write('event: heartbeat\ndata: {}\n\n');
} catch (error) {
removeSseClient(clientId);
}
}, SSE_HEARTBEAT_INTERVAL_MS)
};
sseClients.set(clientId, client);
return client;
}
function broadcastSseEvent(payload) {
if (!payload) {
return;
}
let serialized;
try {
serialized = JSON.stringify(payload);
} catch (error) {
console.warn('Failed to serialize SSE payload:', error.message);
return;
}
const message = `data: ${serialized}\n\n`;
for (const [clientId, client] of sseClients.entries()) {
const target = client && client.res;
if (!target || target.writableEnded) {
removeSseClient(clientId);
continue;
}
try {
target.write(message);
} catch (error) {
removeSseClient(clientId);
}
}
}
function queuePostBroadcast(postId, options = {}) {
if (!postId) {
return;
}
scheduleAsync(() => broadcastPostChangeById(postId, options));
}
function ensureColumn(table, column, definition) {
const info = db.prepare(`PRAGMA table_info(${table})`).all();
if (!info.some((row) => row.name === column)) {
db.prepare(`ALTER TABLE ${table} ADD COLUMN ${definition}`).run();
}
}
ensureColumn('posts', 'post_text', 'post_text TEXT');
ensureColumn('posts', 'post_text_hash', 'post_text_hash TEXT');
ensureColumn('posts', 'content_key', 'content_key TEXT');
db.exec(`
CREATE INDEX IF NOT EXISTS idx_posts_content_key
ON posts(content_key)
`);
const updateContentKeyStmt = db.prepare('UPDATE posts SET content_key = ? WHERE id = ?');
const updatePostTextColumnsStmt = db.prepare('UPDATE posts SET post_text = ?, post_text_hash = ? WHERE id = ?');
const postsMissingKey = db.prepare(`
SELECT id, url
FROM posts
WHERE content_key IS NULL OR content_key = ''
`).all();
for (const entry of postsMissingKey) {
const normalizedUrl = normalizeFacebookPostUrl(entry.url);
const key = extractFacebookContentKey(normalizedUrl);
if (key) {
updateContentKeyStmt.run(key, entry.id);
}
}
const postsMissingHash = db.prepare(`
SELECT id, post_text
FROM posts
WHERE post_text IS NOT NULL
AND TRIM(post_text) <> ''
AND (post_text_hash IS NULL OR post_text_hash = '')
`).all();
for (const entry of postsMissingHash) {
const normalizedText = normalizePostText(entry.post_text);
const hash = computePostTextHash(normalizedText);
updatePostTextColumnsStmt.run(normalizedText, hash, entry.id);
}
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 normalizePostText(value) {
if (typeof value !== 'string') {
return null;
}
let text = value.replace(/\s+/g, ' ').trim();
if (!text) {
return null;
}
if (text.length > MAX_POST_TEXT_LENGTH) {
text = text.slice(0, MAX_POST_TEXT_LENGTH);
}
return text;
}
function computePostTextHash(text) {
if (!text) {
return null;
}
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
}
function normalizeBookmarkQuery(value) {
if (typeof value !== 'string') {
return null;
}
let query = value.trim();
if (!query) {
return null;
}
query = query.replace(/\s+/g, ' ');
if (query.length > MAX_BOOKMARK_QUERY_LENGTH) {
query = query.slice(0, MAX_BOOKMARK_QUERY_LENGTH);
}
return query;
}
function normalizeBookmarkLabel(value, fallback = '') {
const base = typeof value === 'string' ? value.trim() : '';
let label = base || fallback || '';
label = label.replace(/\s+/g, ' ');
if (label.length > MAX_BOOKMARK_LABEL_LENGTH) {
label = label.slice(0, MAX_BOOKMARK_LABEL_LENGTH);
}
return label;
}
function serializeBookmark(row) {
if (!row) {
return null;
}
return {
id: row.id,
label: row.label,
query: row.query,
created_at: sqliteTimestampToUTC(row.created_at),
updated_at: sqliteTimestampToUTC(row.updated_at),
last_clicked_at: sqliteTimestampToUTC(row.last_clicked_at)
};
}
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;
}
parsed.hostname = 'www.facebook.com';
parsed.protocol = 'https:';
parsed.port = '';
const normalizedPathBeforeTrim = parsed.pathname.replace(/\/+$/, '') || '/';
const lowerPathBeforeTrim = normalizedPathBeforeTrim.toLowerCase();
const watchId = parsed.searchParams.get('v') || parsed.searchParams.get('video_id');
if ((lowerPathBeforeTrim === '/watch' || lowerPathBeforeTrim === '/video.php') && watchId) {
parsed.pathname = `/reel/${watchId}/`;
parsed.search = '';
} else {
const reelMatch = lowerPathBeforeTrim.match(/^\/reel\/([^/]+)$/);
if (reelMatch) {
parsed.pathname = `/reel/${reelMatch[1]}/`;
parsed.search = '';
}
}
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 normalizedPath = parsed.pathname.replace(/\/+$/, '').toLowerCase();
if (normalizedPath.startsWith('/hashtag/') || normalizedPath.startsWith('/watch/hashtag/')) {
return null;
}
const search = cleanedParams.toString();
const formatted = `${parsed.origin}${parsed.pathname}${search ? `?${search}` : ''}`;
return formatted.replace(/[?&]$/, '');
}
function extractFacebookContentKey(normalizedUrl) {
if (!normalizedUrl) {
return null;
}
try {
const parsed = new URL(normalizedUrl);
const pathnameRaw = parsed.pathname || '/';
const pathname = pathnameRaw.replace(/\/+$/, '') || '/';
const lowerPath = pathname.toLowerCase();
const params = parsed.searchParams;
const reelMatch = lowerPath.match(/^\/reel\/([^/]+)/);
if (reelMatch) {
return `reel:${reelMatch[1]}`;
}
const watchId = params.get('v') || params.get('video_id');
if ((lowerPath === '/watch' || lowerPath === '/watch/') && watchId) {
return `video:${watchId}`;
}
if (lowerPath === '/video.php' && watchId) {
return `video:${watchId}`;
}
const photoId = params.get('fbid');
if ((lowerPath === '/photo.php' || lowerPath === '/photo') && photoId) {
return `photo:${photoId}`;
}
const storyFbid = params.get('story_fbid');
if (storyFbid) {
const ownerId = params.get('id') || params.get('gid') || params.get('group_id') || params.get('page_id') || '';
return `story:${ownerId}:${storyFbid}`;
}
const groupPostMatch = lowerPath.match(/^\/groups\/([^/]+)\/posts\/([^/]+)/);
if (groupPostMatch) {
return `group-post:${groupPostMatch[1]}:${groupPostMatch[2]}`;
}
const groupPermalinkMatch = lowerPath.match(/^\/groups\/([^/]+)\/permalink\/([^/]+)/);
if (groupPermalinkMatch) {
return `group-post:${groupPermalinkMatch[1]}:${groupPermalinkMatch[2]}`;
}
const pagePostMatch = lowerPath.match(/^\/([^/]+)\/posts\/([^/]+)/);
if (pagePostMatch) {
return `profile-post:${pagePostMatch[1]}:${pagePostMatch[2]}`;
}
const pageVideoMatch = lowerPath.match(/^\/([^/]+)\/videos\/([^/]+)/);
if (pageVideoMatch) {
return `video:${pageVideoMatch[2]}`;
}
const pagePhotoMatch = lowerPath.match(/^\/([^/]+)\/photos\/[^/]+\/([^/]+)/);
if (pagePhotoMatch) {
return `photo:${pagePhotoMatch[2]}`;
}
if (lowerPath === '/' && storyFbid) {
const ownerId = params.get('id') || '';
return `story:${ownerId}:${storyFbid}`;
}
if ((lowerPath === '/permalink.php' || lowerPath === '/story.php') && storyFbid) {
const ownerId = params.get('id') || '';
return `story:${ownerId}:${storyFbid}`;
}
const sortedParams = Array.from(params.entries())
.map(([key, value]) => `${key}=${value}`)
.sort()
.join('&');
return `generic:${lowerPath}?${sortedParams}`;
} catch (error) {
return `generic:${normalizedUrl}`;
}
}
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_by_profile INTEGER,
created_by_name TEXT,
deadline_at DATETIME,
post_text TEXT,
post_text_hash TEXT,
content_key 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(`
DROP INDEX IF EXISTS idx_post_urls_primary;
`);
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 TABLE IF NOT EXISTS maintenance_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
search_retention_days INTEGER DEFAULT ${SEARCH_POST_RETENTION_DAYS},
auto_purge_hidden INTEGER DEFAULT 1,
updated_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);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
label TEXT,
query TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_clicked_at DATETIME,
UNIQUE(query COLLATE NOCASE)
);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_bookmarks_last_clicked_at
ON bookmarks(last_clicked_at);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_bookmarks_created_at
ON bookmarks(created_at);
`);
const listBookmarksStmt = db.prepare(`
SELECT id, label, query, created_at, updated_at, last_clicked_at
FROM bookmarks
ORDER BY
(last_clicked_at IS NULL),
datetime(COALESCE(last_clicked_at, created_at)) DESC,
label COLLATE NOCASE
`);
const getBookmarkByIdStmt = db.prepare(`
SELECT id, label, query, created_at, updated_at, last_clicked_at
FROM bookmarks
WHERE id = ?
`);
const findBookmarkByQueryStmt = db.prepare(`
SELECT id
FROM bookmarks
WHERE LOWER(query) = LOWER(?)
`);
const insertBookmarkStmt = db.prepare(`
INSERT INTO bookmarks (id, label, query)
VALUES (?, ?, ?)
`);
const deleteBookmarkStmt = db.prepare(`
DELETE FROM bookmarks
WHERE id = ?
`);
const updateBookmarkLastClickedStmt = db.prepare(`
UPDATE bookmarks
SET last_clicked_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
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, reason = null) {
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);
}
queuePostBroadcast(postId, { reason: reason || 'touch' });
}
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);
const updatedKey = extractFacebookContentKey(cleaned);
updateContentKeyStmt.run(updatedKey || null, 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 normalizeExistingPostUrlMappings() {
const rows = db.prepare('SELECT id, url FROM post_urls').all();
let updated = 0;
let removed = 0;
for (const row of rows) {
const normalized = normalizeFacebookPostUrl(row.url);
if (!normalized) {
continue;
}
if (normalized === row.url) {
continue;
}
try {
db.prepare('UPDATE post_urls SET url = ? WHERE id = ?').run(normalized, row.id);
updated += 1;
} catch (error) {
if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
db.prepare('DELETE FROM post_urls WHERE id = ?').run(row.id);
removed += 1;
} else {
console.warn(`Failed to normalize post_urls entry ${row.id}:`, error.message);
}
}
}
if (updated || removed) {
console.log(`Normalized post_urls entries: updated ${updated}, removed ${removed}`);
}
}
normalizeExistingPostUrlMappings();
db.prepare('DELETE FROM post_urls WHERE url IN (SELECT url FROM posts)').run();
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 normalizeRetentionDays(value) {
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
return SEARCH_POST_RETENTION_DAYS;
}
return Math.min(365, Math.max(1, parsed));
}
function loadHiddenSettings() {
let settings = db.prepare('SELECT * FROM maintenance_settings WHERE id = 1').get();
if (!settings) {
const defaults = {
id: 1,
search_retention_days: SEARCH_POST_RETENTION_DAYS,
auto_purge_hidden: 1
};
db.prepare(`
INSERT INTO maintenance_settings (id, search_retention_days, auto_purge_hidden, updated_at)
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
`).run(defaults.search_retention_days, defaults.auto_purge_hidden);
settings = defaults;
}
settings.search_retention_days = normalizeRetentionDays(settings.search_retention_days);
settings.auto_purge_hidden = settings.auto_purge_hidden ? 1 : 0;
return settings;
}
function persistHiddenSettings({ retentionDays, autoPurgeEnabled }) {
const normalizedRetention = normalizeRetentionDays(retentionDays);
const normalizedAuto = autoPurgeEnabled ? 1 : 0;
const existing = db.prepare('SELECT id FROM maintenance_settings WHERE id = 1').get();
if (existing) {
db.prepare(`
UPDATE maintenance_settings
SET search_retention_days = ?, auto_purge_hidden = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`).run(normalizedRetention, normalizedAuto);
} else {
db.prepare(`
INSERT INTO maintenance_settings (id, search_retention_days, auto_purge_hidden, updated_at)
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
`).run(normalizedRetention, normalizedAuto);
}
return {
auto_purge_enabled: !!normalizedAuto,
retention_days: normalizedRetention
};
}
function cleanupExpiredSearchPosts() {
try {
const settings = loadHiddenSettings();
if (!settings.auto_purge_hidden) {
return;
}
const threshold = `-${settings.search_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 primaryKey = extractFacebookContentKey(normalizedPrimary);
const normalized = collectNormalizedFacebookUrls(normalizedPrimary, candidates);
return normalized.filter((url) => {
if (url === normalizedPrimary) {
return false;
}
const candidateKey = extractFacebookContentKey(url);
return candidateKey && candidateKey === primaryKey;
});
}
const insertPostUrlStmt = db.prepare(`
INSERT OR IGNORE INTO post_urls (post_id, url, is_primary)
VALUES (?, ?, 0)
`);
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 = ?
ORDER BY created_at ASC
`);
const selectPostByTextHashStmt = db.prepare('SELECT * FROM posts WHERE post_text_hash = ?');
function storePostUrls(postId, primaryUrl, additionalUrls = []) {
if (!postId || !primaryUrl) {
return;
}
const normalizedPrimary = normalizeFacebookPostUrl(primaryUrl);
if (!normalizedPrimary) {
return;
}
const primaryKey = extractFacebookContentKey(normalizedPrimary);
if (Array.isArray(additionalUrls)) {
for (const candidate of additionalUrls) {
const normalized = normalizeFacebookPostUrl(candidate);
if (!normalized || normalized === normalizedPrimary) {
continue;
}
const candidateKey = extractFacebookContentKey(normalized);
if (!candidateKey || candidateKey !== primaryKey) {
continue;
}
const existingPostId = findPostIdByUrl(normalized);
if (existingPostId && existingPostId !== postId) {
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;
}
let postContentKey = post.content_key;
if (!postContentKey) {
const normalizedUrl = normalizeFacebookPostUrl(post.url);
postContentKey = extractFacebookContentKey(normalizedUrl);
if (postContentKey) {
updateContentKeyStmt.run(postContentKey, post.id);
post.content_key = postContentKey;
}
}
if (post.post_text && (!post.post_text_hash || !post.post_text_hash.trim())) {
const normalizedPostText = normalizePostText(post.post_text);
const hash = computePostTextHash(normalizedPostText);
post.post_text = normalizedPostText;
post.post_text_hash = hash;
updatePostTextColumnsStmt.run(normalizedPostText, hash, post.id);
}
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,
post_text: post.post_text || null,
post_text_hash: post.post_text_hash || null,
content_key: post.content_key || postContentKey || null
};
}
function broadcastPostChange(post, options = {}) {
if (!post || !post.id) {
return;
}
const payload = {
type: 'post-upsert',
post
};
if (options && options.reason) {
payload.reason = options.reason;
}
broadcastSseEvent(payload);
}
function broadcastPostChangeById(postId, options = {}) {
if (!postId) {
return;
}
try {
const row = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
if (!row) {
return;
}
const postPayload = mapPostRow(row);
broadcastPostChange(postPayload, options);
} catch (error) {
console.warn(`Failed to broadcast post ${postId}:`, error.message);
}
}
function broadcastPostDeletion(postId, options = {}) {
if (!postId) {
return;
}
const payload = {
type: 'post-deleted',
postId
};
if (options && options.reason) {
payload.reason = options.reason;
}
broadcastSseEvent(payload);
}
app.get('/api/bookmarks', (req, res) => {
try {
const rows = listBookmarksStmt.all();
res.json(rows.map(serializeBookmark));
} catch (error) {
console.error('Failed to load bookmarks:', error);
res.status(500).json({ error: 'Bookmarks konnten nicht geladen werden' });
}
});
app.post('/api/bookmarks', (req, res) => {
try {
const payload = req.body || {};
const normalizedQuery = normalizeBookmarkQuery(payload.query);
if (!normalizedQuery) {
return res.status(400).json({ error: 'Ungültiger Suchbegriff' });
}
if (findBookmarkByQueryStmt.get(normalizedQuery)) {
return res.status(409).json({ error: 'Bookmark existiert bereits' });
}
const normalizedLabel = normalizeBookmarkLabel(payload.label, normalizedQuery);
const id = uuidv4();
insertBookmarkStmt.run(id, normalizedLabel, normalizedQuery);
const saved = getBookmarkByIdStmt.get(id);
res.status(201).json(serializeBookmark(saved));
} catch (error) {
console.error('Failed to create bookmark:', error);
res.status(500).json({ error: 'Bookmark konnte nicht erstellt werden' });
}
});
app.post('/api/bookmarks/:bookmarkId/click', (req, res) => {
const { bookmarkId } = req.params;
if (!bookmarkId) {
return res.status(400).json({ error: 'Bookmark-ID fehlt' });
}
try {
const result = updateBookmarkLastClickedStmt.run(bookmarkId);
if (!result.changes) {
return res.status(404).json({ error: 'Bookmark nicht gefunden' });
}
const updated = getBookmarkByIdStmt.get(bookmarkId);
res.json(serializeBookmark(updated));
} catch (error) {
console.error('Failed to register bookmark click:', error);
res.status(500).json({ error: 'Bookmark konnte nicht aktualisiert werden' });
}
});
app.delete('/api/bookmarks/:bookmarkId', (req, res) => {
const { bookmarkId } = req.params;
if (!bookmarkId) {
return res.status(400).json({ error: 'Bookmark-ID fehlt' });
}
try {
const result = deleteBookmarkStmt.run(bookmarkId);
if (!result.changes) {
return res.status(404).json({ error: 'Bookmark nicht gefunden' });
}
res.status(204).send();
} catch (error) {
console.error('Failed to delete bookmark:', error);
res.status(500).json({ error: 'Bookmark konnte nicht gelöscht werden' });
}
});
// Get all posts
app.get('/api/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
if (typeof res.flushHeaders === 'function') {
res.flushHeaders();
} else {
res.write('\n');
}
res.write(`retry: ${SSE_RETRY_INTERVAL_MS}\n\n`);
const client = addSseClient(res);
const initialPayload = {
type: 'connected',
clientId: client.id
};
res.write(`data: ${JSON.stringify(initialPayload)}\n\n`);
const cleanup = () => {
removeSseClient(client.id);
};
req.on('close', cleanup);
res.on('close', cleanup);
});
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);
touchPost(post.id, 'alternate-urls');
}
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]);
if (alternateUrls.length) {
touchPost(trackedPost.id, 'alternate-urls');
}
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);
const formattedPost = mapPostRow(updatedPost);
res.json(formattedPost);
broadcastPostChange(formattedPost, { reason: 'screenshot-updated' });
} 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', 'no-store');
return res.sendFile(placeholderPath);
}
res.set('Cache-Control', 'no-store');
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', 'no-store');
return res.sendFile(placeholderPath);
}
res.set('Cache-Control', 'no-store');
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,
post_text
} = 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();
const normalizedPostText = normalizePostText(post_text);
const postTextHash = computePostTextHash(normalizedPostText);
const contentKey = extractFacebookContentKey(normalizedUrl);
const useTextHashDedup = normalizedPostText && normalizedPostText.length >= MIN_TEXT_HASH_LENGTH && postTextHash;
if (useTextHashDedup) {
let existingByHash = selectPostByTextHashStmt.get(postTextHash);
if (existingByHash) {
const alternateCandidates = [normalizedUrl, ...alternateUrlsInput];
const alternateUrls = collectPostAlternateUrls(existingByHash.url, alternateCandidates);
storePostUrls(existingByHash.id, existingByHash.url, alternateUrls);
const cleanupSet = new Set([existingByHash.url, normalizedUrl, ...alternateUrls]);
removeSearchSeenEntries(Array.from(cleanupSet));
if (normalizedPostText && (!existingByHash.post_text || !existingByHash.post_text.trim())) {
updatePostTextColumnsStmt.run(normalizedPostText, postTextHash, existingByHash.id);
touchPost(existingByHash.id, 'post-text-normalized');
existingByHash = db.prepare('SELECT * FROM posts WHERE id = ?').get(existingByHash.id);
}
return res.json(mapPostRow(existingByHash));
}
}
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,
post_text,
post_text_hash,
content_key,
last_change
)
VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`);
stmt.run(
id,
normalizedUrl,
title || '',
validatedTargetCount,
creatorProfile,
creatorDisplayName,
normalizedDeadline,
normalizedPostText,
postTextHash,
contentKey || null
);
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
const alternateUrls = collectPostAlternateUrls(normalizedUrl, alternateUrlsInput);
storePostUrls(id, normalizedUrl, alternateUrls);
removeSearchSeenEntries([normalizedUrl, ...alternateUrls]);
const formattedPost = mapPostRow(post);
res.json(formattedPost);
broadcastPostChange(formattedPost, { reason: 'created' });
} 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,
post_text
} = 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;
let updatedContentKey = 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;
const newContentKey = extractFacebookContentKey(normalizedUrl);
updates.push('content_key = ?');
params.push(newContentKey || null);
updatedContentKey = newContentKey || null;
}
if (typeof post_text !== 'undefined') {
const normalizedPostText = normalizePostText(post_text);
const postTextHash = computePostTextHash(normalizedPostText);
updates.push('post_text = ?');
params.push(normalizedPostText);
updates.push('post_text_hash = ?');
params.push(postTextHash);
}
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));
const formattedPost = mapPostRow(updatedPost);
res.json(formattedPost);
broadcastPostChange(formattedPost, { reason: 'updated' });
} 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, 'profile-status-update');
}
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);
if (alternateUrls.length) {
touchPost(post.id, 'alternate-urls');
}
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, 'check-by-url');
}
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, 'profile-status-update');
}
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' });
}
const contentKey = extractFacebookContentKey(normalizedUrl);
// Update URL
db.prepare('UPDATE posts SET url = ?, content_key = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(normalizedUrl, contentKey || null, 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]);
queuePostBroadcast(postId, { reason: 'url-updated' });
return res.json({ success: true, url: normalizedUrl });
}
if (is_successful !== undefined) {
const successValue = is_successful ? 1 : 0;
db.prepare('UPDATE posts SET is_successful = ?, last_change = CURRENT_TIMESTAMP WHERE id = ?').run(successValue, postId);
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
const formattedPost = mapPostRow(updatedPost);
res.json(formattedPost);
broadcastPostChange(formattedPost, { reason: 'success-flag' });
return;
}
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 });
broadcastPostDeletion(postId, { reason: 'deleted' });
} 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 });
}
});
app.get('/api/hidden-settings', (req, res) => {
try {
const settings = loadHiddenSettings();
res.json({
auto_purge_enabled: !!settings.auto_purge_hidden,
retention_days: settings.search_retention_days
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.put('/api/hidden-settings', (req, res) => {
try {
const body = req.body || {};
const retentionDays = normalizeRetentionDays(body.retention_days);
const autoPurgeEnabled = !!body.auto_purge_enabled;
const saved = persistHiddenSettings({ retentionDays, autoPurgeEnabled });
if (saved.auto_purge_enabled) {
cleanupExpiredSearchPosts();
}
res.json(saved);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
function sanitizeAIComment(text) {
if (!text) {
return '';
}
return text
.replace(/<think>[\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}`);
});