348 lines
9.7 KiB
JavaScript
348 lines
9.7 KiB
JavaScript
const Database = require('better-sqlite3');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const crypto = require('crypto');
|
|
|
|
const DATA_DIR = path.join(__dirname, 'data');
|
|
const DB_PATH = path.join(DATA_DIR, 'sessions.db');
|
|
|
|
// make sure data dir exists
|
|
if (!fs.existsSync(DATA_DIR)) {
|
|
fs.mkdirSync(DATA_DIR);
|
|
}
|
|
|
|
const db = new Database(DB_PATH);
|
|
|
|
// setup tables
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
display_name TEXT DEFAULT 'Anonymous',
|
|
username TEXT DEFAULT '@anonymous',
|
|
text TEXT DEFAULT 'No text provided',
|
|
avatar_url TEXT,
|
|
image_url TEXT,
|
|
timestamp INTEGER,
|
|
verified INTEGER DEFAULT 0,
|
|
engagement_likes INTEGER,
|
|
engagement_retweets INTEGER,
|
|
engagement_replies INTEGER,
|
|
engagement_views INTEGER,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at);
|
|
`);
|
|
|
|
// snapshots table
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS snapshots (
|
|
token TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
config_json TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
accessed_at INTEGER NOT NULL,
|
|
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_snapshots_accessed_at ON snapshots(accessed_at);
|
|
CREATE INDEX IF NOT EXISTS idx_snapshots_session_id ON snapshots(session_id);
|
|
`);
|
|
|
|
// migration: add verified column if it doesn't exist
|
|
try {
|
|
db.exec(`ALTER TABLE sessions ADD COLUMN verified INTEGER DEFAULT 0`);
|
|
} catch (err) {
|
|
// column already exists, ignore
|
|
}
|
|
|
|
|
|
function generateId() {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
function nowEpoch() {
|
|
return Math.floor(Date.now() / 1000);
|
|
}
|
|
|
|
function generateSnapshotToken() {
|
|
const buffer = crypto.randomBytes(16);
|
|
return buffer.toString('base64')
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=/g, '~');
|
|
}
|
|
|
|
// convert db row to api resposne format
|
|
function rowToSession(row) {
|
|
if (!row) return null;
|
|
|
|
let engagement = null;
|
|
if (row.engagement_likes !== null &&
|
|
row.engagement_retweets !== null &&
|
|
row.engagement_replies !== null &&
|
|
row.engagement_views !== null) {
|
|
engagement = {
|
|
likes: row.engagement_likes,
|
|
retweets: row.engagement_retweets,
|
|
replies: row.engagement_replies,
|
|
views: row.engagement_views
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: row.id,
|
|
displayName: row.display_name,
|
|
username: row.username,
|
|
text: row.text,
|
|
avatarUrl: row.avatar_url,
|
|
imageUrl: row.image_url,
|
|
timestamp: row.timestamp,
|
|
verified: Boolean(row.verified),
|
|
engagement: engagement,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
};
|
|
}
|
|
|
|
function createSession(data = {}) {
|
|
const id = generateId();
|
|
const now = nowEpoch();
|
|
|
|
const stmt = db.prepare(`
|
|
INSERT INTO sessions (
|
|
id, display_name, username, text, avatar_url, image_url, timestamp, verified,
|
|
engagement_likes, engagement_retweets, engagement_replies, engagement_views,
|
|
created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
|
|
stmt.run(
|
|
id,
|
|
data.displayName || 'Anonymous',
|
|
data.username || '@anonymous',
|
|
data.text || 'No text provided',
|
|
data.avatarUrl || null,
|
|
data.imageUrl || null,
|
|
data.timestamp || null,
|
|
data.verified ? 1 : 0,
|
|
data.engagement?.likes ?? null,
|
|
data.engagement?.retweets ?? null,
|
|
data.engagement?.replies ?? null,
|
|
data.engagement?.views ?? null,
|
|
now,
|
|
now
|
|
);
|
|
|
|
return getSession(id);
|
|
}
|
|
|
|
function getSession(id) {
|
|
const stmt = db.prepare('SELECT * FROM sessions WHERE id = ?');
|
|
const row = stmt.get(id);
|
|
return rowToSession(row);
|
|
}
|
|
|
|
function updateSession(id, data) {
|
|
const session = getSession(id);
|
|
if (!session) return null;
|
|
|
|
const updates = [];
|
|
const values = [];
|
|
|
|
if (data.displayName !== undefined) {
|
|
updates.push('display_name = ?');
|
|
values.push(data.displayName || 'Anonymous');
|
|
}
|
|
if (data.username !== undefined) {
|
|
updates.push('username = ?');
|
|
values.push(data.username || '@anonymous');
|
|
}
|
|
if (data.text !== undefined) {
|
|
updates.push('text = ?');
|
|
values.push(data.text || 'No text provided');
|
|
}
|
|
if (data.avatarUrl !== undefined) {
|
|
updates.push('avatar_url = ?');
|
|
values.push(data.avatarUrl);
|
|
}
|
|
if (data.imageUrl !== undefined) {
|
|
updates.push('image_url = ?');
|
|
values.push(data.imageUrl);
|
|
}
|
|
if (data.timestamp !== undefined) {
|
|
updates.push('timestamp = ?');
|
|
values.push(data.timestamp);
|
|
}
|
|
|
|
// engagement updates (atomic clear or partial updates)
|
|
if (data.engagement !== undefined) {
|
|
if (data.engagement === null) {
|
|
// explicitly clear all engagement fields
|
|
updates.push('engagement_likes = ?');
|
|
updates.push('engagement_retweets = ?');
|
|
updates.push('engagement_replies = ?');
|
|
updates.push('engagement_views = ?');
|
|
values.push(null, null, null, null);
|
|
} else {
|
|
// partial engagement field updates
|
|
if (data.engagement.likes !== undefined) {
|
|
updates.push('engagement_likes = ?');
|
|
values.push(data.engagement.likes);
|
|
}
|
|
if (data.engagement.retweets !== undefined) {
|
|
updates.push('engagement_retweets = ?');
|
|
values.push(data.engagement.retweets);
|
|
}
|
|
if (data.engagement.replies !== undefined) {
|
|
updates.push('engagement_replies = ?');
|
|
values.push(data.engagement.replies);
|
|
}
|
|
if (data.engagement.views !== undefined) {
|
|
updates.push('engagement_views = ?');
|
|
values.push(data.engagement.views);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (updates.length === 0) {
|
|
return session;
|
|
}
|
|
|
|
updates.push('updated_at = ?');
|
|
values.push(nowEpoch());
|
|
values.push(id);
|
|
|
|
const stmt = db.prepare(`UPDATE sessions SET ${updates.join(', ')} WHERE id = ?`);
|
|
stmt.run(...values);
|
|
|
|
return getSession(id);
|
|
}
|
|
|
|
function deleteSession(id) {
|
|
const stmt = db.prepare('DELETE FROM sessions WHERE id = ?');
|
|
const result = stmt.run(id);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
function createSnapshot(sessionId, config, maxRetries = 3) {
|
|
const configJson = JSON.stringify(config);
|
|
const now = nowEpoch();
|
|
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
const token = generateSnapshotToken();
|
|
|
|
try {
|
|
const stmt = db.prepare(`
|
|
INSERT INTO snapshots (token, session_id, config_json, created_at, accessed_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`);
|
|
|
|
stmt.run(token, sessionId, configJson, now, now);
|
|
|
|
return {
|
|
token: token,
|
|
sessionId: sessionId,
|
|
configJson: configJson,
|
|
createdAt: now,
|
|
accessedAt: now
|
|
};
|
|
} catch (err) {
|
|
if (err.code === 'SQLITE_CONSTRAINT' && attempt < maxRetries - 1) {
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
throw new Error('Failed to generate unique snapshot token after retries');
|
|
}
|
|
|
|
function getSnapshot(token) {
|
|
const stmt = db.prepare('SELECT * FROM snapshots WHERE token = ?');
|
|
const row = stmt.get(token);
|
|
|
|
if (!row) return null;
|
|
|
|
return {
|
|
token: row.token,
|
|
sessionId: row.session_id,
|
|
configJson: row.config_json,
|
|
createdAt: row.created_at,
|
|
accessedAt: row.accessed_at
|
|
};
|
|
}
|
|
|
|
function touchSnapshot(token) {
|
|
const now = nowEpoch();
|
|
const stmt = db.prepare('UPDATE snapshots SET accessed_at = ? WHERE token = ?');
|
|
const result = stmt.run(now, token);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
function deleteSnapshot(token) {
|
|
const stmt = db.prepare('DELETE FROM snapshots WHERE token = ?');
|
|
const result = stmt.run(token);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
function getSnapshotsForSession(sessionId) {
|
|
const stmt = db.prepare('SELECT * FROM snapshots WHERE session_id = ? ORDER BY created_at DESC');
|
|
const rows = stmt.all(sessionId);
|
|
|
|
return rows.map(row => ({
|
|
token: row.token,
|
|
sessionId: row.session_id,
|
|
configJson: row.config_json,
|
|
createdAt: row.created_at,
|
|
accessedAt: row.accessed_at
|
|
}));
|
|
}
|
|
|
|
function cleanupExpiredSnapshots() {
|
|
const FORTY_EIGHT_HOURS = 48 * 60 * 60;
|
|
const cutoff = nowEpoch() - FORTY_EIGHT_HOURS;
|
|
|
|
const stmt = db.prepare('DELETE FROM snapshots WHERE accessed_at < ?');
|
|
const result = stmt.run(cutoff);
|
|
|
|
if (result.changes > 0) {
|
|
console.log(`Cleaned up ${result.changes} expired snapshot(s)`);
|
|
}
|
|
}
|
|
|
|
function countSnapshotsForSession(sessionId) {
|
|
const stmt = db.prepare('SELECT COUNT(*) as count FROM snapshots WHERE session_id = ?');
|
|
const row = stmt.get(sessionId);
|
|
return row.count;
|
|
}
|
|
|
|
|
|
function cleanupExpiredSessions() {
|
|
const ONE_DAY = 24 * 60 * 60;
|
|
const cutoff = nowEpoch() - ONE_DAY;
|
|
|
|
const stmt = db.prepare('DELETE FROM sessions WHERE updated_at < ?');
|
|
const result = stmt.run(cutoff);
|
|
|
|
if (result.changes > 0) {
|
|
console.log(`Cleaned up ${result.changes} expired session(s)`);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
createSession,
|
|
getSession,
|
|
updateSession,
|
|
deleteSession,
|
|
cleanupExpiredSessions,
|
|
createSnapshot,
|
|
getSnapshot,
|
|
touchSnapshot,
|
|
deleteSnapshot,
|
|
getSnapshotsForSession,
|
|
cleanupExpiredSnapshots,
|
|
countSnapshotsForSession
|
|
};
|