const express = require('express'); const cors = require('cors'); const Database = require('better-sqlite3'); const { v4: uuidv4 } = require('uuid'); const path = require('path'); const fs = require('fs'); const app = express(); const PORT = process.env.PORT || 3000; const MAX_PROFILES = 5; const DEFAULT_PROFILE_NAMES = { 1: 'Profil 1', 2: 'Profil 2', 3: 'Profil 3', 4: 'Profil 4', 5: 'Profil 5' }; const screenshotDir = path.join(__dirname, 'data', 'screenshots'); if (!fs.existsSync(screenshotDir)) { fs.mkdirSync(screenshotDir, { recursive: true }); } // Middleware - Enhanced CORS for extension app.use(cors({ origin: '*', methods: ['GET', 'POST', 'PUT', '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) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); next(); }); // Database setup const dbPath = path.join(__dirname, 'data', 'tracker.db'); const db = new Database(dbPath); 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 getProfileName(profileNumber) { return DEFAULT_PROFILE_NAMES[profileNumber] || `Profil ${profileNumber}`; } 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 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; db.prepare('UPDATE posts SET checked_count = ? WHERE id = ?').run(checkedCount, postId); if (post.target_count !== requiredProfiles.length) { db.prepare('UPDATE posts SET target_count = ? WHERE id = ?').run(requiredProfiles.length, postId); } return checkedCount; } // Initialize database tables db.exec(` CREATE TABLE IF NOT EXISTS posts ( id TEXT PRIMARY KEY, url TEXT NOT NULL UNIQUE, title TEXT, target_count INTEGER NOT NULL, checked_count INTEGER DEFAULT 0, screenshot_path TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); `); 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 ); `); const ensureColumn = (table, column, definition) => { const columns = db.prepare(`PRAGMA table_info(${table})`).all(); if (!columns.some(col => col.name === column)) { db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`); } }; ensureColumn('posts', 'checked_count', 'checked_count INTEGER DEFAULT 0'); ensureColumn('posts', 'screenshot_path', 'screenshot_path TEXT'); 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}"`); } } } const profileStateRow = db.prepare('SELECT profile_number FROM profile_state WHERE id = 1').get(); if (!profileStateRow) { db.prepare('INSERT INTO profile_state (id, profile_number) VALUES (1, 1)').run(); } function mapPostRow(post) { if (!post) { return null; } const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ? ORDER BY checked_at ASC').all(post.id); const requiredProfiles = getRequiredProfiles(post.target_count); const { statuses, completedChecks } = buildProfileStatuses(requiredProfiles, checks); const checkedCount = statuses.filter(status => status.status === 'done').length; const screenshotFile = post.screenshot_path ? path.join(screenshotDir, post.screenshot_path) : null; const screenshotPath = screenshotFile && fs.existsSync(screenshotFile) ? `/api/posts/${post.id}/screenshot` : null; if (post.checked_count !== checkedCount) { db.prepare('UPDATE posts SET checked_count = ? WHERE id = ?').run(checkedCount, post.id); } if (post.target_count !== requiredProfiles.length) { db.prepare('UPDATE posts SET target_count = ? WHERE id = ?').run(requiredProfiles.length, post.id); } const nextRequired = statuses.find(status => status.status === 'available'); return { ...post, target_count: requiredProfiles.length, checked_count: checkedCount, checks: completedChecks, is_complete: checkedCount >= requiredProfiles.length, screenshot_path: screenshotPath, required_profiles: requiredProfiles, profile_statuses: statuses, next_required_profile: nextRequired ? nextRequired.profile_number : null }; } // Get all posts app.get('/api/posts', (req, res) => { try { const posts = db.prepare(` SELECT * FROM posts ORDER BY created_at DESC `).all(); res.json(posts.map(mapPostRow)); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get post by URL app.get('/api/posts/by-url', (req, res) => { try { const { url } = req.query; if (!url) { return res.status(400).json({ error: 'URL parameter required' }); } const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(url); if (!post) { return res.json(null); } res.json(mapPostRow(post)); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/profile-state', (req, res) => { try { const row = db.prepare('SELECT profile_number FROM profile_state WHERE id = 1').get(); res.json({ profile_number: row ? row.profile_number : 1 }); } 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' }); } db.prepare('UPDATE profile_state SET profile_number = ? WHERE id = 1').run(parsed); res.json({ profile_number: parsed }); } 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 = ? WHERE id = ?').run(fileName, postId); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); res.json(mapPostRow(updatedPost)); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/posts/:postId/screenshot', (req, res) => { try { const { postId } = req.params; const post = db.prepare('SELECT screenshot_path FROM posts WHERE id = ?').get(postId); if (!post || !post.screenshot_path) { return res.status(404).json({ error: 'Screenshot not found' }); } const filePath = path.join(screenshotDir, post.screenshot_path); if (!fs.existsSync(filePath)) { 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 } = req.body; const validatedTargetCount = validateTargetCount(target_count); if (!url || !validatedTargetCount) { return res.status(400).json({ error: 'URL and target_count are required (1-5)' }); } const id = uuidv4(); const stmt = db.prepare('INSERT INTO posts (id, url, title, target_count, checked_count, screenshot_path) VALUES (?, ?, ?, ?, 0, NULL)'); stmt.run(id, url, title || '', validatedTargetCount); const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id); res.json(mapPostRow(post)); } catch (error) { if (error.message.includes('UNIQUE constraint failed')) { res.status(409).json({ error: 'Post with this URL already exists' }); } else { res.status(500).json({ error: error.message }); } } }); app.put('/api/posts/:postId', (req, res) => { try { const { postId } = req.params; const { target_count, title } = req.body || {}; const updates = []; const params = []; 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 (!updates.length) { return res.status(400).json({ error: 'No valid fields to update' }); } params.push(postId); const stmt = db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`); const result = stmt.run(...params); if (result.changes === 0) { return res.status(404).json({ error: 'Post not found' }); } recalcCheckedCount(postId); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); res.json(mapPostRow(updatedPost)); } catch (error) { res.status(500).json({ error: error.message }); } }); // Check a post for a profile app.post('/api/posts/:postId/check', (req, res) => { try { const { postId } = req.params; const { profile_number } = req.body; const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); if (!post) { return res.status(404).json({ error: 'Post not found' }); } const requiredProfiles = getRequiredProfiles(post.target_count); if (post.target_count !== requiredProfiles.length) { db.prepare('UPDATE posts SET target_count = ? WHERE id = ?').run(requiredProfiles.length, postId); post.target_count = requiredProfiles.length; } let profileValue = sanitizeProfileNumber(profile_number); if (!profileValue) { const stateRow = db.prepare('SELECT profile_number FROM profile_state WHERE id = 1').get(); profileValue = sanitizeProfileNumber(stateRow ? stateRow.profile_number : null) || 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)); } if (requiredProfiles.length > 0) { 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); recalcCheckedCount(postId); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); res.json(mapPostRow(updatedPost)); } catch (error) { res.status(500).json({ error: error.message }); } }); // Check by URL (for web interface auto-check) app.post('/api/check-by-url', (req, res) => { try { const { url, profile_number } = req.body; if (!url) { return res.status(400).json({ error: 'URL is required' }); } const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(url); if (!post) { return res.status(404).json({ error: 'Post not found' }); } const requiredProfiles = getRequiredProfiles(post.target_count); if (post.target_count !== requiredProfiles.length) { db.prepare('UPDATE posts SET target_count = ? WHERE id = ?').run(requiredProfiles.length, post.id); post.target_count = requiredProfiles.length; } let profileValue = sanitizeProfileNumber(profile_number); if (!profileValue) { const stateRow = db.prepare('SELECT profile_number FROM profile_state WHERE id = 1').get(); profileValue = sanitizeProfileNumber(stateRow ? stateRow.profile_number : null) || 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)); } if (requiredProfiles.length > 0) { 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); recalcCheckedCount(post.id); const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(post.id); res.json(mapPostRow(updatedPost)); } catch (error) { res.status(500).json({ error: error.message }); } }); // Delete post app.delete('/api/posts/:postId', (req, res) => { try { const { postId } = req.params; const post = db.prepare('SELECT screenshot_path FROM posts WHERE id = ?').get(postId); db.prepare('DELETE FROM checks WHERE post_id = ?').run(postId); const result = db.prepare('DELETE FROM posts WHERE id = ?').run(postId); if (result.changes === 0) { return res.status(404).json({ error: 'Post not found' }); } if (post && post.screenshot_path) { const filePath = path.join(screenshotDir, post.screenshot_path); if (fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); } catch (error) { console.warn('Failed to remove screenshot:', error.message); } } } res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // 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}`); });