Files
2025-11-11 10:36:31 +01:00

617 lines
19 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 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}`);
});