import express from "express"; import pg from "pg"; import path from "node:path"; import crypto from "node:crypto"; import multer from "multer"; import { Client as MinioClient } from "minio"; import { ZipArchive } from "archiver"; import PDFDocument from "pdfkit"; import sharp from "sharp"; import nodemailer from "nodemailer"; import QRCode from "qrcode"; import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } from "@simplewebauthn/server"; import { rateLimit } from "express-rate-limit"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; import { lookup } from "node:dns/promises"; import { isIP } from "node:net"; import os from "node:os"; import { execFile } from "node:child_process"; import { createWriteStream } from "node:fs"; import { mkdir, readdir, stat, unlink } from "node:fs/promises"; const { Pool } = pg, app = express(), port = +(process.env.PORT || 3000), root = path.dirname(fileURLToPath(import.meta.url)), scrypt = promisify(crypto.scrypt), runFile = promisify(execFile), backupPath = process.env.BACKUP_PATH || "/backups"; let backupRunning = false; const mailer = nodemailer.createTransport({ host: process.env.SMTP_HOST || "field-log-mail", port: +(process.env.SMTP_PORT || 1025), secure: false, }); function privateAddress(address) { if (address.toLowerCase().startsWith("::ffff:")) return privateAddress(address.slice(7)); return ( address === "::" || address === "::1" || address.startsWith("fe80:") || address.startsWith("fc") || address.startsWith("fd") || /^127\./.test(address) || /^10\./.test(address) || /^192\.168\./.test(address) || /^169\.254\./.test(address) || /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(address) || /^198\.(1[89])\./.test(address) || /^172\.(1[6-9]|2\d|3[01])\./.test(address) || /^0\./.test(address) ); } async function safeRemoteUrl(value) { const url = new URL(value); if ( !["http:", "https:"].includes(url.protocol) || url.username || url.password ) throw new Error("Only public HTTP(S) links are supported"); if ( ["localhost", "localhost.localdomain"].includes(url.hostname.toLowerCase()) ) throw new Error("Private network links are not supported"); const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true }); if ( !addresses.length || addresses.some((item) => privateAddress(item.address)) ) throw new Error("Private network links are not supported"); return url; } function htmlAttribute(tag, name) { return tag.match(new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`, "i"))?.[1]; } async function remotePreview(value, redirects = 0) { const url = await safeRemoteUrl(value), response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(8000), headers: { "User-Agent": "Daymark-Link-Preview/1.0", Accept: "text/html,application/xhtml+xml", }, }); if ( response.status >= 300 && response.status < 400 && response.headers.get("location") ) { if (redirects >= 3) throw new Error("Too many redirects"); return remotePreview( new URL(response.headers.get("location"), url).href, redirects + 1, ); } if ( !response.ok || !response.headers.get("content-type")?.includes("text/html") ) throw new Error("Link does not provide an HTML preview"); const reader = response.body.getReader(); let size = 0, html = ""; while (size < 524288) { const { done, value: chunk } = await reader.read(); if (done) break; size += chunk.length; html += new TextDecoder().decode(chunk, { stream: true }); } reader.cancel().catch(() => {}); const metadata = {}; for (const tag of html.match(/]*>/gi) || []) { const key = htmlAttribute(tag, "property") || htmlAttribute(tag, "name"), content = htmlAttribute(tag, "content"); if (key && content) metadata[key.toLowerCase()] = content; } const title = metadata["og:title"] || metadata["twitter:title"] || html.match(/]*>([^<]*)<\/title>/i)?.[1] || url.hostname; return { url: url.href, title: title.trim().slice(0, 300), description: String( metadata["og:description"] || metadata.description || metadata["twitter:description"] || "", ) .trim() .slice(0, 600), image: metadata["og:image"] ? new URL(metadata["og:image"], url).href : null, siteName: metadata["og:site_name"] || url.hostname, }; } if (!process.env.DATABASE_URL && !process.env.DB_HOST) process.exit(console.error("DATABASE_URL or DB_HOST is required")); const pool = new Pool({ ...(process.env.DATABASE_URL ? { connectionString: process.env.DATABASE_URL } : { host: process.env.DB_HOST, port: +(process.env.DB_PORT || 5432), database: process.env.DB_NAME, user: process.env.DB_USER, password: process.env.DB_PASSWORD, }), ssl: process.env.DATABASE_SSL === "true" ? { rejectUnauthorized: false } : false, max: +(process.env.DATABASE_POOL_SIZE || 10), }); const mediaBucket = process.env.MINIO_BUCKET || "field-log-media"; const storageQuota = +(process.env.STORAGE_QUOTA || 5 * 1024 * 1024 * 1024); const mediaStore = new MinioClient({ endPoint: process.env.MINIO_HOST || "field-log-minio", port: +(process.env.MINIO_PORT || 9000), useSSL: process.env.MINIO_SSL === "true", accessKey: process.env.MINIO_ACCESS_KEY || "fieldlog", secretKey: process.env.MINIO_SECRET_KEY || "change-this-minio-password", }); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: +(process.env.MEDIA_FILE_LIMIT || 50 * 1024 * 1024), files: 12, }, }); async function storageUsed(userId) { let total = 0; for await (const object of mediaStore.listObjectsV2( mediaBucket, `${userId}/`, true, )) total += object.size || 0; return total; } async function userStorageQuota(userId, executor = pool) { const { rows } = await executor.query( "SELECT COALESCE(storage_quota,$2)::bigint AS quota FROM diary_users WHERE id=$1", [userId, storageQuota], ); return rows[0] ? +rows[0].quota : storageQuota; } async function migrate() { await pool.query( `CREATE TABLE IF NOT EXISTS diary_users(id BIGSERIAL PRIMARY KEY,username TEXT UNIQUE NOT NULL,email TEXT UNIQUE NOT NULL,password_hash TEXT NOT NULL,display_name TEXT NOT NULL,bio TEXT NOT NULL DEFAULT '',created_at TIMESTAMPTZ DEFAULT NOW());CREATE TABLE IF NOT EXISTS diary_sessions(token_hash TEXT PRIMARY KEY,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,expires_at TIMESTAMPTZ NOT NULL);CREATE TABLE IF NOT EXISTS diary_entries(id BIGSERIAL PRIMARY KEY,entry_date DATE NOT NULL DEFAULT CURRENT_DATE,title TEXT NOT NULL DEFAULT 'Untitled',body JSONB NOT NULL DEFAULT '[]',tags JSONB NOT NULL DEFAULT '[]',media JSONB NOT NULL DEFAULT '[]',link TEXT NOT NULL DEFAULT '',created_at TIMESTAMPTZ DEFAULT NOW(),updated_at TIMESTAMPTZ DEFAULT NOW());ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES diary_users(id) ON DELETE CASCADE;ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'private';ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS mood TEXT NOT NULL DEFAULT '';ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS location_name TEXT NOT NULL DEFAULT '';ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION;ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION;ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS pinned BOOLEAN NOT NULL DEFAULT false;ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;CREATE TABLE IF NOT EXISTS diary_entry_revisions(id BIGSERIAL PRIMARY KEY,entry_id BIGINT NOT NULL,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,snapshot JSONB NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_entries_user_date ON diary_entries(user_id,entry_date DESC);CREATE INDEX IF NOT EXISTS diary_sessions_expiry ON diary_sessions(expires_at);CREATE INDEX IF NOT EXISTS diary_revisions_entry ON diary_entry_revisions(entry_id,created_at DESC);`, ); await pool.query( `CREATE INDEX IF NOT EXISTS diary_entries_search ON diary_entries USING GIN ((to_tsvector('english'::regconfig,coalesce(title,'')||' '||coalesce(mood,'')||' '||coalesce(location_name,'')) || jsonb_to_tsvector('english'::regconfig,body,'["string"]'::jsonb) || jsonb_to_tsvector('english'::regconfig,tags,'["string"]'::jsonb)))`, ); await pool.query( `CREATE TABLE IF NOT EXISTS diary_journals(id BIGSERIAL PRIMARY KEY,owner_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,name TEXT NOT NULL,description TEXT NOT NULL DEFAULT '',color TEXT NOT NULL DEFAULT '#7aa2f7',visibility TEXT NOT NULL DEFAULT 'private',sort_order INTEGER NOT NULL DEFAULT 0,is_default BOOLEAN NOT NULL DEFAULT false,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),UNIQUE(owner_id,name));ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS journal_id BIGINT REFERENCES diary_journals(id) ON DELETE RESTRICT;INSERT INTO diary_journals(owner_id,name,is_default) SELECT u.id,'Field Notes',true FROM diary_users u WHERE NOT EXISTS(SELECT 1 FROM diary_journals j WHERE j.owner_id=u.id);UPDATE diary_entries e SET journal_id=(SELECT j.id FROM diary_journals j WHERE j.owner_id=e.user_id ORDER BY j.is_default DESC,j.sort_order,j.id LIMIT 1) WHERE e.journal_id IS NULL;CREATE INDEX IF NOT EXISTS diary_entries_journal_date ON diary_entries(journal_id,entry_date DESC);CREATE INDEX IF NOT EXISTS diary_journals_owner_sort ON diary_journals(owner_id,sort_order,id);CREATE UNIQUE INDEX IF NOT EXISTS diary_journals_one_default ON diary_journals(owner_id) WHERE is_default;`, ); await pool.query( `CREATE TABLE IF NOT EXISTS diary_journal_members(journal_id BIGINT NOT NULL REFERENCES diary_journals(id) ON DELETE CASCADE,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,role TEXT NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),PRIMARY KEY(journal_id,user_id));CREATE TABLE IF NOT EXISTS diary_journal_invitations(id BIGSERIAL PRIMARY KEY,journal_id BIGINT NOT NULL REFERENCES diary_journals(id) ON DELETE CASCADE,invited_by BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,email TEXT NOT NULL DEFAULT '',role TEXT NOT NULL,token_hash TEXT UNIQUE NOT NULL,expires_at TIMESTAMPTZ NOT NULL,accepted_at TIMESTAMPTZ,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_members_user ON diary_journal_members(user_id,journal_id);CREATE INDEX IF NOT EXISTS diary_invitations_journal ON diary_journal_invitations(journal_id,created_at DESC);`, ); await pool.query( `CREATE TABLE IF NOT EXISTS diary_comments(id BIGSERIAL PRIMARY KEY,entry_id BIGINT NOT NULL REFERENCES diary_entries(id) ON DELETE CASCADE,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,parent_id BIGINT REFERENCES diary_comments(id) ON DELETE CASCADE,body TEXT NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE TABLE IF NOT EXISTS diary_reactions(id BIGSERIAL PRIMARY KEY,entry_id BIGINT NOT NULL REFERENCES diary_entries(id) ON DELETE CASCADE,comment_id BIGINT REFERENCES diary_comments(id) ON DELETE CASCADE,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,emoji TEXT NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE TABLE IF NOT EXISTS diary_activity(id BIGSERIAL PRIMARY KEY,journal_id BIGINT NOT NULL REFERENCES diary_journals(id) ON DELETE CASCADE,actor_id BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,entry_id BIGINT REFERENCES diary_entries(id) ON DELETE CASCADE,action TEXT NOT NULL,metadata JSONB NOT NULL DEFAULT '{}',created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE TABLE IF NOT EXISTS diary_notifications(id BIGSERIAL PRIMARY KEY,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,actor_id BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,entry_id BIGINT REFERENCES diary_entries(id) ON DELETE CASCADE,comment_id BIGINT REFERENCES diary_comments(id) ON DELETE CASCADE,type TEXT NOT NULL,read_at TIMESTAMPTZ,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_comments_entry ON diary_comments(entry_id,created_at);CREATE INDEX IF NOT EXISTS diary_reactions_entry ON diary_reactions(entry_id);CREATE UNIQUE INDEX IF NOT EXISTS diary_reactions_unique ON diary_reactions(entry_id,COALESCE(comment_id,0),user_id,emoji);CREATE INDEX IF NOT EXISTS diary_activity_journal ON diary_activity(journal_id,created_at DESC);CREATE INDEX IF NOT EXISTS diary_notifications_user ON diary_notifications(user_id,read_at,created_at DESC);`, ); await pool.query( `CREATE TABLE IF NOT EXISTS diary_public_links(id BIGSERIAL PRIMARY KEY,entry_id BIGINT NOT NULL REFERENCES diary_entries(id) ON DELETE CASCADE,created_by BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,token_hash TEXT UNIQUE NOT NULL,password_hash TEXT,expires_at TIMESTAMPTZ NOT NULL,revoked_at TIMESTAMPTZ,view_count BIGINT NOT NULL DEFAULT 0,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),last_viewed_at TIMESTAMPTZ);CREATE TABLE IF NOT EXISTS diary_public_sessions(token_hash TEXT PRIMARY KEY,link_id BIGINT NOT NULL REFERENCES diary_public_links(id) ON DELETE CASCADE,expires_at TIMESTAMPTZ NOT NULL);CREATE INDEX IF NOT EXISTS diary_public_links_entry ON diary_public_links(entry_id,created_at DESC);CREATE INDEX IF NOT EXISTS diary_public_sessions_expiry ON diary_public_sessions(expires_at);`, ); await pool.query( `ALTER TABLE diary_public_links ADD COLUMN IF NOT EXISTS token_ciphertext TEXT;`, ); await pool.query( `CREATE TABLE IF NOT EXISTS diary_entry_links(id BIGSERIAL PRIMARY KEY,source_entry_id BIGINT NOT NULL REFERENCES diary_entries(id) ON DELETE CASCADE,target_entry_id BIGINT NOT NULL REFERENCES diary_entries(id) ON DELETE CASCADE,created_by BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,label TEXT NOT NULL DEFAULT 'related',created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),UNIQUE(source_entry_id,target_entry_id),CHECK(source_entry_id<>target_entry_id));CREATE TABLE IF NOT EXISTS diary_people(id BIGSERIAL PRIMARY KEY,owner_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,name TEXT NOT NULL,notes TEXT NOT NULL DEFAULT '',created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),UNIQUE(owner_id,name));CREATE TABLE IF NOT EXISTS diary_entry_people(entry_id BIGINT NOT NULL REFERENCES diary_entries(id) ON DELETE CASCADE,person_id BIGINT NOT NULL REFERENCES diary_people(id) ON DELETE CASCADE,created_by BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,PRIMARY KEY(entry_id,person_id));CREATE TABLE IF NOT EXISTS diary_collections(id BIGSERIAL PRIMARY KEY,journal_id BIGINT NOT NULL REFERENCES diary_journals(id) ON DELETE CASCADE,name TEXT NOT NULL,color TEXT NOT NULL DEFAULT '#bb9af7',description TEXT NOT NULL DEFAULT '',created_by BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),UNIQUE(journal_id,name));CREATE TABLE IF NOT EXISTS diary_collection_entries(collection_id BIGINT NOT NULL REFERENCES diary_collections(id) ON DELETE CASCADE,entry_id BIGINT NOT NULL REFERENCES diary_entries(id) ON DELETE CASCADE,added_by BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,PRIMARY KEY(collection_id,entry_id));CREATE INDEX IF NOT EXISTS diary_links_source ON diary_entry_links(source_entry_id);CREATE INDEX IF NOT EXISTS diary_links_target ON diary_entry_links(target_entry_id);CREATE INDEX IF NOT EXISTS diary_entry_people_person ON diary_entry_people(person_id);CREATE INDEX IF NOT EXISTS diary_collection_entries_entry ON diary_collection_entries(entry_id);`, ); await pool.query( `ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ;CREATE TABLE IF NOT EXISTS diary_account_tokens(id BIGSERIAL PRIMARY KEY,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,type TEXT NOT NULL,token_hash TEXT UNIQUE NOT NULL,expires_at TIMESTAMPTZ NOT NULL,used_at TIMESTAMPTZ,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_account_tokens_user ON diary_account_tokens(user_id,type,created_at DESC);`, ); await pool.query( `ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS totp_secret TEXT;ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN NOT NULL DEFAULT false;ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS recovery_code_hashes JSONB NOT NULL DEFAULT '[]';CREATE TABLE IF NOT EXISTS diary_auth_challenges(token_hash TEXT PRIMARY KEY,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,type TEXT NOT NULL,payload JSONB NOT NULL DEFAULT '{}',expires_at TIMESTAMPTZ NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_auth_challenges_user ON diary_auth_challenges(user_id,type,expires_at);`, ); await pool.query( `CREATE TABLE IF NOT EXISTS diary_passkeys(id BIGSERIAL PRIMARY KEY,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,credential_id TEXT UNIQUE NOT NULL,public_key BYTEA NOT NULL,counter BIGINT NOT NULL DEFAULT 0,transports JSONB NOT NULL DEFAULT '[]',device_type TEXT NOT NULL DEFAULT '',backed_up BOOLEAN NOT NULL DEFAULT false,name TEXT NOT NULL DEFAULT 'Passkey',created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),last_used_at TIMESTAMPTZ);CREATE INDEX IF NOT EXISTS diary_passkeys_user ON diary_passkeys(user_id,created_at);`, ); await pool.query( `ALTER TABLE diary_sessions ADD COLUMN IF NOT EXISTS id UUID DEFAULT gen_random_uuid();ALTER TABLE diary_sessions ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();ALTER TABLE diary_sessions ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW();ALTER TABLE diary_sessions ADD COLUMN IF NOT EXISTS ip_address TEXT NOT NULL DEFAULT '';ALTER TABLE diary_sessions ADD COLUMN IF NOT EXISTS user_agent TEXT NOT NULL DEFAULT '';CREATE UNIQUE INDEX IF NOT EXISTS diary_sessions_id ON diary_sessions(id);CREATE TABLE IF NOT EXISTS diary_login_history(id BIGSERIAL PRIMARY KEY,user_id BIGINT REFERENCES diary_users(id) ON DELETE CASCADE,identifier TEXT NOT NULL DEFAULT '',success BOOLEAN NOT NULL,method TEXT NOT NULL,ip_address TEXT NOT NULL DEFAULT '',user_agent TEXT NOT NULL DEFAULT '',created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_login_history_user ON diary_login_history(user_id,created_at DESC);`, ); await pool.query( `ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT false;ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS disabled_at TIMESTAMPTZ;CREATE TABLE IF NOT EXISTS diary_site_settings(id BOOLEAN PRIMARY KEY DEFAULT true CHECK(id),registration_mode TEXT NOT NULL DEFAULT 'open',updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());INSERT INTO diary_site_settings(id) VALUES(true) ON CONFLICT(id) DO NOTHING;CREATE TABLE IF NOT EXISTS diary_registration_invites(id BIGSERIAL PRIMARY KEY,code_hash TEXT UNIQUE NOT NULL,created_by BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,max_uses INTEGER NOT NULL DEFAULT 1,use_count INTEGER NOT NULL DEFAULT 0,expires_at TIMESTAMPTZ NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_registration_invites_active ON diary_registration_invites(expires_at,use_count);`, ); await pool.query( `ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS is_encrypted BOOLEAN NOT NULL DEFAULT false;ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS encrypted_payload JSONB;`, ); await pool.query( `ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS reminder_at TIMESTAMPTZ;`, ); await pool.query( `ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS weather JSONB;`, ); await pool.query( `ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS site_name TEXT NOT NULL DEFAULT 'Daymark';ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS site_tagline TEXT NOT NULL DEFAULT 'A private record of days, places, people, and ideas.';ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS site_notice TEXT NOT NULL DEFAULT 'NOTES FROM HOME AND AWAY';ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS footer_text TEXT NOT NULL DEFAULT 'WORDS, IMAGES & OTHER EVIDENCE';`, ); await pool.query( `CREATE TABLE IF NOT EXISTS diary_admin_audit(id BIGSERIAL PRIMARY KEY,admin_id BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,action TEXT NOT NULL,target_type TEXT NOT NULL DEFAULT '',target_id TEXT NOT NULL DEFAULT '',details JSONB NOT NULL DEFAULT '{}',ip_address TEXT NOT NULL DEFAULT '',created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_admin_audit_created ON diary_admin_audit(created_at DESC);CREATE INDEX IF NOT EXISTS diary_admin_audit_admin ON diary_admin_audit(admin_id,created_at DESC);`, ); await pool.query( `ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS storage_quota BIGINT;`, ); await pool.query( `ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS preferences JSONB NOT NULL DEFAULT '{}';`, ); await pool.query( `ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS alert_memory_percent INTEGER NOT NULL DEFAULT 90;ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS alert_db_connections_percent INTEGER NOT NULL DEFAULT 80;ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS alert_failed_logins INTEGER NOT NULL DEFAULT 10;ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS alert_backup_hours INTEGER NOT NULL DEFAULT 30;ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS alert_storage_percent INTEGER NOT NULL DEFAULT 90;`, ); await pool.query( `ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS maintenance_enabled BOOLEAN NOT NULL DEFAULT false;ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS maintenance_starts_at TIMESTAMPTZ;ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS maintenance_ends_at TIMESTAMPTZ;ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS maintenance_message TEXT NOT NULL DEFAULT 'The diary is temporarily unavailable while maintenance is completed.';`, ); await pool.query( `ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS entry_time TIME;UPDATE diary_entries SET entry_time=created_at::time WHERE entry_time IS NULL;`, ); await pool.query( `ALTER TABLE diary_comments ADD COLUMN IF NOT EXISTS approved BOOLEAN NOT NULL DEFAULT true;CREATE TABLE IF NOT EXISTS diary_user_blocks(blocker_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,blocked_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),PRIMARY KEY(blocker_id,blocked_id),CHECK(blocker_id<>blocked_id));CREATE INDEX IF NOT EXISTS diary_user_blocks_blocked ON diary_user_blocks(blocked_id,blocker_id);`, ); await pool.query( `ALTER TABLE diary_comments ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;CREATE INDEX IF NOT EXISTS diary_comments_deleted ON diary_comments(entry_id,deleted_at);`, ); await pool.query( `ALTER TABLE diary_notifications ADD COLUMN IF NOT EXISTS email_sent_at TIMESTAMPTZ;ALTER TABLE diary_notifications ADD COLUMN IF NOT EXISTS email_attempted_at TIMESTAMPTZ;CREATE INDEX IF NOT EXISTS diary_notifications_email_pending ON diary_notifications(user_id,created_at) WHERE email_sent_at IS NULL;`, ); await pool.query( `CREATE TABLE IF NOT EXISTS diary_user_backups(id UUID PRIMARY KEY DEFAULT gen_random_uuid(),user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,file_name TEXT NOT NULL,bytes BIGINT NOT NULL DEFAULT 0,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_user_backups_user ON diary_user_backups(user_id,created_at DESC);`, ); await pool.query( `ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS self_deactivated BOOLEAN NOT NULL DEFAULT false;ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS deletion_requested_at TIMESTAMPTZ;ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS deletion_scheduled_for TIMESTAMPTZ;`, ); if (process.env.ADMIN_EMAIL) await pool.query( "UPDATE diary_users SET is_admin=true WHERE email=lower($1)", [process.env.ADMIN_EMAIL], ); if (!(await mediaStore.bucketExists(mediaBucket))) await mediaStore.makeBucket(mediaBucket); } const hash = (s) => crypto.createHash("sha256").update(s).digest("hex"); async function password(p, salt = crypto.randomBytes(16).toString("hex")) { return salt + ":" + Buffer.from(await scrypt(p, salt, 64)).toString("hex"); } async function verify(p, v) { const [salt, key] = v.split(":"); return crypto.timingSafeEqual( Buffer.from(key, "hex"), Buffer.from((await password(p, salt)).split(":")[1], "hex"), ); } const base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; function base32Encode(buffer) { let bits = ""; for (const byte of buffer) bits += byte.toString(2).padStart(8, "0"); return bits .match(/.{1,5}/g) .map((chunk) => base32Alphabet[parseInt(chunk.padEnd(5, "0"), 2)]) .join(""); } function base32Decode(value) { const bits = [...value.toUpperCase().replace(/=+$/, "")] .map((character) => base32Alphabet.indexOf(character).toString(2).padStart(5, "0"), ) .join(""); return Buffer.from( (bits.match(/.{8}/g) || []).map((byte) => parseInt(byte, 2)), ); } function totpCode(secret, offset = 0) { const counter = Math.floor(Date.now() / 30000) + offset, buffer = Buffer.alloc(8); buffer.writeBigUInt64BE(BigInt(counter)); const digest = crypto .createHmac("sha1", base32Decode(secret)) .update(buffer) .digest(), position = digest[digest.length - 1] & 15, number = (digest.readUInt32BE(position) & 0x7fffffff) % 1000000; return String(number).padStart(6, "0"); } function verifyTotp(secret, code) { const candidate = String(code || "").trim(); return ( /^\d{6}$/.test(candidate) && [-1, 0, 1].some((offset) => crypto.timingSafeEqual( Buffer.from(totpCode(secret, offset)), Buffer.from(candidate), ), ) ); } function encryptSecret(value) { const key = crypto .createHash("sha256") .update(process.env.AUTH_ENCRYPTION_KEY || "field-log-change-this-key") .digest(), iv = crypto.randomBytes(12), cipher = crypto.createCipheriv("aes-256-gcm", key, iv), encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); return [iv, cipher.getAuthTag(), encrypted] .map((part) => part.toString("base64url")) .join("."); } function decryptSecret(value) { const [iv, tag, encrypted] = value .split(".") .map((part) => Buffer.from(part, "base64url")), key = crypto .createHash("sha256") .update(process.env.AUTH_ENCRYPTION_KEY || "field-log-change-this-key") .digest(), decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString( "utf8", ); } async function authChallenge(userId, type, payload = {}, minutes = 10) { const token = crypto.randomBytes(32).toString("base64url"); await pool.query( "INSERT INTO diary_auth_challenges(token_hash,user_id,type,payload,expires_at) VALUES($1,$2,$3,$4,NOW()+($5||' minutes')::interval)", [hash(token), userId, type, payload, minutes], ); return token; } function cookie(req, name) { return req.headers.cookie ?.split(";") .map((x) => x.trim().split("=")) .find((x) => x[0] === name)?.[1]; } async function auth(req, res, next) { try { const token = cookie(req, "diary_session"); if (!token) return res.status(401).json({ error: "Authentication required" }); const { rows } = await pool.query( `SELECT u.id,u.username,u.email,u.display_name,u.bio,u.email_verified_at,u.two_factor_enabled,u.is_admin FROM diary_sessions s JOIN diary_users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.expires_at>NOW() AND u.disabled_at IS NULL`, [hash(token)], ); if (!rows[0]) return res.status(401).json({ error: "Session expired" }); req.user = rows[0]; await pool.query( "UPDATE diary_sessions SET last_seen_at=NOW() WHERE token_hash=$1 AND last_seen_at ({ id: +r.id, date: r.entry_date instanceof Date ? r.entry_date.toISOString().slice(0, 10) : String(r.entry_date).slice(0, 10), time: r.entry_time ? String(r.entry_time).slice(0, 5) : "", title: r.title, body: r.body, tags: r.tags, media: r.media, link: r.link, visibility: r.visibility, mood: r.mood, location: r.location_name, latitude: r.latitude, longitude: r.longitude, pinned: r.pinned, author: r.display_name, userId: r.user_id ? +r.user_id : null, owner: r.owner ?? true, journalId: r.journal_id ? +r.journal_id : null, encrypted: r.is_encrypted || false, encryptedPayload: r.encrypted_payload || null, reminderAt: r.reminder_at || null, weather: r.weather || null, }); const journal = (row) => ({ id: +row.id, name: row.name, description: row.description, color: row.color, visibility: row.visibility, sortOrder: row.sort_order, isDefault: row.is_default, ownerId: +row.owner_id, entryCount: +(row.entry_count || 0), role: row.role || "owner", owned: row.role ? row.role === "owner" : true, }); async function writableJournal( userId, requestedId, roles = ["owner", "editor", "contributor"], ) { const { rows } = await pool.query( `SELECT j.id,CASE WHEN j.owner_id=$1 THEN 'owner' ELSE m.role END AS role FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 WHERE (j.owner_id=$1 OR m.user_id=$1) AND ($2::bigint IS NULL OR j.id=$2) ORDER BY CASE WHEN j.id=$2 THEN 0 ELSE 1 END,j.is_default DESC,j.sort_order,j.id LIMIT 1`, [userId, requestedId || null], ); return rows[0] && roles.includes(rows[0].role) ? rows[0] : null; } async function entryAccess(userId, entryId) { const { rows } = await pool.query( `SELECT e.id,e.journal_id,e.user_id,e.title,e.is_encrypted,CASE WHEN j.owner_id=$1 THEN 'owner' ELSE m.role END AS role,(e.user_id=$1 OR j.owner_id=$1 OR m.role='editor') AS can_manage FROM diary_entries e JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 WHERE e.id=$2 AND e.deleted_at IS NULL AND (e.user_id=$1 OR j.owner_id=$1 OR m.user_id=$1 OR e.visibility='public')`, [userId, entryId], ); return rows[0] || null; } async function activity( journalId, actorId, action, entryId = null, metadata = {}, ) { await pool.query( "INSERT INTO diary_activity(journal_id,actor_id,entry_id,action,metadata) VALUES($1,$2,$3,$4,$5)", [journalId, actorId, entryId, action, metadata], ); } async function notifyJournalMembers(journalId, actorId, entryId, type) { await pool.query( `INSERT INTO diary_notifications(user_id,actor_id,entry_id,type) SELECT recipients.user_id,$2,$3,$4 FROM (SELECT owner_id AS user_id FROM diary_journals WHERE id=$1 UNION SELECT user_id FROM diary_journal_members WHERE journal_id=$1) recipients JOIN diary_users u ON u.id=recipients.user_id WHERE recipients.user_id<>$2 AND COALESCE((u.preferences->>'notifyShares')::boolean,true) AND NOT EXISTS(SELECT 1 FROM diary_user_blocks b WHERE (b.blocker_id=recipients.user_id AND b.blocked_id=$2) OR (b.blocker_id=$2 AND b.blocked_id=recipients.user_id))`, [journalId, actorId, entryId, type], ); } async function sendAccountToken(req, user, type) { const token = crypto.randomBytes(32).toString("base64url"), hours = type === "verify" ? 48 : 1, route = type === "verify" ? "verify" : "reset", origin = process.env.APP_URL || `${req.protocol}://${req.get("host")}`; await pool.query( "UPDATE diary_account_tokens SET used_at=NOW() WHERE user_id=$1 AND type=$2 AND used_at IS NULL", [user.id, type], ); await pool.query( "INSERT INTO diary_account_tokens(user_id,type,token_hash,expires_at) VALUES($1,$2,$3,NOW()+($4||' hours')::interval)", [user.id, type, hash(token), hours], ); await mailer.sendMail({ from: process.env.MAIL_FROM || "Daymark ", to: user.email, subject: type === "verify" ? "Verify your Daymark email" : "Reset your Daymark password", text: `${type === "verify" ? "Verify your email" : "Reset your password"}: ${origin}/${route}/${token}\n\nThis link expires in ${hours} hour${hours === 1 ? "" : "s"}.`, }); } function webauthnConfig(req) { const origin = process.env.WEBAUTHN_ORIGIN || `${req.protocol}://${req.get("host")}`; return { origin, rpID: process.env.WEBAUTHN_RP_ID || new URL(origin).hostname, rpName: process.env.WEBAUTHN_RP_NAME || "Daymark", }; } let maintenanceCache = { expires: 0, value: null }; async function maintenanceStatus() { if (maintenanceCache.value && maintenanceCache.expires > Date.now()) return maintenanceCache.value; const { rows } = await pool.query( `SELECT maintenance_enabled,maintenance_starts_at,maintenance_ends_at,maintenance_message,(maintenance_enabled AND (maintenance_starts_at IS NULL OR maintenance_starts_at<=NOW()) AND (maintenance_ends_at IS NULL OR maintenance_ends_at>NOW())) AS maintenance_active FROM diary_site_settings WHERE id=true`, ); maintenanceCache = { expires: Date.now() + 5000, value: rows[0] }; return rows[0]; } app.disable("x-powered-by"); app.set("trust proxy", 1); app.use(express.json({ limit: process.env.JSON_LIMIT || "30mb" })); const strictAuthLimit = rateLimit({ windowMs: 15 * 60 * 1000, limit: 20, standardHeaders: "draft-8", legacyHeaders: false, message: { error: "Too many authentication attempts. Try again later." }, }), recoveryLimit = rateLimit({ windowMs: 60 * 60 * 1000, limit: 10, standardHeaders: "draft-8", legacyHeaders: false, message: { error: "Too many recovery requests. Try again later." }, }), publicAccessLimit = rateLimit({ windowMs: 15 * 60 * 1000, limit: 60, standardHeaders: "draft-8", legacyHeaders: false, message: { error: "Too many public-link attempts. Try again later." }, }), apiWriteLimit = rateLimit({ windowMs: 60 * 1000, limit: 180, standardHeaders: "draft-8", legacyHeaders: false, message: { error: "Too many changes. Slow down and try again." }, }); app.use( [ "/api/auth/login", "/api/auth/register", "/api/auth/2fa/login", "/api/auth/passkey/options", "/api/auth/passkey/verify", ], strictAuthLimit, ); app.use( [ "/api/auth/password/request", "/api/auth/password/reset", "/api/auth/verification/resend", ], recoveryLimit, ); app.use("/api/public", publicAccessLimit); app.use("/api", (req, res, next) => ["GET", "HEAD", "OPTIONS"].includes(req.method) ? next() : apiWriteLimit(req, res, next), ); app.use("/api", (req, res, next) => { let csrf = cookie(req, "field_log_csrf"); if (!csrf) { csrf = crypto.randomBytes(24).toString("base64url"); res.append( "Set-Cookie", `field_log_csrf=${csrf}; SameSite=Strict; Path=/; Max-Age=2592000${process.env.COOKIE_SECURE === "true" ? "; Secure" : ""}`, ); } if ( !["GET", "HEAD", "OPTIONS"].includes(req.method) && cookie(req, "diary_session") ) { const supplied = String(req.get("x-csrf-token") || ""); if ( supplied.length !== csrf.length || !crypto.timingSafeEqual(Buffer.from(supplied), Buffer.from(csrf)) ) return res.status(403).json({ error: "Invalid CSRF token" }); } next(); }); app.use("/api", async (req, res, next) => { try { if ( req.path === "/health" || req.path === "/site-settings" || req.path.startsWith("/auth/") ) return next(); const status = await maintenanceStatus(); if (!status?.maintenance_active) return next(); const token = cookie(req, "diary_session"); if (token) { const { rows } = await pool.query( `SELECT u.is_admin FROM diary_sessions s JOIN diary_users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.expires_at>NOW() AND u.disabled_at IS NULL`, [hash(token)], ); if (rows[0]?.is_admin) return next(); } res.status(503).json({ error: "Maintenance mode", maintenance: true, message: status.maintenance_message, startsAt: status.maintenance_starts_at, endsAt: status.maintenance_ends_at, }); } catch (error) { next(error); } }); app.get("/api/health", async (_q, r) => { try { await pool.query("SELECT 1"); r.json({ status: "ok", database: "connected" }); } catch { r.status(503).json({ status: "error" }); } }); app.get("/api/places", auth, async (req, res, next) => { try { const query = String(req.query.q || "") .trim() .slice(0, 160); if (query.length < 3) return res.status(400).json({ error: "Enter at least 3 characters" }); const parameters = new URLSearchParams({ q: query, format: "jsonv2", addressdetails: "1", limit: "6", }), response = await fetch( `https://nominatim.openstreetmap.org/search?${parameters}`, { headers: { "User-Agent": `Daymark/1.0 (${process.env.APP_URL || "self-hosted"})`, Accept: "application/json", }, }, ); if (!response.ok) throw new Error(`Place provider returned ${response.status}`); const places = await response.json(); res.json( places.map((place) => ({ id: place.place_id, name: place.display_name, latitude: +place.lat, longitude: +place.lon, type: place.type, })), ); } catch (error) { next(error); } }); app.get("/api/link-preview", auth, async (req, res) => { try { res.json(await remotePreview(String(req.query.url || ""))); } catch (error) { res .status(422) .json({ error: error.message || "Link preview unavailable" }); } }); app.get("/api/weather", auth, async (req, res, next) => { try { const latitude = +req.query.latitude, longitude = +req.query.longitude, date = String(req.query.date || ""); if ( !Number.isFinite(latitude) || !Number.isFinite(longitude) || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 || !/^\d{4}-\d{2}-\d{2}$/.test(date) ) return res .status(400) .json({ error: "Valid coordinates and date required" }); const historical = date < new Date(Date.now() - 5 * 86400000).toISOString().slice(0, 10), endpoint = historical ? "https://archive-api.open-meteo.com/v1/archive" : "https://api.open-meteo.com/v1/forecast", parameters = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), start_date: date, end_date: date, daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max", timezone: "auto", }), response = await fetch(`${endpoint}?${parameters}`), result = await response.json(); if (!response.ok || !result.daily?.time?.length) return res.status(422).json({ error: result.reason || "Weather is unavailable for this date", }); res.json({ date: result.daily.time[0], code: result.daily.weather_code[0], temperatureMax: result.daily.temperature_2m_max[0], temperatureMin: result.daily.temperature_2m_min[0], precipitation: result.daily.precipitation_sum[0], windMax: result.daily.wind_speed_10m_max[0], timezone: result.timezone, source: "Open-Meteo", }); } catch (error) { next(error); } }); app.get("/api/auth/registration", async (_req, res, next) => { try { const { rows } = await pool.query( "SELECT registration_mode FROM diary_site_settings WHERE id=true", ); res.json({ mode: rows[0].registration_mode }); } catch (error) { next(error); } }); app.post("/api/auth/register", async (req, res, next) => { const client = await pool.connect(); let transactionOpen = false; try { const { username, email, password: pass, displayName } = req.body, inviteCode = String(req.body.inviteCode || "") .trim() .toUpperCase(); if ( !/^[a-z0-9_-]{3,30}$/i.test(username) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || pass?.length < 10 ) return res.status(400).json({ error: "Use a valid username, email, and password of at least 10 characters", }); await client.query("BEGIN"); transactionOpen = true; const setting = await client.query( "SELECT registration_mode FROM diary_site_settings WHERE id=true FOR UPDATE", ), mode = setting.rows[0].registration_mode; if (mode === "closed") { await client.query("ROLLBACK"); transactionOpen = false; return res .status(403) .json({ error: "Registration is currently closed" }); } if (mode === "invite") { const accepted = await client.query( "UPDATE diary_registration_invites SET use_count=use_count+1 WHERE code_hash=$1 AND expires_at>NOW() AND use_count { try { const { rows } = await pool.query( "SELECT * FROM diary_users WHERE (username=lower($1) OR email=lower($1)) AND self_deactivated", [req.body.login], ); if (!rows[0] || !(await verify(req.body.password || "", rows[0].password_hash))) return res.status(401).json({ error: "Invalid credentials or account was disabled by an administrator" }); await pool.query("UPDATE diary_users SET disabled_at=NULL,self_deactivated=false WHERE id=$1", [rows[0].id]); await login(rows[0], req, res, "reactivation"); const { password_hash, totp_secret, recovery_code_hashes, ...user } = rows[0]; res.json({ ...user, disabled_at: null, self_deactivated: false }); } catch (error) { next(error); } }); app.post("/api/auth/login", async (req, res, next) => { try { const { rows } = await pool.query( "SELECT * FROM diary_users WHERE username=lower($1) OR email=lower($1)", [req.body.login], ); if ( !rows[0] || rows[0].disabled_at || !(await verify(req.body.password || "", rows[0].password_hash)) ) { await recordLogin( req, rows[0]?.id || null, String(req.body.login || ""), false, "password", ); if (rows[0]?.id) await pool.query( `INSERT INTO diary_notifications(user_id,type) SELECT id,'security_failed_login' FROM diary_users WHERE id=$1 AND COALESCE((preferences->>'notifySecurity')::boolean,true)`, [rows[0].id], ); return res.status(401).json({ error: "Invalid credentials" }); } if (rows[0].two_factor_enabled) return res.json({ requiresTwoFactor: true, challenge: await authChallenge(rows[0].id, "login-2fa"), }); await login(rows[0], req, res, "password"); const { password_hash, totp_secret, recovery_code_hashes, ...user } = rows[0]; res.json(user); } catch (e) { next(e); } }); app.post("/api/auth/2fa/login", async (req, res, next) => { const client = await pool.connect(); try { await client.query("BEGIN"); const challenge = await client.query( "SELECT * FROM diary_auth_challenges WHERE token_hash=$1 AND type='login-2fa' AND expires_at>NOW() FOR UPDATE", [hash(String(req.body.challenge || ""))], ); if (!challenge.rows[0]) { await client.query("ROLLBACK"); return res.status(401).json({ error: "Login challenge expired" }); } const userResult = await client.query( "SELECT * FROM diary_users WHERE id=$1", [challenge.rows[0].user_id], ), user = userResult.rows[0], code = String(req.body.code || "") .trim() .toUpperCase(), recoveryIndex = (user.recovery_code_hashes || []).findIndex( (item) => item === hash(code), ), valid = verifyTotp(decryptSecret(user.totp_secret), code) || recoveryIndex >= 0; if (!valid) { await client.query("ROLLBACK"); return res.status(401).json({ error: "Invalid authentication code" }); } if (recoveryIndex >= 0) { const remaining = [...user.recovery_code_hashes]; remaining.splice(recoveryIndex, 1); await client.query( "UPDATE diary_users SET recovery_code_hashes=$1 WHERE id=$2", [JSON.stringify(remaining), user.id], ); } await client.query( "DELETE FROM diary_auth_challenges WHERE token_hash=$1", [hash(req.body.challenge)], ); await client.query("COMMIT"); await login(user, req, res, recoveryIndex >= 0 ? "recovery-code" : "totp"); const { password_hash, totp_secret, recovery_code_hashes, ...safeUser } = user; res.json(safeUser); } catch (error) { await client.query("ROLLBACK"); next(error); } finally { client.release(); } }); async function recordLogin(req, userId, identifier, success, method) { await pool.query( "INSERT INTO diary_login_history(user_id,identifier,success,method,ip_address,user_agent) VALUES($1,$2,$3,$4,$5,$6)", [ userId, identifier.slice(0, 254), success, method, req.ip || "", String(req.get("user-agent") || "").slice(0, 500), ], ); } async function login(user, req, res, method = "password") { const token = crypto.randomBytes(32).toString("base64url"), ip = req.ip || "", userAgent = String(req.get("user-agent") || "").slice(0, 500), knownDevice = await pool.query( "SELECT 1 FROM diary_sessions WHERE user_id=$1 AND ip_address=$2 AND user_agent=$3 LIMIT 1", [user.id, ip, userAgent], ); await pool.query( "INSERT INTO diary_sessions(token_hash,user_id,expires_at,ip_address,user_agent) VALUES($1,$2,NOW()+INTERVAL '30 days',$3,$4)", [ hash(token), user.id, ip, userAgent, ], ); await recordLogin( req, user.id, user.email || user.username || "", true, method, ); if (method !== "registration" && !knownDevice.rowCount) await pool.query( `INSERT INTO diary_notifications(user_id,type) SELECT id,'security_new_device' FROM diary_users WHERE id=$1 AND COALESCE((preferences->>'notifySecurity')::boolean,true)`, [user.id], ); res.setHeader( "Set-Cookie", `diary_session=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000${process.env.COOKIE_SECURE === "true" ? "; Secure" : ""}`, ); } app.get("/api/auth/me", auth, (q, r) => r.json(q.user)); const defaultUserPreferences = { defaultVisibility: "private", defaultJournal: "", defaultView: "diary", entriesPerPage: 20, dateFormat: "locale", timeFormat: "24h", timezone: "auto", temperatureUnit: "celsius", distanceUnit: "metric", weekStartsOn: "monday", spellcheck: true, autosave: true, editorMode: "markdown", imageQuality: "high", mapZoom: 11, rememberEntryOptions: true, theme: "tokyo-night", fontScale: 100, density: "comfortable", reduceMotion: false, compactEntries: false, accentColor: "#7aa2f7", headerImage: "", notifyComments: true, notifyReplies: true, notifyMentions: true, notifyShares: true, notifySecurity: true, notificationInApp: true, notificationBrowser: false, notificationEmail: false, digestFrequency: "instant", digestTime: "08:00", quietHoursStart: "", quietHoursEnd: "", locationPrecision: "exact", commentPolicy: "members", approveComments: false, profileVisibility: "private", shareExpiryDays: 7, avatarUrl: "", pronouns: "", personalWebsite: "", homeLocation: "", language: "auto", customTemplates: [], customPrompts: [], writingGoals: { weeklyEntries: 3, monthlyWords: 5000 }, recurringPromptTime: "", recurringPromptDays: [], }; function timeInZone(timezone) { try { return new Intl.DateTimeFormat("en-GB", { timeZone: timezone && timezone !== "auto" ? timezone : undefined, hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date()); } catch { return new Date().toTimeString().slice(0, 5); } } function withinQuietHours(preferences) { const start = preferences.quietHoursStart, end = preferences.quietHoursEnd; if (!start || !end || start === end) return false; const now = timeInZone(preferences.timezone); return start < end ? now >= start && now < end : now >= start || now < end; } async function deliverNotificationEmails() { const { rows } = await pool.query( `SELECT n.id,n.user_id,n.type,n.created_at,u.email,u.display_name,u.preferences,a.display_name AS actor,e.title AS entry_title FROM diary_notifications n JOIN diary_users u ON u.id=n.user_id LEFT JOIN diary_users a ON a.id=n.actor_id LEFT JOIN diary_entries e ON e.id=n.entry_id WHERE u.email_verified_at IS NOT NULL AND n.email_sent_at IS NULL AND (n.email_attempted_at IS NULL OR n.email_attempted_at item.id); await pool.query("UPDATE diary_notifications SET email_attempted_at=NOW() WHERE id=ANY($1::bigint[])", [ids]); try { const lines = group.items.map((item) => `${item.actor || "Diary security"} ${item.type.replaceAll("_", " ")}${item.entry_title ? `: ${item.entry_title}` : ""}`), digest = group.preferences.digestFrequency !== "instant" || lines.length > 1; await mailer.sendMail({ from: process.env.MAIL_FROM || "Daymark ", to: group.user.email, subject: digest ? `${lines.length} diary notifications` : lines[0], text: `Hello ${group.user.display_name},\n\n${lines.map((line) => `- ${line}`).join("\n")}\n\nOpen your inbox: ${(process.env.APP_URL || "").replace(/\/$/, "")}/settings#notifications`, }); await pool.query("UPDATE diary_notifications SET email_sent_at=NOW() WHERE id=ANY($1::bigint[])", [ids]); } catch (error) { console.error("Notification email delivery failed", error.message); } } } app.get("/api/preferences", auth, async (req, res, next) => { try { const { rows } = await pool.query( "SELECT preferences FROM diary_users WHERE id=$1", [req.user.id], ); res.json({ ...defaultUserPreferences, ...(rows[0]?.preferences || {}) }); } catch (error) { next(error); } }); app.put("/api/preferences", auth, async (req, res, next) => { try { const clean = {}; for (const [key, fallback] of Object.entries(defaultUserPreferences)) { if (!(key in req.body)) continue; const value = req.body[key]; if (typeof fallback === "boolean") clean[key] = !!value; else if (typeof fallback === "number") { const numeric = Number(value); if (Number.isFinite(numeric)) clean[key] = numeric; } else if (Array.isArray(fallback)) clean[key] = Array.isArray(value) ? value.slice(0, 50) : []; else if (fallback && typeof fallback === "object") clean[key] = value && typeof value === "object" && !Array.isArray(value) ? value : fallback; else clean[key] = String(value).slice(0, 500); } const { rows } = await pool.query( "UPDATE diary_users SET preferences=preferences||$1::jsonb WHERE id=$2 RETURNING preferences", [JSON.stringify(clean), req.user.id], ); res.json({ ...defaultUserPreferences, ...rows[0].preferences }); } catch (error) { next(error); } }); app.put("/api/profile", auth, async (req, res, next) => { try { const displayName = String(req.body.displayName || "").trim().slice(0, 100); const bio = String(req.body.bio || "").trim().slice(0, 2000); if (displayName.length < 2) return res.status(400).json({ error: "Display name must contain at least two characters" }); const { rows } = await pool.query( "UPDATE diary_users SET display_name=$1,bio=$2 WHERE id=$3 RETURNING id,username,email,display_name,bio,email_verified_at,two_factor_enabled,is_admin", [displayName, bio, req.user.id], ); res.json(rows[0]); } catch (error) { next(error); } }); app.post("/api/auth/logout", auth, async (q, r) => { await pool.query("DELETE FROM diary_sessions WHERE token_hash=$1", [ hash(cookie(q, "diary_session")), ]); r.setHeader( "Set-Cookie", "diary_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", ); r.status(204).end(); }); app.get("/api/security", auth, async (req, res, next) => { try { const { rows } = await pool.query( "SELECT two_factor_enabled,jsonb_array_length(recovery_code_hashes) AS recovery_codes FROM diary_users WHERE id=$1", [req.user.id], ), passkeys = await pool.query( "SELECT id,name,device_type,backed_up,created_at,last_used_at FROM diary_passkeys WHERE user_id=$1 ORDER BY created_at DESC", [req.user.id], ); res.json({ twoFactorEnabled: rows[0].two_factor_enabled, recoveryCodes: +rows[0].recovery_codes, passkeys: passkeys.rows.map((item) => ({ ...item, id: +item.id })), }); } catch (error) { next(error); } }); app.get("/api/security/sessions", auth, async (req, res, next) => { try { const currentHash = hash(cookie(req, "diary_session") || ""), { rows } = await pool.query( "SELECT id,created_at,last_seen_at,expires_at,ip_address,user_agent,(token_hash=$2) AS current FROM diary_sessions WHERE user_id=$1 AND expires_at>NOW() ORDER BY current DESC,last_seen_at DESC", [req.user.id, currentHash], ); res.json(rows); } catch (error) { next(error); } }); app.delete("/api/security/sessions/:id", auth, async (req, res, next) => { try { const currentHash = hash(cookie(req, "diary_session") || ""), { rows } = await pool.query( "DELETE FROM diary_sessions WHERE id=$1 AND user_id=$2 RETURNING token_hash", [req.params.id, req.user.id], ); if (!rows[0]) return res.status(404).json({ error: "Session not found" }); if (rows[0].token_hash === currentHash) res.setHeader( "Set-Cookie", "diary_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", ); res.status(204).end(); } catch (error) { next(error); } }); app.delete("/api/security/sessions", auth, async (req, res, next) => { try { const currentHash = hash(cookie(req, "diary_session") || ""); await pool.query( "DELETE FROM diary_sessions WHERE user_id=$1 AND token_hash<>$2", [req.user.id, currentHash], ); res.status(204).end(); } catch (error) { next(error); } }); app.get("/api/security/login-history", auth, async (req, res, next) => { try { const { rows } = await pool.query( "SELECT id,success,method,ip_address,user_agent,created_at FROM diary_login_history WHERE user_id=$1 ORDER BY created_at DESC LIMIT 100", [req.user.id], ); res.json(rows); } catch (error) { next(error); } }); app.post("/api/security/passkeys/options", auth, async (req, res, next) => { try { const config = webauthnConfig(req), existing = await pool.query( "SELECT credential_id,transports FROM diary_passkeys WHERE user_id=$1", [req.user.id], ), options = await generateRegistrationOptions({ rpName: config.rpName, rpID: config.rpID, userName: req.user.email, userDisplayName: req.user.display_name, userID: Buffer.from(String(req.user.id)), attestationType: "none", excludeCredentials: existing.rows.map((item) => ({ id: item.credential_id, transports: item.transports, })), authenticatorSelection: { residentKey: "preferred", userVerification: "required", }, }), challengeToken = await authChallenge(req.user.id, "passkey-register", { challenge: options.challenge, origin: config.origin, rpID: config.rpID, }); res.json({ options, challengeToken }); } catch (error) { next(error); } }); app.post("/api/security/passkeys/verify", auth, async (req, res, next) => { try { const challenge = await pool.query( "SELECT * FROM diary_auth_challenges WHERE token_hash=$1 AND user_id=$2 AND type='passkey-register' AND expires_at>NOW()", [hash(String(req.body.challengeToken || "")), req.user.id], ); if (!challenge.rows[0]) return res.status(404).json({ error: "Passkey challenge expired" }); const payload = challenge.rows[0].payload, verification = await verifyRegistrationResponse({ response: req.body.response, expectedChallenge: payload.challenge, expectedOrigin: payload.origin, expectedRPID: payload.rpID, }); if (!verification.verified) return res .status(400) .json({ error: "Passkey registration could not be verified" }); const info = verification.registrationInfo, credential = info.credential, name = String(req.body.name || "Passkey") .trim() .slice(0, 80) || "Passkey"; await pool.query( `INSERT INTO diary_passkeys(user_id,credential_id,public_key,counter,transports,device_type,backed_up,name) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, [ req.user.id, credential.id, Buffer.from(credential.publicKey), credential.counter, JSON.stringify(credential.transports || []), info.credentialDeviceType, info.credentialBackedUp, name, ], ); await pool.query("DELETE FROM diary_auth_challenges WHERE token_hash=$1", [ hash(req.body.challengeToken), ]); res.status(201).json({ verified: true }); } catch (error) { if (error.code === "23505") return res .status(409) .json({ error: "This passkey is already registered" }); next(error); } }); app.delete("/api/security/passkeys/:id", auth, async (req, res, next) => { try { const result = await pool.query( "DELETE FROM diary_passkeys WHERE id=$1 AND user_id=$2", [req.params.id, req.user.id], ); if (!result.rowCount) return res.status(404).json({ error: "Passkey not found" }); res.status(204).end(); } catch (error) { next(error); } }); app.post("/api/auth/passkey/options", async (req, res, next) => { try { const { rows } = await pool.query( "SELECT id FROM diary_users WHERE username=lower($1) OR email=lower($1)", [String(req.body.login || "")], ); if (!rows[0]) return res.status(404).json({ error: "No passkey account found" }); const credentials = await pool.query( "SELECT credential_id,transports FROM diary_passkeys WHERE user_id=$1", [rows[0].id], ); if (!credentials.rowCount) return res .status(404) .json({ error: "No passkeys registered for this account" }); const config = webauthnConfig(req), options = await generateAuthenticationOptions({ rpID: config.rpID, allowCredentials: credentials.rows.map((item) => ({ id: item.credential_id, transports: item.transports, })), userVerification: "required", }), challengeToken = await authChallenge(rows[0].id, "passkey-login", { challenge: options.challenge, origin: config.origin, rpID: config.rpID, }); res.json({ options, challengeToken }); } catch (error) { next(error); } }); app.post("/api/auth/passkey/verify", async (req, res, next) => { try { const challenge = await pool.query( "SELECT * FROM diary_auth_challenges WHERE token_hash=$1 AND type='passkey-login' AND expires_at>NOW()", [hash(String(req.body.challengeToken || ""))], ); if (!challenge.rows[0]) return res.status(404).json({ error: "Passkey challenge expired" }); const passkey = await pool.query( "SELECT * FROM diary_passkeys WHERE credential_id=$1 AND user_id=$2", [req.body.response?.id, challenge.rows[0].user_id], ); if (!passkey.rows[0]) return res.status(404).json({ error: "Passkey not found" }); const item = passkey.rows[0], payload = challenge.rows[0].payload, verification = await verifyAuthenticationResponse({ response: req.body.response, expectedChallenge: payload.challenge, expectedOrigin: payload.origin, expectedRPID: payload.rpID, credential: { id: item.credential_id, publicKey: new Uint8Array(item.public_key), counter: +item.counter, transports: item.transports, }, requireUserVerification: true, }); if (!verification.verified) return res.status(401).json({ error: "Passkey verification failed" }); await pool.query( "UPDATE diary_passkeys SET counter=$1,last_used_at=NOW() WHERE id=$2", [verification.authenticationInfo.newCounter, item.id], ); await pool.query("DELETE FROM diary_auth_challenges WHERE token_hash=$1", [ hash(req.body.challengeToken), ]); const userResult = await pool.query( "SELECT * FROM diary_users WHERE id=$1", [item.user_id], ), user = userResult.rows[0]; await login(user, req, res, "passkey"); const { password_hash, totp_secret, recovery_code_hashes, ...safeUser } = user; res.json(safeUser); } catch (error) { next(error); } }); app.post("/api/security/2fa/setup", auth, async (req, res, next) => { try { const { rows } = await pool.query( "SELECT password_hash,two_factor_enabled FROM diary_users WHERE id=$1", [req.user.id], ); if (!(await verify(String(req.body.password || ""), rows[0].password_hash))) return res.status(401).json({ error: "Current password is incorrect" }); if (rows[0].two_factor_enabled) return res .status(409) .json({ error: "Two-factor authentication is already enabled" }); const secret = base32Encode(crypto.randomBytes(20)), issuer = encodeURIComponent("Daymark"), account = encodeURIComponent(req.user.email), uri = `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=6&period=30`, challenge = await authChallenge(req.user.id, "setup-2fa", { secret: encryptSecret(secret), }); res.json({ challenge, secret, qr: await QRCode.toDataURL(uri), uri }); } catch (error) { next(error); } }); app.post("/api/security/2fa/confirm", auth, async (req, res, next) => { try { const { rows } = await pool.query( "SELECT * FROM diary_auth_challenges WHERE token_hash=$1 AND user_id=$2 AND type='setup-2fa' AND expires_at>NOW()", [hash(String(req.body.challenge || "")), req.user.id], ); if (!rows[0]) return res.status(404).json({ error: "Setup challenge expired" }); const encryptedSecret = rows[0].payload.secret, secret = decryptSecret(encryptedSecret); if (!verifyTotp(secret, req.body.code)) return res.status(401).json({ error: "Invalid authenticator code" }); const recoveryCodes = Array.from( { length: 10 }, () => `${base32Encode(crypto.randomBytes(5)).slice(0, 4)}-${base32Encode(crypto.randomBytes(5)).slice(0, 4)}`, ); await pool.query( "UPDATE diary_users SET totp_secret=$1,two_factor_enabled=true,recovery_code_hashes=$2 WHERE id=$3", [ encryptedSecret, JSON.stringify(recoveryCodes.map((code) => hash(code))), req.user.id, ], ); await pool.query("DELETE FROM diary_auth_challenges WHERE token_hash=$1", [ hash(req.body.challenge), ]); res.json({ enabled: true, recoveryCodes }); } catch (error) { next(error); } }); app.delete("/api/security/2fa", auth, async (req, res, next) => { try { const { rows } = await pool.query( "SELECT password_hash,totp_secret,two_factor_enabled FROM diary_users WHERE id=$1", [req.user.id], ); if (!(await verify(String(req.body.password || ""), rows[0].password_hash))) return res.status(401).json({ error: "Current password is incorrect" }); if ( !rows[0].two_factor_enabled || !verifyTotp(decryptSecret(rows[0].totp_secret), req.body.code) ) return res.status(401).json({ error: "Invalid authenticator code" }); await pool.query( "UPDATE diary_users SET totp_secret=NULL,two_factor_enabled=false,recovery_code_hashes='[]' WHERE id=$1", [req.user.id], ); await pool.query("DELETE FROM diary_auth_challenges WHERE user_id=$1", [ req.user.id, ]); res.status(204).end(); } catch (error) { next(error); } }); app.post("/api/auth/verification/resend", auth, async (req, res, next) => { try { if (req.user.email_verified_at) return res.status(204).end(); await sendAccountToken(req, req.user, "verify"); res.status(204).end(); } catch (error) { next(error); } }); app.post("/api/auth/verify/:token", async (req, res, next) => { const client = await pool.connect(); try { await client.query("BEGIN"); const { rows } = await client.query( "SELECT * FROM diary_account_tokens WHERE token_hash=$1 AND type='verify' AND used_at IS NULL AND expires_at>NOW() FOR UPDATE", [hash(req.params.token)], ); if (!rows[0]) { await client.query("ROLLBACK"); return res .status(404) .json({ error: "Verification link is invalid or expired" }); } await client.query( "UPDATE diary_users SET email_verified_at=NOW() WHERE id=$1", [rows[0].user_id], ); await client.query( "UPDATE diary_account_tokens SET used_at=NOW() WHERE id=$1", [rows[0].id], ); await client.query("COMMIT"); res.json({ verified: true }); } catch (error) { await client.query("ROLLBACK"); next(error); } finally { client.release(); } }); app.post("/api/auth/password/request", async (req, res, next) => { try { const { rows } = await pool.query( "SELECT id,email,display_name FROM diary_users WHERE email=lower($1)", [String(req.body.email || "")], ); if (rows[0]) await sendAccountToken(req, rows[0], "reset").catch(console.error); res.json({ message: "If that address exists, a reset link has been sent" }); } catch (error) { next(error); } }); app.post("/api/auth/password/reset/:token", async (req, res, next) => { const client = await pool.connect(); try { const pass = String(req.body.password || ""); if (pass.length < 10) return res .status(400) .json({ error: "Password needs at least 10 characters" }); await client.query("BEGIN"); const { rows } = await client.query( "SELECT * FROM diary_account_tokens WHERE token_hash=$1 AND type='reset' AND used_at IS NULL AND expires_at>NOW() FOR UPDATE", [hash(req.params.token)], ); if (!rows[0]) { await client.query("ROLLBACK"); return res .status(404) .json({ error: "Reset link is invalid or expired" }); } await client.query("UPDATE diary_users SET password_hash=$1 WHERE id=$2", [ await password(pass), rows[0].user_id, ]); await client.query( "UPDATE diary_account_tokens SET used_at=NOW() WHERE id=$1", [rows[0].id], ); await client.query("DELETE FROM diary_sessions WHERE user_id=$1", [ rows[0].user_id, ]); await client.query("COMMIT"); res.json({ reset: true }); } catch (error) { await client.query("ROLLBACK"); next(error); } finally { client.release(); } }); function admin(req, res, next) { return req.user.is_admin ? next() : res.status(403).json({ error: "Administrator required" }); } async function auditAdmin( req, action, targetType = "", targetId = "", details = {}, executor = pool, ) { await executor.query( "INSERT INTO diary_admin_audit(admin_id,action,target_type,target_id,details,ip_address) VALUES($1,$2,$3,$4,$5,$6)", [ req.user.id, action, targetType, String(targetId || ""), JSON.stringify(details), req.ip || "", ], ); } async function backupInventory() { await mkdir(backupPath, { recursive: true }); const files = await readdir(backupPath), backups = new Map(); for (const name of files) { const match = name.match(/^(diary|media)-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)\.(dump|tar\.gz)$/); if (!match) continue; const [, type, stamp] = match, information = await stat(path.join(backupPath, name)), item = backups.get(stamp) || { stamp, createdAt: information.mtime.toISOString(), database: null, media: null, }; item[type === "diary" ? "database" : "media"] = { name, bytes: information.size, }; if (information.mtime.toISOString() < item.createdAt) item.createdAt = information.mtime.toISOString(); backups.set(stamp, item); } return [...backups.values()] .map((item) => ({ ...item, complete: !!item.database && !!item.media, bytes: (item.database?.bytes || 0) + (item.media?.bytes || 0), })) .sort((a, b) => b.stamp.localeCompare(a.stamp)); } app.get("/api/admin/backups", auth, admin, async (_req, res, next) => { try { res.json({ running: backupRunning, backups: await backupInventory() }); } catch (error) { next(error); } }); app.post("/api/admin/backups", auth, admin, async (req, res, next) => { if (backupRunning) return res.status(409).json({ error: "A backup is already running" }); backupRunning = true; const stamp = new Date().toISOString().replace(/:/g, "-").replace(/\.\d{3}Z$/, "Z"), databaseFile = path.join(backupPath, `diary-${stamp}.dump`), mediaFile = path.join(backupPath, `media-${stamp}.tar.gz`); try { await mkdir(backupPath, { recursive: true }); const pgEnvironment = { ...process.env, PGHOST: process.env.DB_HOST || "database", PGPORT: process.env.DB_PORT || "5432", PGDATABASE: process.env.DB_NAME || "diary", PGUSER: process.env.DB_USER || "diary_user", PGPASSWORD: process.env.DB_PASSWORD || "", }; await runFile("pg_dump", ["--format=custom", "--file", databaseFile], { env: pgEnvironment, timeout: 30 * 60 * 1000, }); await runFile("tar", ["-czf", mediaFile, "-C", "/minio-data", "."], { timeout: 30 * 60 * 1000, }); await auditAdmin(req, "backup.created", "backup", stamp); const backups = await backupInventory(); res.status(201).json(backups.find((item) => item.stamp === stamp)); } catch (error) { await Promise.all([ unlink(databaseFile).catch(() => {}), unlink(mediaFile).catch(() => {}), ]); next(error); } finally { backupRunning = false; } }); app.get( "/api/admin/backups/:stamp/:type", auth, admin, async (req, res, next) => { try { if (!/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z$/.test(req.params.stamp)) return res.status(400).json({ error: "Invalid backup timestamp" }); const fileName = req.params.type === "database" ? `diary-${req.params.stamp}.dump` : req.params.type === "media" ? `media-${req.params.stamp}.tar.gz` : null; if (!fileName) return res.status(400).json({ error: "Invalid backup type" }); res.download(path.join(backupPath, fileName), fileName); } catch (error) { next(error); } }, ); app.delete("/api/admin/backups/:stamp", auth, admin, async (req, res, next) => { try { const { stamp } = req.params; if (!/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z$/.test(stamp)) return res.status(400).json({ error: "Invalid backup timestamp" }); let deleted = 0; for (const name of [`diary-${stamp}.dump`, `media-${stamp}.tar.gz`]) { try { await unlink(path.join(backupPath, name)); deleted += 1; } catch (error) { if (error.code !== "ENOENT") throw error; } } if (!deleted) return res.status(404).json({ error: "Backup not found" }); await auditAdmin(req, "backup.deleted", "backup", stamp, { files: deleted }); res.json({ deleted: true }); } catch (error) { next(error); } }); app.get("/api/admin/moderation", auth, admin, async (_req, res, next) => { try { const comments = await pool.query( `SELECT c.id,c.entry_id,c.parent_id,c.body,c.created_at,c.updated_at,u.id AS user_id,u.display_name,u.username,e.title AS entry_title,j.name AS journal_name FROM diary_comments c JOIN diary_users u ON u.id=c.user_id JOIN diary_entries e ON e.id=c.entry_id JOIN diary_journals j ON j.id=e.journal_id WHERE c.deleted_at IS NULL ORDER BY c.created_at DESC LIMIT 200`, ), links = await pool.query( `SELECT l.id,l.entry_id,l.expires_at,l.revoked_at,l.view_count,l.created_at,l.last_viewed_at,(l.password_hash IS NOT NULL) AS password_protected,e.title AS entry_title,u.display_name,u.username FROM diary_public_links l JOIN diary_entries e ON e.id=l.entry_id LEFT JOIN diary_users u ON u.id=l.created_by ORDER BY l.created_at DESC LIMIT 200`, ); res.json({ comments: comments.rows, links: links.rows }); } catch (error) { next(error); } }); app.delete( "/api/admin/moderation/comments/:id", auth, admin, async (req, res, next) => { try { const { rows } = await pool.query( "DELETE FROM diary_comments WHERE id=$1 RETURNING id,entry_id,user_id,left(body,160) AS excerpt", [+req.params.id], ); if (!rows[0]) return res.status(404).json({ error: "Comment not found" }); await auditAdmin(req, "moderation.comment_deleted", "comment", rows[0].id, { entryId: rows[0].entry_id, userId: rows[0].user_id, excerpt: rows[0].excerpt, }); res.json({ deleted: true }); } catch (error) { next(error); } }, ); app.delete( "/api/admin/moderation/public-links/:id", auth, admin, async (req, res, next) => { try { const { rows } = await pool.query( "UPDATE diary_public_links SET revoked_at=COALESCE(revoked_at,NOW()) WHERE id=$1 RETURNING id,entry_id,view_count", [+req.params.id], ); if (!rows[0]) return res.status(404).json({ error: "Public link not found" }); await auditAdmin( req, "moderation.public_link_revoked", "public_link", rows[0].id, { entryId: rows[0].entry_id, views: rows[0].view_count }, ); res.json({ revoked: true }); } catch (error) { next(error); } }, ); app.post("/api/admin/reports/:format", auth, admin, async (req, res, next) => { try { const format = req.params.format, days = Math.min(365, Math.max(7, Math.round(Number(req.query.days) || 30))); if (!['csv', 'pdf'].includes(format)) return res.status(400).json({ error: "Report format must be CSV or PDF" }); const totals = await pool.query( `SELECT (SELECT count(*) FROM diary_users)::int AS users,(SELECT count(*) FROM diary_users WHERE disabled_at IS NULL)::int AS active_users,(SELECT count(*) FROM diary_entries WHERE deleted_at IS NULL)::int AS entries,(SELECT count(*) FROM diary_journals)::int AS journals,(SELECT count(*) FROM diary_comments)::int AS comments,(SELECT count(*) FROM diary_public_links WHERE revoked_at IS NULL AND expires_at>NOW())::int AS active_public_links,(SELECT count(*) FROM diary_login_history WHERE NOT success AND created_at>NOW()-INTERVAL '24 hours')::int AS failed_logins_24h`, ), daily = await pool.query( `WITH days AS (SELECT generate_series(CURRENT_DATE-($1::int-1),CURRENT_DATE,INTERVAL '1 day')::date AS day) SELECT day::text AS date,(SELECT count(*) FROM diary_entries WHERE created_at>=day AND created_at=day AND created_at=day AND created_at=day AND created_at=day AND created_at>'size' ~ '^[0-9]+$' THEN (item->>'size')::bigint ELSE 0 END),0)::bigint FROM diary_entries se CROSS JOIN LATERAL jsonb_array_elements(COALESCE(se.media,'[]'::jsonb)) item WHERE se.user_id=u.id AND se.deleted_at IS NULL) AS storage_used FROM diary_users u LEFT JOIN diary_entries e ON e.user_id=u.id GROUP BY u.id ORDER BY entries DESC,u.display_name`, [storageQuota], ), visibility = await pool.query( `SELECT visibility,count(*)::int AS count FROM diary_entries WHERE deleted_at IS NULL GROUP BY visibility ORDER BY count DESC`, ), audit = await pool.query( `SELECT a.action,a.target_type,a.target_id,a.created_at,u.display_name,u.username FROM diary_admin_audit a LEFT JOIN diary_users u ON u.id=a.admin_id ORDER BY a.created_at DESC LIMIT 50`, ), generatedAt = new Date(), fileDate = generatedAt.toISOString().slice(0, 10), byteLabel = (value) => { const bytes = +value || 0; if (!bytes) return "0 B"; const unit = Math.min(4, Math.floor(Math.log(bytes) / Math.log(1024))); return `${(bytes / 1024 ** unit).toFixed(unit ? 1 : 0)} ${["B", "KB", "MB", "GB", "TB"][unit]}`; }; await auditAdmin(req, "analytics.report_generated", "report", format, { days, }); if (format === "csv") { const escape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`, rows = [["section", "date", "metric", "value", "details"]]; for (const [metric, value] of Object.entries(totals.rows[0])) rows.push(["summary", generatedAt.toISOString(), metric, value, ""]); for (const item of daily.rows) for (const metric of ["entries", "comments", "users", "successful_logins", "failed_logins"]) rows.push(["daily", item.date, metric, item[metric], `${days}-day range`]); for (const item of visibility.rows) rows.push(["visibility", generatedAt.toISOString(), item.visibility, item.count, "entries"]); for (const item of users.rows) rows.push(["user", generatedAt.toISOString(), item.username, item.entries, `${item.display_name}; ${item.email}; storage ${byteLabel(item.storage_used)} / ${byteLabel(item.storage_quota)}; admin ${item.is_admin}; disabled ${item.disabled}`]); for (const item of audit.rows) rows.push(["audit", item.created_at, item.action, item.target_id, `${item.display_name || "deleted administrator"}; ${item.target_type}`]); res.setHeader("Content-Type", "text/csv; charset=utf-8"); res.setHeader("Content-Disposition", `attachment; filename=diary-admin-report-${fileDate}.csv`); return res.send(`\uFEFF${rows.map((row) => row.map(escape).join(",")).join("\n")}`); } res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Disposition", `attachment; filename=diary-admin-report-${fileDate}.pdf`); const doc = new PDFDocument({ margin: 48, size: "A4", bufferPages: true }); doc.pipe(res); const heading = (title) => { if (doc.y > 700) doc.addPage(); doc.moveDown(0.7).fontSize(13).fillColor("#1f3a5f").text(title).moveDown(0.35); }, line = (label, value, detail = "") => { if (doc.y > 750) doc.addPage(); doc.fontSize(8).fillColor("#111827").text(`${label}: ${value}`, { continued: !!detail }); if (detail) doc.fillColor("#6b7280").text(` ${detail}`); }; doc.fontSize(22).fillColor("#111827").text("Daymark", { align: "left" }); doc.fontSize(11).fillColor("#4b5563").text("Administrator analytics report"); doc.fontSize(8).text(`Generated ${generatedAt.toLocaleString("en-GB")} · Reporting period ${days} days`); heading("Site summary"); for (const [metric, value] of Object.entries(totals.rows[0])) line(metric.replaceAll("_", " ").toUpperCase(), value); heading("Daily activity"); for (const item of daily.rows) line(item.date, `${item.entries} entries · ${item.comments} comments · ${item.users} users`, `${item.successful_logins} successful / ${item.failed_logins} failed logins`); heading("Entry visibility"); for (const item of visibility.rows) line(item.visibility.toUpperCase(), item.count); heading("Users and storage"); for (const item of users.rows) line(`${item.display_name} (@${item.username})`, `${item.entries} entries`, `${byteLabel(item.storage_used)} / ${byteLabel(item.storage_quota)} · ${item.is_admin ? "admin" : "user"}${item.disabled ? " · disabled" : ""}`); heading("Recent administrator activity"); for (const item of audit.rows) line(new Date(item.created_at).toLocaleString("en-GB"), item.action, `${item.display_name || "deleted administrator"} · ${item.target_type}${item.target_id ? ` #${item.target_id}` : ""}`); doc.end(); } catch (error) { next(error); } }); app.get("/api/site-settings", async (_req, res, next) => { try { const status = await maintenanceStatus(), { rows } = await pool.query( "SELECT site_name,site_tagline,site_notice,footer_text FROM diary_site_settings WHERE id=true", ); res.json({ ...rows[0], ...status }); } catch (error) { next(error); } }); app.get("/api/authors/:username", async (req, res, next) => { try { const { rows } = await pool.query( "SELECT id,username,display_name,bio,preferences,created_at FROM diary_users WHERE username=lower($1) AND disabled_at IS NULL", [req.params.username], ); const author = rows[0], preferences = { ...defaultUserPreferences, ...(author?.preferences || {}) }; if (!author || preferences.profileVisibility !== "public") return res.status(404).json({ error: "Public profile not found" }); const entries = await pool.query( "SELECT e.*,u.display_name FROM diary_entries e JOIN diary_users u ON u.id=e.user_id WHERE e.user_id=$1 AND e.visibility='public' AND e.deleted_at IS NULL AND NOT e.is_encrypted ORDER BY e.entry_date DESC,e.created_at DESC LIMIT 100", [author.id], ); res.json({ profile: { username: author.username, displayName: author.display_name, bio: author.bio, joinedAt: author.created_at, avatarUrl: preferences.avatarUrl?.startsWith("/api/media/") ? `/api/authors/${author.username}/avatar` : preferences.avatarUrl, pronouns: preferences.pronouns, personalWebsite: preferences.personalWebsite }, entries: entries.rows.map((item) => ({ ...entry(item), media: [] })), }); } catch (error) { next(error); } }); app.get("/api/authors/:username/avatar", async (req, res, next) => { try { const { rows } = await pool.query("SELECT id,preferences FROM diary_users WHERE username=lower($1) AND disabled_at IS NULL", [req.params.username]), user = rows[0], preferences = { ...defaultUserPreferences, ...(user?.preferences || {}) }; if (!user || preferences.profileVisibility !== "public" || !preferences.avatarUrl?.startsWith("/api/media/")) return res.status(404).end(); const key = decodeURIComponent(preferences.avatarUrl.slice("/api/media/".length)); if (!key.startsWith(`${user.id}/`)) return res.status(403).end(); const stat = await mediaStore.statObject(mediaBucket, key); res.setHeader("Content-Type", stat.metaData["content-type"] || "image/jpeg"); res.setHeader("Cache-Control", "public, max-age=3600"); (await mediaStore.getObject(mediaBucket, key)).pipe(res); } catch (error) { if (error.code === "NoSuchKey" || error.code === "NotFound") return res.status(404).end(); next(error); } }); app.get("/api/admin", auth, admin, async (_req, res, next) => { try { const users = await pool.query( `SELECT u.id,u.username,u.email,u.display_name,u.bio,u.is_admin,u.disabled_at,u.email_verified_at,u.created_at,COALESCE(u.storage_quota,$1)::bigint AS storage_quota,(SELECT COALESCE(sum(CASE WHEN item->>'size' ~ '^[0-9]+$' THEN (item->>'size')::bigint ELSE 0 END),0)::bigint FROM diary_entries se CROSS JOIN LATERAL jsonb_array_elements(COALESCE(se.media,'[]'::jsonb)) item WHERE se.user_id=u.id AND se.deleted_at IS NULL) AS storage_used,count(DISTINCT e.id) FILTER (WHERE e.deleted_at IS NULL) AS entry_count,max(h.created_at) FILTER (WHERE h.success) AS last_login FROM diary_users u LEFT JOIN diary_entries e ON e.user_id=u.id LEFT JOIN diary_login_history h ON h.user_id=u.id GROUP BY u.id ORDER BY u.created_at`, [storageQuota], ), settings = await pool.query( "SELECT registration_mode,site_name,site_tagline,site_notice,footer_text,alert_memory_percent,alert_db_connections_percent,alert_failed_logins,alert_backup_hours,alert_storage_percent,maintenance_enabled,maintenance_starts_at,maintenance_ends_at,maintenance_message FROM diary_site_settings WHERE id=true", ), totals = await pool.query( "SELECT (SELECT count(*) FROM diary_users)::int AS users,(SELECT count(*) FROM diary_users WHERE disabled_at IS NULL)::int AS active_users,(SELECT count(*) FROM diary_entries WHERE deleted_at IS NULL)::int AS entries,(SELECT count(*) FROM diary_entries WHERE deleted_at IS NOT NULL)::int AS trashed_entries,(SELECT count(*) FROM diary_journals)::int AS journals,(SELECT count(*) FROM diary_comments)::int AS comments,(SELECT count(*) FROM diary_sessions WHERE expires_at>NOW())::int AS active_sessions,(SELECT count(*) FROM diary_login_history WHERE NOT success AND created_at>NOW()-INTERVAL '24 hours')::int AS failed_logins_24h", ), invitations = await pool.query( "SELECT i.id,i.max_uses,i.use_count,i.expires_at,i.created_at,u.display_name AS created_by_name FROM diary_registration_invites i LEFT JOIN diary_users u ON u.id=i.created_by ORDER BY i.created_at DESC LIMIT 100", ), loginActivity = await pool.query( "SELECT h.id,h.identifier,h.success,h.method,h.ip_address,h.created_at,u.display_name,u.username FROM diary_login_history h LEFT JOIN diary_users u ON u.id=h.user_id ORDER BY h.created_at DESC LIMIT 50", ), media = await pool.query( `SELECT count(*)::int AS files,COALESCE(sum(CASE WHEN item->>'size' ~ '^[0-9]+$' THEN (item->>'size')::bigint ELSE 0 END),0)::bigint AS bytes FROM diary_entries e CROSS JOIN LATERAL jsonb_array_elements(COALESCE(e.media,'[]'::jsonb)) item WHERE e.deleted_at IS NULL`, ), system = await pool.query( "SELECT current_database() AS database,current_setting('server_version') AS database_version,NOW() AS server_time,pg_database_size(current_database())::bigint AS database_bytes,(SELECT count(*)::int FROM pg_stat_activity WHERE datname=current_database()) AS connections,current_setting('max_connections')::int AS max_connections", ), tableSizes = await pool.query( `SELECT relname AS name,pg_total_relation_size(relid)::bigint AS bytes,n_live_tup::bigint AS rows FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 12`, ), dailyActivity = await pool.query( `WITH days AS (SELECT generate_series(CURRENT_DATE-29,CURRENT_DATE,INTERVAL '1 day')::date AS day) SELECT day::text AS date,(SELECT count(*) FROM diary_entries WHERE created_at>=day AND created_at=day AND created_at=day AND created_at=day AND created_at=day AND created_at []), latestCompleteBackup = backupItems.find((item) => item.complete), backupAgeHours = latestCompleteBackup ? (Date.now() - new Date(latestCompleteBackup.createdAt).getTime()) / 3600000 : Infinity, alerts = []; if (hostMemoryPercent >= alertSettings.alert_memory_percent) alerts.push({ id: "memory", severity: "warning", title: "High host memory usage", detail: `${hostMemoryPercent.toFixed(1)}% used; threshold ${alertSettings.alert_memory_percent}%`, }); if (connectionPercent >= alertSettings.alert_db_connections_percent) alerts.push({ id: "database-connections", severity: "warning", title: "High database connection usage", detail: `${connectionPercent.toFixed(1)}% used; threshold ${alertSettings.alert_db_connections_percent}%`, }); if (+totals.rows[0].failed_logins_24h >= alertSettings.alert_failed_logins) alerts.push({ id: "failed-logins", severity: "critical", title: "Elevated failed sign-ins", detail: `${totals.rows[0].failed_logins_24h} failures in 24 hours; threshold ${alertSettings.alert_failed_logins}`, }); if (backupAgeHours >= alertSettings.alert_backup_hours) alerts.push({ id: "backup-age", severity: "critical", title: latestCompleteBackup ? "Backup is overdue" : "No complete backup found", detail: latestCompleteBackup ? `${backupAgeHours.toFixed(1)} hours since the latest complete backup` : "Create a complete database and media backup", }); for (const item of users.rows) { const percent = (+item.storage_used / Math.max(1, +item.storage_quota || storageQuota)) * 100; if (percent >= alertSettings.alert_storage_percent) alerts.push({ id: `storage-${item.id}`, severity: percent >= 100 ? "critical" : "warning", title: `${item.display_name} storage ${percent >= 100 ? "exceeded" : "nearly full"}`, detail: `${percent.toFixed(1)}% of the account quota is used`, userId: +item.id, }); } for (const item of backupItems.filter((backup) => !backup.complete).slice(0, 5)) alerts.push({ id: `backup-incomplete-${item.stamp}`, severity: "warning", title: "Incomplete backup pair", detail: `${item.stamp} is missing ${item.database ? "media" : "database"}`, }); res.json({ settings: settings.rows[0], totals: totals.rows[0], users: users.rows.map((user) => ({ ...user, id: +user.id, entry_count: +user.entry_count, storage_quota: +user.storage_quota, storage_used: +user.storage_used, })), invitations: invitations.rows, loginActivity: loginActivity.rows, media: { files: +media.rows[0].files, bytes: +media.rows[0].bytes }, analytics: { daily: dailyActivity.rows, visibility: visibility.rows, contributors: contributors.rows, }, adminAudit: adminAudit.rows, alerts, system: { ...system.rows[0], database_bytes: +system.rows[0].database_bytes, tables: tableSizes.rows.map((table) => ({ ...table, bytes: +table.bytes, rows: +table.rows, })), host: { hostname: os.hostname(), platform: `${os.platform()} ${os.release()} (${os.arch()})`, cpu_model: os.cpus()[0]?.model || "Unknown", cpu_cores: os.cpus().length, load: os.loadavg(), total_memory: os.totalmem(), free_memory: os.freemem(), system_uptime: os.uptime(), process_uptime: process.uptime(), process_memory: process.memoryUsage().rss, node_version: process.version, }, }, }); } catch (error) { next(error); } }); app.put("/api/admin/settings", auth, admin, async (req, res, next) => { try { const mode = ["open", "invite", "closed"].includes( req.body.registrationMode, ) ? req.body.registrationMode : null; if (!mode) return res.status(400).json({ error: "Invalid registration mode" }); await pool.query( "UPDATE diary_site_settings SET registration_mode=$1,updated_at=NOW() WHERE id=true", [mode], ); await auditAdmin(req, "registration.mode_changed", "site", "settings", { mode, }); res.json({ registrationMode: mode }); } catch (error) { next(error); } }); app.put("/api/admin/site-settings", auth, admin, async (req, res, next) => { try { const siteName = String(req.body.siteName || "").trim().slice(0, 80), siteTagline = String(req.body.siteTagline || "").trim().slice(0, 240), siteNotice = String(req.body.siteNotice || "").trim().slice(0, 160), footerText = String(req.body.footerText || "").trim().slice(0, 160); if (!siteName || !siteTagline) return res .status(400) .json({ error: "Site name and description are required" }); const { rows } = await pool.query( `UPDATE diary_site_settings SET site_name=$1,site_tagline=$2,site_notice=$3,footer_text=$4,updated_at=NOW() WHERE id=true RETURNING site_name,site_tagline,site_notice,footer_text`, [siteName, siteTagline, siteNotice, footerText], ); await auditAdmin(req, "website.settings_updated", "site", "settings", { siteName, }); res.json(rows[0]); } catch (error) { next(error); } }); app.put("/api/admin/alert-settings", auth, admin, async (req, res, next) => { try { const values = { memory: Math.round(Number(req.body.memory)), connections: Math.round(Number(req.body.connections)), failedLogins: Math.round(Number(req.body.failedLogins)), backupHours: Math.round(Number(req.body.backupHours)), storage: Math.round(Number(req.body.storage)), }; if ( !Number.isFinite(values.memory) || values.memory < 10 || values.memory > 100 || !Number.isFinite(values.connections) || values.connections < 10 || values.connections > 100 || !Number.isFinite(values.failedLogins) || values.failedLogins < 1 || values.failedLogins > 10000 || !Number.isFinite(values.backupHours) || values.backupHours < 1 || values.backupHours > 720 || !Number.isFinite(values.storage) || values.storage < 10 || values.storage > 100 ) return res.status(400).json({ error: "Invalid alert thresholds" }); await pool.query( `UPDATE diary_site_settings SET alert_memory_percent=$1,alert_db_connections_percent=$2,alert_failed_logins=$3,alert_backup_hours=$4,alert_storage_percent=$5,updated_at=NOW() WHERE id=true`, [ values.memory, values.connections, values.failedLogins, values.backupHours, values.storage, ], ); await auditAdmin(req, "alerts.thresholds_updated", "site", "settings", values); res.json(values); } catch (error) { next(error); } }); app.put("/api/admin/maintenance", auth, admin, async (req, res, next) => { try { const enabled = !!req.body.enabled, startsAt = req.body.startsAt ? new Date(req.body.startsAt) : null, endsAt = req.body.endsAt ? new Date(req.body.endsAt) : null, message = String(req.body.message || "").trim().slice(0, 500); if ( (startsAt && Number.isNaN(startsAt.getTime())) || (endsAt && Number.isNaN(endsAt.getTime())) || (startsAt && endsAt && endsAt <= startsAt) || !message ) return res.status(400).json({ error: "Use a valid maintenance schedule and message", }); const { rows } = await pool.query( `UPDATE diary_site_settings SET maintenance_enabled=$1,maintenance_starts_at=$2,maintenance_ends_at=$3,maintenance_message=$4,updated_at=NOW() WHERE id=true RETURNING maintenance_enabled,maintenance_starts_at,maintenance_ends_at,maintenance_message`, [enabled, startsAt, endsAt, message], ); maintenanceCache = { expires: 0, value: null }; await auditAdmin(req, "maintenance.schedule_updated", "site", "maintenance", { enabled, startsAt, endsAt, }); res.json(rows[0]); } catch (error) { next(error); } }); app.post("/api/admin/users", auth, admin, async (req, res, next) => { const client = await pool.connect(); try { const username = String(req.body.username || "").trim(), email = String(req.body.email || "").trim(), displayName = String(req.body.displayName || "").trim(), pass = String(req.body.password || ""), quotaGb = Number(req.body.storageQuotaGb || 0), quotaBytes = quotaGb > 0 ? Math.round(quotaGb * 1024 ** 3) : null; if ( !/^[a-z0-9_-]{3,30}$/i.test(username) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || pass.length < 10 || (req.body.storageQuotaGb && (!Number.isFinite(quotaGb) || quotaGb < 0.1 || quotaGb > 1024)) ) return res.status(400).json({ error: "Use a valid username, email, and password of at least 10 characters", }); await client.query("BEGIN"); const { rows } = await client.query( `INSERT INTO diary_users(username,email,password_hash,display_name,bio,is_admin,email_verified_at,disabled_at,storage_quota) VALUES(lower($1),lower($2),$3,$4,$5,$6,CASE WHEN $7 THEN NOW() ELSE NULL END,CASE WHEN $8 THEN NOW() ELSE NULL END,$9) RETURNING id,username,email,display_name`, [ username, email, await password(pass), displayName || username, String(req.body.bio || "").slice(0, 2000), !!req.body.isAdmin, !!req.body.verified, !!req.body.disabled, quotaBytes, ], ); await client.query( "INSERT INTO diary_journals(owner_id,name,is_default) VALUES($1,'Field Notes',true)", [rows[0].id], ); await auditAdmin( req, "user.created", "user", rows[0].id, { username: rows[0].username, email: rows[0].email }, client, ); await client.query("COMMIT"); res.status(201).json(rows[0]); } catch (error) { await client.query("ROLLBACK").catch(() => {}); if (error.code === "23505") return res.status(409).json({ error: "Username or email already exists" }); next(error); } finally { client.release(); } }); app.put("/api/admin/users/:id/details", auth, admin, async (req, res, next) => { const client = await pool.connect(); try { const targetId = +req.params.id, username = String(req.body.username || "").trim(), email = String(req.body.email || "").trim(), displayName = String(req.body.displayName || "").trim(), pass = String(req.body.password || ""), quotaGb = Number(req.body.storageQuotaGb || 0), quotaBytes = quotaGb > 0 ? Math.round(quotaGb * 1024 ** 3) : storageQuota; if ( !/^[a-z0-9_-]{3,30}$/i.test(username) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || !displayName || (pass && pass.length < 10) || !Number.isFinite(quotaGb) || quotaGb < 0.1 || quotaGb > 1024 ) return res.status(400).json({ error: "Invalid account details" }); if ( targetId === +req.user.id && (req.body.disabled || req.body.isAdmin === false) ) return res.status(400).json({ error: "You cannot disable or demote your own active account", }); await client.query("BEGIN"); const current = await client.query( "SELECT is_admin FROM diary_users WHERE id=$1 FOR UPDATE", [targetId], ); if (!current.rowCount) { await client.query("ROLLBACK"); return res.status(404).json({ error: "User not found" }); } if (current.rows[0].is_admin && req.body.isAdmin === false) { const admins = await client.query( "SELECT count(*)::int AS count FROM diary_users WHERE is_admin AND disabled_at IS NULL", ); if (admins.rows[0].count <= 1) { await client.query("ROLLBACK"); return res.status(409).json({ error: "At least one active administrator is required", }); } } await client.query( `UPDATE diary_users SET username=lower($1),email=lower($2),display_name=$3,bio=$4,is_admin=$5,email_verified_at=CASE WHEN $6 THEN COALESCE(email_verified_at,NOW()) ELSE NULL END,disabled_at=CASE WHEN $7 THEN COALESCE(disabled_at,NOW()) ELSE NULL END,password_hash=CASE WHEN $8='' THEN password_hash ELSE $9 END,storage_quota=$10 WHERE id=$11`, [ username, email, displayName, String(req.body.bio || "").slice(0, 2000), !!req.body.isAdmin, !!req.body.verified, !!req.body.disabled, pass, pass ? await password(pass) : "", quotaBytes, targetId, ], ); if (req.body.disabled || pass) await client.query("DELETE FROM diary_sessions WHERE user_id=$1", [ targetId, ]); await auditAdmin( req, "user.details_updated", "user", targetId, { username, email, isAdmin: !!req.body.isAdmin, verified: !!req.body.verified, disabled: !!req.body.disabled, passwordReset: !!pass, storageQuota: quotaBytes, }, client, ); await client.query("COMMIT"); res.json({ updated: true }); } catch (error) { await client.query("ROLLBACK").catch(() => {}); if (error.code === "23505") return res.status(409).json({ error: "Username or email already exists" }); next(error); } finally { client.release(); } }); app.delete("/api/admin/users/:id", auth, admin, async (req, res, next) => { const client = await pool.connect(); try { const targetId = +req.params.id; if (targetId === +req.user.id) return res.status(400).json({ error: "You cannot delete yourself" }); await client.query("BEGIN"); const current = await client.query( "SELECT is_admin FROM diary_users WHERE id=$1 FOR UPDATE", [targetId], ); if (!current.rowCount) { await client.query("ROLLBACK"); return res.status(404).json({ error: "User not found" }); } if (current.rows[0].is_admin) { const admins = await client.query( "SELECT count(*)::int AS count FROM diary_users WHERE is_admin AND disabled_at IS NULL", ); if (admins.rows[0].count <= 1) { await client.query("ROLLBACK"); return res.status(409).json({ error: "The last administrator cannot be deleted" }); } } await client.query( "DELETE FROM diary_entries WHERE user_id=$1 OR journal_id IN (SELECT id FROM diary_journals WHERE owner_id=$1)", [targetId], ); await auditAdmin(req, "user.deleted", "user", targetId, {}, client); await client.query("DELETE FROM diary_users WHERE id=$1", [targetId]); await client.query("COMMIT"); const keys = []; for await (const object of mediaStore.listObjectsV2( mediaBucket, `${targetId}/`, true, )) keys.push(object.name); if (keys.length) await mediaStore.removeObjects(mediaBucket, keys); res.json({ deleted: true }); } catch (error) { await client.query("ROLLBACK").catch(() => {}); next(error); } finally { client.release(); } }); app.post( "/api/admin/registration-invites", auth, admin, async (req, res, next) => { try { const code = base32Encode(crypto.randomBytes(10)), days = Math.min(90, Math.max(1, +(req.body.days || 7))), uses = Math.min(100, Math.max(1, +(req.body.maxUses || 1))), { rows } = await pool.query( "INSERT INTO diary_registration_invites(code_hash,created_by,max_uses,expires_at) VALUES($1,$2,$3,NOW()+($4||' days')::interval) RETURNING id,max_uses,expires_at", [hash(code), req.user.id, uses, days], ); await auditAdmin( req, "registration.code_created", "registration_invite", rows[0].id, { maxUses: uses, days }, ); res.status(201).json({ ...rows[0], code }); } catch (error) { next(error); } }, ); app.delete( "/api/admin/registration-invites/:id", auth, admin, async (req, res, next) => { try { const { rowCount } = await pool.query( "DELETE FROM diary_registration_invites WHERE id=$1", [+req.params.id], ); if (!rowCount) return res.status(404).json({ error: "Registration code not found" }); await auditAdmin( req, "registration.code_revoked", "registration_invite", req.params.id, ); res.json({ deleted: true }); } catch (error) { next(error); } }, ); app.delete( "/api/admin/users/:id/sessions", auth, admin, async (req, res, next) => { try { const targetId = +req.params.id; if (!targetId) return res.status(400).json({ error: "Invalid user" }); const { rowCount } = await pool.query( "DELETE FROM diary_sessions WHERE user_id=$1", [targetId], ); await auditAdmin(req, "user.sessions_revoked", "user", targetId, { sessions: rowCount, }); res.json({ revoked: rowCount }); } catch (error) { next(error); } }, ); app.put("/api/admin/users/:id", auth, admin, async (req, res, next) => { const client = await pool.connect(); try { const targetId = +req.params.id; if ( targetId === +req.user.id && (req.body.disabled || req.body.isAdmin === false) ) return res.status(400).json({ error: "You cannot disable or demote your own active account", }); await client.query("BEGIN"); const current = await client.query( "SELECT * FROM diary_users WHERE id=$1 FOR UPDATE", [targetId], ); if (!current.rows[0]) { await client.query("ROLLBACK"); return res.status(404).json({ error: "User not found" }); } if (current.rows[0].is_admin && req.body.isAdmin === false) { const admins = await client.query( "SELECT count(*)::int AS count FROM diary_users WHERE is_admin AND disabled_at IS NULL", ); if (admins.rows[0].count <= 1) { await client.query("ROLLBACK"); return res .status(409) .json({ error: "At least one active administrator is required" }); } } const { rows } = await client.query( "UPDATE diary_users SET is_admin=COALESCE($1,is_admin),disabled_at=CASE WHEN $2::boolean THEN COALESCE(disabled_at,NOW()) ELSE NULL END WHERE id=$3 RETURNING id,is_admin,disabled_at", [ typeof req.body.isAdmin === "boolean" ? req.body.isAdmin : null, !!req.body.disabled, targetId, ], ); if (req.body.disabled) await client.query("DELETE FROM diary_sessions WHERE user_id=$1", [ targetId, ]); await auditAdmin( req, "user.access_updated", "user", targetId, { isAdmin: typeof req.body.isAdmin === "boolean" ? req.body.isAdmin : undefined, disabled: !!req.body.disabled, }, client, ); await client.query("COMMIT"); res.json(rows[0]); } catch (error) { await client.query("ROLLBACK"); next(error); } finally { client.release(); } }); app.get("/api/journals", auth, async (req, res, next) => { try { const { rows } = await pool.query( `SELECT j.*,CASE WHEN j.owner_id=$1 THEN 'owner' ELSE m.role END AS role,count(e.id) FILTER (WHERE e.deleted_at IS NULL) AS entry_count FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 LEFT JOIN diary_entries e ON e.journal_id=j.id WHERE j.owner_id=$1 OR m.user_id=$1 GROUP BY j.id,m.role ORDER BY (j.owner_id=$1) DESC,j.is_default DESC,j.sort_order,j.id`, [req.user.id], ); res.json(rows.map(journal)); } catch (error) { next(error); } }); app.post("/api/journals", auth, async (req, res, next) => { try { const name = String(req.body.name || "") .trim() .slice(0, 80), color = /^#[0-9a-f]{6}$/i.test(req.body.color) ? req.body.color : "#7aa2f7"; if (!name) return res.status(400).json({ error: "Journal name required" }); const { rows } = await pool.query( `INSERT INTO diary_journals(owner_id,name,description,color,visibility,sort_order) VALUES($1,$2,$3,$4,$5,COALESCE((SELECT max(sort_order)+1 FROM diary_journals WHERE owner_id=$1),0)) RETURNING *`, [ req.user.id, name, String(req.body.description || "").slice(0, 500), color, req.body.visibility === "public" ? "public" : "private", ], ); res.status(201).json(journal(rows[0])); } catch (error) { if (error.code === "23505") return res .status(409) .json({ error: "A journal with that name already exists" }); next(error); } }); app.put("/api/journals/:id", auth, async (req, res, next) => { const client = await pool.connect(); try { const name = String(req.body.name || "") .trim() .slice(0, 80), color = /^#[0-9a-f]{6}$/i.test(req.body.color) ? req.body.color : "#7aa2f7"; if (!name) return res.status(400).json({ error: "Journal name required" }); await client.query("BEGIN"); if (req.body.isDefault) await client.query( "UPDATE diary_journals SET is_default=false WHERE owner_id=$1", [req.user.id], ); const { rows } = await client.query( `UPDATE diary_journals SET name=$1,description=$2,color=$3,visibility=$4,sort_order=$5,is_default=CASE WHEN $6 THEN true ELSE is_default END,updated_at=NOW() WHERE id=$7 AND owner_id=$8 RETURNING *`, [ name, String(req.body.description || "").slice(0, 500), color, req.body.visibility === "public" ? "public" : "private", Number.isInteger(req.body.sortOrder) ? req.body.sortOrder : 0, !!req.body.isDefault, req.params.id, req.user.id, ], ); if (!rows[0]) { await client.query("ROLLBACK"); return res.status(404).json({ error: "Journal not found" }); } await client.query("COMMIT"); res.json(journal(rows[0])); } catch (error) { await client.query("ROLLBACK"); if (error.code === "23505") return res .status(409) .json({ error: "A journal with that name already exists" }); next(error); } finally { client.release(); } }); app.delete("/api/journals/:id", auth, async (req, res, next) => { try { const result = await pool.query( `DELETE FROM diary_journals j WHERE j.id=$1 AND j.owner_id=$2 AND NOT j.is_default AND NOT EXISTS(SELECT 1 FROM diary_entries e WHERE e.journal_id=j.id)`, [req.params.id, req.user.id], ); if (!result.rowCount) return res .status(409) .json({ error: "Only empty, non-default journals can be deleted" }); res.status(204).end(); } catch (error) { next(error); } }); app.get("/api/journals/:id/members", auth, async (req, res, next) => { try { const owned = await pool.query( "SELECT 1 FROM diary_journals WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.id], ); if (!owned.rowCount) return res.status(403).json({ error: "Journal owner required" }); const members = await pool.query( `SELECT u.id,u.username,u.email,u.display_name,m.role,m.created_at FROM diary_journal_members m JOIN diary_users u ON u.id=m.user_id WHERE m.journal_id=$1 ORDER BY m.created_at`, [req.params.id], ); const invitations = await pool.query( `SELECT id,email,role,expires_at,accepted_at,created_at FROM diary_journal_invitations WHERE journal_id=$1 AND accepted_at IS NULL AND expires_at>NOW() ORDER BY created_at DESC`, [req.params.id], ); res.json({ members: members.rows, invitations: invitations.rows }); } catch (error) { next(error); } }); app.post("/api/journals/:id/invitations", auth, async (req, res, next) => { try { const roles = ["editor", "contributor", "viewer"], role = roles.includes(req.body.role) ? req.body.role : "viewer", email = String(req.body.email || "") .trim() .toLowerCase() .slice(0, 254), days = Math.min(30, Math.max(1, +(req.body.days || 7))), token = crypto.randomBytes(32).toString("base64url"); const { rows } = await pool.query( `INSERT INTO diary_journal_invitations(journal_id,invited_by,email,role,token_hash,expires_at) SELECT id,$2,$3,$4,$5,NOW()+($6||' days')::interval FROM diary_journals WHERE id=$1 AND owner_id=$2 RETURNING id,email,role,expires_at`, [req.params.id, req.user.id, email, role, hash(token), days], ); if (!rows[0]) return res.status(403).json({ error: "Journal owner required" }); res.status(201).json({ ...rows[0], token, acceptUrl: `/invite/${token}` }); } catch (error) { next(error); } }); app.post("/api/invitations/:token/accept", auth, async (req, res, next) => { const client = await pool.connect(); try { await client.query("BEGIN"); const { rows } = await client.query( `SELECT i.*,j.owner_id,j.name AS journal_name FROM diary_journal_invitations i JOIN diary_journals j ON j.id=i.journal_id WHERE i.token_hash=$1 AND i.accepted_at IS NULL AND i.expires_at>NOW() FOR UPDATE`, [hash(req.params.token)], ); const invitation = rows[0]; if (!invitation) { await client.query("ROLLBACK"); return res .status(404) .json({ error: "Invitation is invalid or expired" }); } if (invitation.email && invitation.email !== req.user.email.toLowerCase()) { await client.query("ROLLBACK"); return res .status(403) .json({ error: "Invitation belongs to another email address" }); } if (+invitation.owner_id === +req.user.id) { await client.query("ROLLBACK"); return res.status(400).json({ error: "You already own this journal" }); } await client.query( `INSERT INTO diary_journal_members(journal_id,user_id,role) VALUES($1,$2,$3) ON CONFLICT(journal_id,user_id) DO UPDATE SET role=EXCLUDED.role`, [invitation.journal_id, req.user.id, invitation.role], ); await client.query( "UPDATE diary_journal_invitations SET accepted_at=NOW() WHERE id=$1", [invitation.id], ); await client.query("COMMIT"); res.json({ journalId: +invitation.journal_id, journalName: invitation.journal_name, role: invitation.role, }); } catch (error) { await client.query("ROLLBACK"); next(error); } finally { client.release(); } }); app.put( "/api/journals/:journalId/members/:userId", auth, async (req, res, next) => { try { const role = ["editor", "contributor", "viewer"].includes(req.body.role) ? req.body.role : null; if (!role) return res.status(400).json({ error: "Invalid role" }); const { rows } = await pool.query( `UPDATE diary_journal_members m SET role=$1 FROM diary_journals j WHERE m.journal_id=$2 AND m.user_id=$3 AND j.id=m.journal_id AND j.owner_id=$4 RETURNING m.*`, [role, req.params.journalId, req.params.userId, req.user.id], ); if (!rows[0]) return res.status(404).json({ error: "Member not found" }); res.json(rows[0]); } catch (error) { next(error); } }, ); app.delete( "/api/journals/:journalId/members/:userId", auth, async (req, res, next) => { try { const result = await pool.query( `DELETE FROM diary_journal_members m USING diary_journals j WHERE m.journal_id=$1 AND m.user_id=$2 AND j.id=m.journal_id AND j.owner_id=$3`, [req.params.journalId, req.params.userId, req.user.id], ); if (!result.rowCount) return res.status(404).json({ error: "Member not found" }); res.status(204).end(); } catch (error) { next(error); } }, ); app.post( "/api/media", auth, upload.array("media", 12), async (req, res, next) => { try { const files = req.files || [], used = await storageUsed(req.user.id), incoming = files.reduce((total, file) => total + file.size, 0), captions = (() => { try { return JSON.parse(req.body.captions || "[]"); } catch { return []; } })(), metadata = (() => { try { return JSON.parse(req.body.metadata || "[]"); } catch { return []; } })(); const quota = await userStorageQuota(req.user.id); if (used + incoming > quota) return res .status(413) .json({ error: "Storage quota exceeded", used, quota }); const saved = []; for (const [index, file] of files.entries()) { const key = `${req.user.id}/${crypto.randomUUID()}`; await mediaStore.putObject(mediaBucket, key, file.buffer, file.size, { "Content-Type": file.mimetype, "X-Amz-Meta-Original-Name": encodeURIComponent(file.originalname), }); const item = { key, url: `/api/media/${encodeURIComponent(key)}`, name: file.originalname, type: file.mimetype, size: file.size, caption: String(captions[index] || "").slice(0, 500), }; if (file.mimetype.startsWith("image/")) { const supplied = metadata[index] && typeof metadata[index] === "object" ? metadata[index] : {}, textValue = (name, limit) => String(supplied[name] || "").slice(0, limit), numberValue = (name) => supplied[name] !== null && supplied[name] !== "" && Number.isFinite(Number(supplied[name])) ? Number(supplied[name]) : null; item.metadata = { make: textValue("make", 100), model: textValue("model", 100), lens: textValue("lens", 140), aperture: numberValue("aperture"), exposure: numberValue("exposure"), iso: numberValue("iso"), focalLength: numberValue("focalLength"), width: numberValue("width"), height: numberValue("height"), capturedAt: /^\d{4}-\d{2}-\d{2}T/.test(String(supplied.capturedAt || "")) ? String(supplied.capturedAt).slice(0, 40) : null, }; try { const thumbnail = await sharp(file.buffer) .rotate() .resize({ width: 640, height: 640, fit: "inside", withoutEnlargement: true, }) .webp({ quality: 78 }) .toBuffer(); item.thumbKey = `${key}.thumb.webp`; item.thumbUrl = `/api/media/${encodeURIComponent(item.thumbKey)}`; await mediaStore.putObject( mediaBucket, item.thumbKey, thumbnail, thumbnail.length, { "Content-Type": "image/webp", }, ); } catch { // Preserve the original even when its format cannot be thumbnailed. } } saved.push(item); } res.status(201).json(saved); } catch (e) { next(e); } }, ); app.get("/api/storage", auth, async (req, res, next) => { try { res.json({ used: await storageUsed(req.user.id), quota: await userStorageQuota(req.user.id), }); } catch (e) { next(e); } }); app.get("/api/storage/details", auth, async (req, res, next) => { try { const { rows } = await pool.query( `SELECT e.id AS entry_id,e.title,item FROM diary_entries e CROSS JOIN LATERAL jsonb_array_elements(COALESCE(e.media,'[]'::jsonb)) item WHERE e.user_id=$1 AND e.deleted_at IS NULL`, [req.user.id], ); const files = []; for (const row of rows) { const item = row.item || {}, key = item.key || ""; let checksum = ""; if (key) try { checksum = (await mediaStore.statObject(mediaBucket, key)).etag || ""; } catch {} const type = String(item.type || "application/octet-stream"), category = type.startsWith("image/") ? "images" : type.startsWith("video/") ? "videos" : type.startsWith("audio/") ? "audio" : "documents"; files.push({ entryId: +row.entry_id, entryTitle: row.title, key, thumbKey: item.thumbKey || "", name: item.name || "attachment", type, category, size: Number(item.size || 0), checksum, url: item.url || "" }); } const categories = { images: 0, videos: 0, audio: 0, documents: 0 }; for (const file of files) categories[file.category] += file.size; const grouped = new Map(); for (const file of files) { if (!file.checksum) continue; const duplicateKey = `${file.checksum}:${file.size}`, group = grouped.get(duplicateKey) || []; group.push(file); grouped.set(duplicateKey, group); } res.json({ used: await storageUsed(req.user.id), quota: await userStorageQuota(req.user.id), categories, files: files.sort((a, b) => b.size - a.size), duplicates: [...grouped.values()].filter((group) => group.length > 1) }); } catch (error) { next(error); } }); app.delete("/api/storage/files", auth, async (req, res, next) => { const client = await pool.connect(); try { const key = String(req.body.key || ""); if (!key.startsWith(`${req.user.id}/`)) return res.status(403).json({ error: "File ownership required" }); await client.query("BEGIN"); const { rows } = await client.query("SELECT id,media FROM diary_entries WHERE user_id=$1 AND media @> $2::jsonb FOR UPDATE", [req.user.id, JSON.stringify([{ key }])]); let thumbKey = ""; for (const row of rows) { const matched = (row.media || []).find((item) => item.key === key); thumbKey ||= matched?.thumbKey || ""; await client.query("UPDATE diary_entries SET media=$1,updated_at=NOW() WHERE id=$2", [JSON.stringify((row.media || []).filter((item) => item.key !== key)), row.id]); } await client.query("COMMIT"); try { await mediaStore.removeObject(mediaBucket, key); } catch {} if (thumbKey) try { await mediaStore.removeObject(mediaBucket, thumbKey); } catch {} res.json({ removedFromEntries: rows.length }); } catch (error) { await client.query("ROLLBACK"); next(error); } finally { client.release(); } }); app.get("/api/tags", auth, async (req, res, next) => { try { const { rows } = await pool.query( `SELECT t.tag,count(*)::int AS count FROM diary_entries e CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(e.tags,'[]'::jsonb)) AS t(tag) WHERE e.user_id=$1 AND e.deleted_at IS NULL GROUP BY t.tag ORDER BY count DESC,t.tag`, [req.user.id], ); res.json(rows); } catch (error) { next(error); } }); app.put("/api/tags/:tag", auth, async (req, res, next) => { try { const oldTag = String(req.params.tag).trim(), newTag = String(req.body.name || "").trim().replace(/^#/, "").slice(0, 80); if (!newTag) return res.status(400).json({ error: "Tag name required" }); const result = await pool.query( `UPDATE diary_entries e SET tags=(SELECT jsonb_agg(DISTINCT CASE WHEN value=$1 THEN $2 ELSE value END) FROM jsonb_array_elements_text(e.tags) value),updated_at=NOW() WHERE e.user_id=$3 AND e.tags ? $1`, [oldTag, newTag, req.user.id], ); res.json({ updated: result.rowCount }); } catch (error) { next(error); } }); app.delete("/api/tags/:tag", auth, async (req, res, next) => { try { const tag = String(req.params.tag).trim(), result = await pool.query( `UPDATE diary_entries e SET tags=COALESCE((SELECT jsonb_agg(value) FROM jsonb_array_elements_text(e.tags) value WHERE value<>$1),'[]'::jsonb),updated_at=NOW() WHERE e.user_id=$2 AND e.tags ? $1`, [tag, req.user.id], ); res.json({ updated: result.rowCount }); } catch (error) { next(error); } }); app.get("/api/media/*key", auth, async (req, res, next) => { try { const key = Array.isArray(req.params.key) ? req.params.key.join("/") : req.params.key; if (!key.startsWith(`${req.user.id}/`)) { const allowed = await pool.query( `SELECT 1 FROM diary_entries e JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members jm ON jm.journal_id=j.id AND jm.user_id=$2 CROSS JOIN LATERAL jsonb_array_elements(e.media) m WHERE (e.visibility='public' OR j.owner_id=$2 OR jm.user_id=$2) AND (m->>'key'=$1 OR m->>'thumbKey'=$1) LIMIT 1`, [key, req.user.id], ); if (!allowed.rowCount) return res.status(403).json({ error: "Media access denied" }); } const stat = await mediaStore.statObject(mediaBucket, key); res.setHeader( "Content-Type", stat.metaData["content-type"] || "application/octet-stream", ); res.setHeader("Cache-Control", "private, max-age=86400"); (await mediaStore.getObject(mediaBucket, key)).pipe(res); } catch (e) { if (e.code === "NoSuchKey" || e.code === "NotFound") return res.status(404).end(); next(e); } }); async function findPublicLink(token) { const { rows } = await pool.query( `SELECT l.*,e.*,l.id AS link_id,l.password_hash AS link_password_hash,l.expires_at AS link_expires_at,u.display_name FROM diary_public_links l JOIN diary_entries e ON e.id=l.entry_id JOIN diary_users u ON u.id=e.user_id WHERE l.token_hash=$1 AND l.revoked_at IS NULL AND l.expires_at>NOW() AND e.deleted_at IS NULL`, [hash(token)], ); return rows[0] || null; } async function hasPublicGrant(req, link) { if (!link.link_password_hash) return true; const grant = cookie(req, "field_log_share"); if (!grant) return false; const result = await pool.query( "SELECT 1 FROM diary_public_sessions WHERE token_hash=$1 AND link_id=$2 AND expires_at>NOW()", [hash(grant), link.link_id], ); return !!result.rowCount; } function sharedEntry(row, token) { const result = entry({ ...row, owner: false }); result.media = (result.media || []).map((item) => ({ ...item, url: item.key ? `/api/public/${encodeURIComponent(token)}/media/${encodeURIComponent(item.key)}` : item.url, thumbUrl: item.thumbKey ? `/api/public/${encodeURIComponent(token)}/media/${encodeURIComponent(item.thumbKey)}` : item.thumbUrl, })); return result; } app.get("/api/public/:token", async (req, res, next) => { try { const link = await findPublicLink(req.params.token); if (!link) return res .status(404) .json({ error: "Share link is invalid or expired" }); if (!(await hasPublicGrant(req, link))) return res.status(401).json({ passwordRequired: true }); await pool.query( "UPDATE diary_public_links SET view_count=view_count+1,last_viewed_at=NOW() WHERE id=$1", [link.link_id], ); res.json(sharedEntry(link, req.params.token)); } catch (error) { next(error); } }); app.post("/api/public/:token/access", async (req, res, next) => { try { const link = await findPublicLink(req.params.token); if (!link) return res .status(404) .json({ error: "Share link is invalid or expired" }); if ( link.link_password_hash && !(await verify(String(req.body.password || ""), link.link_password_hash)) ) return res.status(401).json({ error: "Incorrect password" }); const grant = crypto.randomBytes(32).toString("base64url"), expiry = new Date( Math.min( Date.now() + 86400000, new Date(link.link_expires_at).getTime(), ), ); await pool.query( "INSERT INTO diary_public_sessions(token_hash,link_id,expires_at) VALUES($1,$2,$3)", [hash(grant), link.link_id, expiry], ); res.setHeader( "Set-Cookie", `field_log_share=${grant}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.max(1, Math.floor((expiry.getTime() - Date.now()) / 1000))}${process.env.COOKIE_SECURE === "true" ? "; Secure" : ""}`, ); await pool.query( "UPDATE diary_public_links SET view_count=view_count+1,last_viewed_at=NOW() WHERE id=$1", [link.link_id], ); res.json(sharedEntry(link, req.params.token)); } catch (error) { next(error); } }); app.get("/api/public/:token/media/*key", async (req, res, next) => { try { const link = await findPublicLink(req.params.token); if (!link || !(await hasPublicGrant(req, link))) return res.status(403).end(); const key = Array.isArray(req.params.key) ? req.params.key.join("/") : req.params.key, allowed = (link.media || []).some( (item) => item.key === key || item.thumbKey === key, ); if (!allowed) return res.status(403).end(); const stat = await mediaStore.statObject(mediaBucket, key); res.setHeader( "Content-Type", stat.metaData["content-type"] || "application/octet-stream", ); res.setHeader("Cache-Control", "private, max-age=3600"); (await mediaStore.getObject(mediaBucket, key)).pipe(res); } catch (error) { if (error.code === "NoSuchKey" || error.code === "NotFound") return res.status(404).end(); next(error); } }); app.get("/api/entries/:id/public-links", auth, async (req, res, next) => { try { const access = await entryAccess(req.user.id, req.params.id); if (!access?.can_manage) return res.status(403).json({ error: "Entry management required" }); if (access.is_encrypted) return res .status(409) .json({ error: "Encrypted entries cannot have public links" }); const { rows } = await pool.query( "SELECT id,expires_at,revoked_at,view_count,created_at,last_viewed_at,token_ciphertext,(password_hash IS NOT NULL) AS password_protected FROM diary_public_links WHERE entry_id=$1 ORDER BY created_at DESC", [req.params.id], ); res.json(rows.map(({ token_ciphertext, ...row }) => { let url = null; if (token_ciphertext && !row.revoked_at && new Date(row.expires_at) > new Date()) { try { url = `/share/${decryptSecret(token_ciphertext)}`; } catch {} } return { ...row, url }; })); } catch (error) { next(error); } }); app.post("/api/entries/:id/public-links/automatic", auth, async (req, res, next) => { try { const access = await entryAccess(req.user.id, req.params.id), days = Math.min(365, Math.max(1, +(req.body.days || 7))); if (!access?.can_manage) return res.status(403).json({ error: "Entry management required" }); if (access.is_encrypted) return res.status(409).json({ error: "Encrypted entries cannot have public links" }); const existing = await pool.query( `SELECT id,expires_at,created_at,token_ciphertext FROM diary_public_links WHERE entry_id=$1 AND revoked_at IS NULL AND expires_at>NOW() AND token_ciphertext IS NOT NULL AND password_hash IS NULL ORDER BY created_at DESC LIMIT 1`, [req.params.id], ); if (existing.rows[0]) { try { const { token_ciphertext, ...link } = existing.rows[0]; return res.json({ ...link, url: `/share/${decryptSecret(token_ciphertext)}`, reused: true }); } catch {} } const token = crypto.randomBytes(32).toString("base64url"), created = await pool.query( `INSERT INTO diary_public_links(entry_id,created_by,token_hash,token_ciphertext,expires_at) VALUES($1,$2,$3,$4,NOW()+($5||' days')::interval) RETURNING id,expires_at,created_at`, [req.params.id, req.user.id, hash(token), encryptSecret(token), days], ); await activity(access.journal_id, req.user.id, "created_public_link", req.params.id, { expiresAt: created.rows[0].expires_at, passwordProtected: false, automatic: true, }); res.status(201).json({ ...created.rows[0], url: `/share/${token}`, reused: false }); } catch (error) { next(error); } }); app.get("/api/public-links", auth, async (req, res, next) => { try { const { rows } = await pool.query( `SELECT l.id,l.entry_id,l.expires_at,l.revoked_at,l.view_count,l.created_at,l.last_viewed_at,(l.password_hash IS NOT NULL) AS password_protected,e.title FROM diary_public_links l JOIN diary_entries e ON e.id=l.entry_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 WHERE e.user_id=$1 OR j.owner_id=$1 OR m.role='editor' ORDER BY l.created_at DESC`, [req.user.id], ); res.json(rows); } catch (error) { next(error); } }); app.get("/api/blocks", auth, async (req, res, next) => { try { const { rows } = await pool.query( "SELECT u.id,u.username,u.display_name,b.created_at FROM diary_user_blocks b JOIN diary_users u ON u.id=b.blocked_id WHERE b.blocker_id=$1 ORDER BY b.created_at DESC", [req.user.id], ); res.json(rows); } catch (error) { next(error); } }); app.post("/api/blocks", auth, async (req, res, next) => { try { const username = String(req.body.username || "").trim().toLowerCase(), found = await pool.query("SELECT id FROM diary_users WHERE username=$1 AND id<>$2", [username, req.user.id]); if (!found.rows[0]) return res.status(404).json({ error: "User not found" }); await pool.query("INSERT INTO diary_user_blocks(blocker_id,blocked_id) VALUES($1,$2) ON CONFLICT DO NOTHING", [req.user.id, found.rows[0].id]); res.status(201).json({ ok: true }); } catch (error) { next(error); } }); app.delete("/api/blocks/:id", auth, async (req, res, next) => { try { await pool.query("DELETE FROM diary_user_blocks WHERE blocker_id=$1 AND blocked_id=$2", [req.user.id, req.params.id]); res.status(204).end(); } catch (error) { next(error); } }); app.get("/api/comments/pending", auth, async (req, res, next) => { try { const { rows } = await pool.query( `SELECT c.id,c.body,c.created_at,e.id AS entry_id,e.title,u.display_name,u.username FROM diary_comments c JOIN diary_entries e ON e.id=c.entry_id JOIN diary_users u ON u.id=c.user_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 WHERE c.deleted_at IS NULL AND NOT c.approved AND (e.user_id=$1 OR j.owner_id=$1 OR m.role='editor') ORDER BY c.created_at DESC`, [req.user.id], ); res.json(rows); } catch (error) { next(error); } }); app.post("/api/comments/:id/approve", auth, async (req, res, next) => { try { const { rows } = await pool.query( `UPDATE diary_comments c SET approved=true FROM diary_entries e JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE c.id=$1 AND e.id=c.entry_id AND (e.user_id=$2 OR j.owner_id=$2 OR m.role='editor') RETURNING c.id`, [req.params.id, req.user.id], ); if (!rows[0]) return res.status(404).json({ error: "Pending comment not found" }); res.json(rows[0]); } catch (error) { next(error); } }); app.post("/api/entries/:id/public-links", auth, async (req, res, next) => { try { const access = await entryAccess(req.user.id, req.params.id), days = Math.min(365, Math.max(1, +(req.body.days || 7))), pass = String(req.body.password || ""), token = crypto.randomBytes(32).toString("base64url"); if (!access?.can_manage) return res.status(403).json({ error: "Entry management required" }); if (access.is_encrypted) return res .status(409) .json({ error: "Encrypted entries cannot have public links" }); if (pass && pass.length < 8) return res .status(400) .json({ error: "Share passwords need at least 8 characters" }); const { rows } = await pool.query( `INSERT INTO diary_public_links(entry_id,created_by,token_hash,token_ciphertext,password_hash,expires_at) VALUES($1,$2,$3,$4,$5,NOW()+($6||' days')::interval) RETURNING id,expires_at,created_at`, [ req.params.id, req.user.id, hash(token), encryptSecret(token), pass ? await password(pass) : null, days, ], ); await activity( access.journal_id, req.user.id, "created_public_link", req.params.id, { expiresAt: rows[0].expires_at, passwordProtected: !!pass }, ); res.status(201).json({ ...rows[0], token, url: `/share/${token}`, passwordProtected: !!pass, }); } catch (error) { next(error); } }); app.delete("/api/public-links/:id", auth, async (req, res, next) => { try { const { rows } = await pool.query( `UPDATE diary_public_links l SET revoked_at=NOW() FROM diary_entries e JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE l.id=$1 AND e.id=l.entry_id AND (e.user_id=$2 OR j.owner_id=$2 OR m.role='editor') RETURNING l.id,l.entry_id,e.journal_id`, [req.params.id, req.user.id], ); if (!rows[0]) return res.status(404).json({ error: "Share link not found" }); await activity( rows[0].journal_id, req.user.id, "revoked_public_link", rows[0].entry_id, ); res.status(204).end(); } catch (error) { next(error); } }); app.get("/api/entries", auth, async (req, res, next) => { try { const { rows } = await pool.query( `SELECT e.*,u.display_name,(e.user_id=$1 OR j.owner_id=$1 OR jm.role='editor') AS owner FROM diary_entries e JOIN diary_users u ON u.id=e.user_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members jm ON jm.journal_id=j.id AND jm.user_id=$1 WHERE e.deleted_at IS NULL AND (e.user_id=$1 OR j.owner_id=$1 OR jm.user_id=$1 OR e.visibility='public') ORDER BY e.pinned DESC,e.entry_date DESC,e.created_at DESC`, [req.user.id], ); res.json(rows.map(entry)); } catch (e) { next(e); } }); app.get("/api/entries/:id", auth, async (req, res, next) => { try { const { rows } = await pool.query( `SELECT e.*,u.display_name,(e.user_id=$1 OR j.owner_id=$1 OR jm.role='editor') AS owner FROM diary_entries e JOIN diary_users u ON u.id=e.user_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members jm ON jm.journal_id=j.id AND jm.user_id=$1 WHERE e.id=$2 AND e.deleted_at IS NULL AND (e.user_id=$1 OR j.owner_id=$1 OR jm.user_id=$1 OR e.visibility='public')`, [req.user.id, req.params.id], ); if (!rows[0]) return res.status(404).json({ error: "Entry not found" }); res.json(entry(rows[0])); } catch (error) { next(error); } }); app.get("/api/search", auth, async (req, res, next) => { try { const values = [req.user.id], clauses = [ "e.deleted_at IS NULL", "(e.user_id=$1 OR j.owner_id=$1 OR jm.user_id=$1 OR e.visibility='public')", ], add = (sql, value) => { values.push(value); clauses.push(sql.replace("?", `$${values.length}`)); }, q = String(req.query.q || "") .trim() .slice(0, 500); if (q) { values.push(q); clauses.push( `(to_tsvector('english'::regconfig,coalesce(e.title,'')||' '||coalesce(e.mood,'')||' '||coalesce(e.location_name,'')) || jsonb_to_tsvector('english'::regconfig,e.body,'["string"]'::jsonb) || jsonb_to_tsvector('english'::regconfig,e.tags,'["string"]'::jsonb)) @@ websearch_to_tsquery('english'::regconfig,$${values.length})`, ); } if (req.query.tag) add("e.tags @> to_jsonb(ARRAY[?]::text[])", String(req.query.tag)); if (req.query.mood) add("lower(e.mood)=lower(?)", String(req.query.mood)); if (req.query.from) add("e.entry_date>=?::date", String(req.query.from)); if (req.query.to) add("e.entry_date<=?::date", String(req.query.to)); if (req.query.media === "true") clauses.push("jsonb_array_length(e.media)>0"); if (req.query.located === "true") clauses.push("e.latitude IS NOT NULL AND e.longitude IS NOT NULL"); if (req.query.owner === "mine") clauses.push("e.user_id=$1"); if (req.query.visibility === "private") clauses.push("e.user_id=$1 AND e.visibility='private'"); if (req.query.visibility === "public") clauses.push("e.visibility='public'"); const { rows } = await pool.query( `SELECT e.*,u.display_name,(e.user_id=$1 OR j.owner_id=$1 OR jm.role='editor') AS owner FROM diary_entries e JOIN diary_users u ON u.id=e.user_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members jm ON jm.journal_id=j.id AND jm.user_id=$1 WHERE ${clauses.join(" AND ")} ORDER BY e.pinned DESC,e.entry_date DESC,e.created_at DESC LIMIT 500`, values, ); res.json(rows.map(entry)); } catch (e) { next(e); } }); app.get("/api/entries/:id/social", auth, async (req, res, next) => { try { const access = await entryAccess(req.user.id, req.params.id); if (!access) return res.status(404).json({ error: "Entry not found" }); const comments = await pool.query( `SELECT c.id,c.parent_id,CASE WHEN c.deleted_at IS NULL THEN c.body ELSE '' END AS body,c.created_at,c.updated_at,c.approved,c.deleted_at IS NOT NULL AS deleted,CASE WHEN c.deleted_at IS NULL THEN u.id END AS user_id,CASE WHEN c.deleted_at IS NULL THEN u.username ELSE '' END AS username,CASE WHEN c.deleted_at IS NULL THEN u.display_name ELSE 'Deleted comment' END AS display_name,(c.deleted_at IS NULL AND (c.user_id=$2 OR $3::boolean)) AS can_delete FROM diary_comments c JOIN diary_users u ON u.id=c.user_id WHERE c.entry_id=$1 AND (c.deleted_at IS NOT NULL OR c.approved OR c.user_id=$2 OR $3::boolean) ORDER BY c.created_at`, [req.params.id, req.user.id, access.can_manage], ); const reactions = await pool.query( `SELECT r.id,r.comment_id,r.emoji,u.display_name,(r.user_id=$2) AS mine FROM diary_reactions r JOIN diary_users u ON u.id=r.user_id LEFT JOIN diary_comments c ON c.id=r.comment_id WHERE r.entry_id=$1 AND (r.comment_id IS NULL OR c.deleted_at IS NULL) ORDER BY r.created_at`, [req.params.id, req.user.id], ); res.json({ comments: comments.rows, reactions: reactions.rows }); } catch (error) { next(error); } }); app.post("/api/entries/:id/comments", auth, async (req, res, next) => { try { const access = await entryAccess(req.user.id, req.params.id), body = String(req.body.body || "") .trim() .slice(0, 5000), parentId = req.body.parentId || null; if (!access) return res.status(404).json({ error: "Entry not found" }); if (!body) return res.status(400).json({ error: "Comment required" }); const ownerPreferences = await pool.query( "SELECT preferences FROM diary_users WHERE id=$1", [access.user_id], ); const commentPolicy = { ...defaultUserPreferences, ...(ownerPreferences.rows[0]?.preferences || {}), }.commentPolicy; const ownsEntry = +access.user_id === +req.user.id; const blocked = await pool.query( "SELECT 1 FROM diary_user_blocks WHERE (blocker_id=$1 AND blocked_id=$2) OR (blocker_id=$2 AND blocked_id=$1) LIMIT 1", [access.user_id, req.user.id], ); if (blocked.rowCount && !ownsEntry) return res.status(403).json({ error: "Comments are unavailable between these accounts" }); if (commentPolicy === "nobody" && !ownsEntry) return res.status(403).json({ error: "Comments are disabled for this entry" }); if (commentPolicy === "members" && !ownsEntry && !access.role) return res.status(403).json({ error: "Comments are limited to journal members" }); if (parentId) { const parent = await pool.query( "SELECT 1 FROM diary_comments WHERE id=$1 AND entry_id=$2 AND deleted_at IS NULL", [parentId, req.params.id], ); if (!parent.rowCount) return res.status(400).json({ error: "Invalid parent comment" }); } const approved = ownsEntry || access.can_manage || !ownerPreferences.rows[0]?.preferences?.approveComments, { rows } = await pool.query( `INSERT INTO diary_comments(entry_id,user_id,parent_id,body,approved) VALUES($1,$2,$3,$4,$5) RETURNING *`, [req.params.id, req.user.id, parentId, body, approved], ); if (!approved) return res.status(202).json({ ...rows[0], pending: true }); await pool.query( `INSERT INTO diary_notifications(user_id,actor_id,entry_id,comment_id,type) SELECT e.user_id,$1,e.id,$3,'comment' FROM diary_entries e JOIN diary_users u ON u.id=e.user_id WHERE e.id=$2 AND e.user_id<>$1 AND COALESCE((u.preferences->>'notifyComments')::boolean,true)`, [req.user.id, req.params.id, rows[0].id], ); if (parentId) await pool.query( `INSERT INTO diary_notifications(user_id,actor_id,entry_id,comment_id,type) SELECT c.user_id,$1,c.entry_id,$3,'reply' FROM diary_comments c JOIN diary_users u ON u.id=c.user_id WHERE c.id=$2 AND c.user_id<>$1 AND COALESCE((u.preferences->>'notifyReplies')::boolean,true) AND c.user_id<>(SELECT user_id FROM diary_entries WHERE id=c.entry_id)`, [req.user.id, parentId, rows[0].id], ); const usernames = [ ...new Set( [...body.matchAll(/(?:^|\s)@([a-z0-9_-]{3,30})/gi)].map((match) => match[1].toLowerCase(), ), ), ]; if (usernames.length) await pool.query( `INSERT INTO diary_notifications(user_id,actor_id,entry_id,comment_id,type) SELECT u.id,$1,$2,$3,'mention' FROM diary_users u WHERE u.username=ANY($4) AND u.id<>$1 AND COALESCE((u.preferences->>'notifyMentions')::boolean,true) AND NOT EXISTS(SELECT 1 FROM diary_user_blocks b WHERE (b.blocker_id=u.id AND b.blocked_id=$1) OR (b.blocker_id=$1 AND b.blocked_id=u.id)) AND (EXISTS(SELECT 1 FROM diary_journals j WHERE j.id=$5 AND j.owner_id=u.id) OR EXISTS(SELECT 1 FROM diary_journal_members m WHERE m.journal_id=$5 AND m.user_id=u.id))`, [req.user.id, req.params.id, rows[0].id, usernames, access.journal_id], ); await activity(access.journal_id, req.user.id, "commented", req.params.id, { commentId: +rows[0].id, }); res.status(201).json(rows[0]); } catch (error) { next(error); } }); app.delete("/api/comments/:id", auth, async (req, res, next) => { const client = await pool.connect(); try { const found = await client.query( "SELECT c.*,e.journal_id FROM diary_comments c JOIN diary_entries e ON e.id=c.entry_id WHERE c.id=$1", [req.params.id], ), comment = found.rows[0]; if (!comment) return res.status(404).json({ error: "Comment not found" }); const access = await entryAccess(req.user.id, comment.entry_id); if (!access || (+comment.user_id !== +req.user.id && !access.can_manage)) return res.status(403).json({ error: "Not allowed" }); await client.query("BEGIN"); const children = await client.query("SELECT 1 FROM diary_comments WHERE parent_id=$1 LIMIT 1", [req.params.id]); if (children.rowCount) { await client.query("UPDATE diary_comments SET body='',deleted_at=NOW(),updated_at=NOW() WHERE id=$1", [req.params.id]); await client.query("DELETE FROM diary_reactions WHERE comment_id=$1", [req.params.id]); } else { await client.query("DELETE FROM diary_comments WHERE id=$1", [req.params.id]); let ancestorId = comment.parent_id; while (ancestorId) { const ancestor = await client.query("SELECT id,parent_id,deleted_at,EXISTS(SELECT 1 FROM diary_comments child WHERE child.parent_id=diary_comments.id) AS has_children FROM diary_comments WHERE id=$1 FOR UPDATE", [ancestorId]); if (!ancestor.rowCount || !ancestor.rows[0].deleted_at || ancestor.rows[0].has_children) break; await client.query("DELETE FROM diary_comments WHERE id=$1", [ancestorId]); ancestorId = ancestor.rows[0].parent_id; } } await client.query("COMMIT"); await activity( comment.journal_id, req.user.id, "deleted_comment", comment.entry_id, ); res.json({ deleted: true, tombstoned: children.rowCount > 0 }); } catch (error) { await client.query("ROLLBACK").catch(() => {}); next(error); } finally { client.release(); } }); app.post("/api/entries/:id/reactions", auth, async (req, res, next) => { try { const access = await entryAccess(req.user.id, req.params.id), emoji = String(req.body.emoji || "").slice(0, 16), commentId = req.body.commentId || null; if (!access) return res.status(404).json({ error: "Entry not found" }); if (!emoji) return res.status(400).json({ error: "Reaction required" }); const blocked = await pool.query( "SELECT 1 FROM diary_user_blocks WHERE (blocker_id=$1 AND blocked_id=$2) OR (blocker_id=$2 AND blocked_id=$1) LIMIT 1", [access.user_id, req.user.id], ); if (blocked.rowCount && +access.user_id !== +req.user.id) return res.status(403).json({ error: "Reactions are unavailable between these accounts" }); if (commentId) { const comment = await pool.query( "SELECT 1 FROM diary_comments WHERE id=$1 AND entry_id=$2 AND deleted_at IS NULL", [commentId, req.params.id], ); if (!comment.rowCount) return res.status(400).json({ error: "Invalid comment" }); } const removed = await pool.query( "DELETE FROM diary_reactions WHERE entry_id=$1 AND comment_id IS NOT DISTINCT FROM $2::bigint AND user_id=$3 AND emoji=$4", [req.params.id, commentId, req.user.id, emoji], ); if (!removed.rowCount) await pool.query( "INSERT INTO diary_reactions(entry_id,comment_id,user_id,emoji) VALUES($1,$2,$3,$4)", [req.params.id, commentId, req.user.id, emoji], ); await activity( access.journal_id, req.user.id, removed.rowCount ? "removed_reaction" : "reacted", req.params.id, { emoji, commentId }, ); res.json({ active: !removed.rowCount }); } catch (error) { next(error); } }); app.get("/api/journals/:id/activity", auth, async (req, res, next) => { try { const access = await pool.query( `SELECT 1 FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE j.id=$1 AND (j.owner_id=$2 OR m.user_id=$2)`, [req.params.id, req.user.id], ); if (!access.rowCount) return res.status(403).json({ error: "Journal access required" }); const { rows } = await pool.query( `SELECT a.id,a.action,a.metadata,a.created_at,u.display_name,e.title AS entry_title FROM diary_activity a LEFT JOIN diary_users u ON u.id=a.actor_id LEFT JOIN diary_entries e ON e.id=a.entry_id WHERE a.journal_id=$1 ORDER BY a.created_at DESC LIMIT 100`, [req.params.id], ); res.json(rows); } catch (error) { next(error); } }); app.get("/api/notifications", auth, async (req, res, next) => { try { const preferenceResult = await pool.query("SELECT preferences FROM diary_users WHERE id=$1", [req.user.id]), preferences = { ...defaultUserPreferences, ...(preferenceResult.rows[0]?.preferences || {}) }; if (!preferences.notificationInApp && req.query.channel !== "browser") return res.json([]); const { rows } = await pool.query( `SELECT n.id,n.type,n.read_at,n.created_at,n.entry_id,u.display_name AS actor,e.title AS entry_title FROM diary_notifications n LEFT JOIN diary_users u ON u.id=n.actor_id LEFT JOIN diary_entries e ON e.id=n.entry_id WHERE n.user_id=$1 ${req.query.unread === "1" ? "AND n.read_at IS NULL" : ""} ORDER BY n.created_at DESC LIMIT 100`, [req.user.id], ); res.json(rows); } catch (error) { next(error); } }); app.post("/api/notifications/read", auth, async (req, res, next) => { try { await pool.query( "UPDATE diary_notifications SET read_at=NOW() WHERE user_id=$1 AND read_at IS NULL", [req.user.id], ); res.status(204).end(); } catch (error) { next(error); } }); app.get("/api/knowledge", auth, async (req, res, next) => { try { const accessible = await pool.query( `SELECT DISTINCT e.id,e.journal_id,e.title,e.entry_date,u.display_name AS author FROM diary_entries e JOIN diary_users u ON u.id=e.user_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 WHERE e.deleted_at IS NULL AND (e.user_id=$1 OR j.owner_id=$1 OR m.user_id=$1 OR e.visibility='public') ORDER BY e.entry_date DESC LIMIT 1000`, [req.user.id], ); const ids = accessible.rows.map((row) => row.id); if (!ids.length) return res.json({ entries: [], links: [], people: [], collections: [] }); const [links, people, collections] = await Promise.all([ pool.query( `SELECT l.id,l.source_entry_id,l.target_entry_id,l.label FROM diary_entry_links l WHERE l.source_entry_id=ANY($1::bigint[]) AND l.target_entry_id=ANY($1::bigint[])`, [ids], ), pool.query( `SELECT p.id,p.name,p.notes,p.owner_id,COALESCE(array_agg(ep.entry_id) FILTER (WHERE ep.entry_id=ANY($2::bigint[])),'{}') AS entry_ids FROM diary_people p LEFT JOIN diary_entry_people ep ON ep.person_id=p.id WHERE p.owner_id=$1 OR ep.entry_id=ANY($2::bigint[]) GROUP BY p.id ORDER BY p.name`, [req.user.id, ids], ), pool.query( `SELECT c.id,c.journal_id,c.name,c.color,c.description,c.created_by,COALESCE(array_agg(ce.entry_id) FILTER (WHERE ce.entry_id=ANY($2::bigint[])),'{}') AS entry_ids FROM diary_collections c LEFT JOIN diary_collection_entries ce ON ce.collection_id=c.id JOIN diary_journals j ON j.id=c.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 WHERE j.owner_id=$1 OR m.user_id=$1 GROUP BY c.id ORDER BY c.name`, [req.user.id, ids], ), ]); res.json({ entries: accessible.rows.map((row) => ({ ...row, id: +row.id, journal_id: +row.journal_id, })), links: links.rows.map((row) => ({ id: +row.id, sourceId: +row.source_entry_id, targetId: +row.target_entry_id, label: row.label, })), people: people.rows.map((row) => ({ id: +row.id, name: row.name, notes: row.notes, ownerId: +row.owner_id, entryIds: row.entry_ids.map(Number), })), collections: collections.rows.map((row) => ({ id: +row.id, journalId: +row.journal_id, name: row.name, color: row.color, description: row.description, createdBy: +row.created_by, entryIds: row.entry_ids.map(Number), })), }); } catch (error) { next(error); } }); app.post("/api/people", auth, async (req, res, next) => { try { const name = String(req.body.name || "") .trim() .slice(0, 100); if (!name) return res.status(400).json({ error: "Person name required" }); const { rows } = await pool.query( "INSERT INTO diary_people(owner_id,name,notes) VALUES($1,$2,$3) RETURNING *", [req.user.id, name, String(req.body.notes || "").slice(0, 1000)], ); res.status(201).json(rows[0]); } catch (error) { if (error.code === "23505") return res.status(409).json({ error: "That person already exists" }); next(error); } }); app.put("/api/people/:id", auth, async (req, res, next) => { try { const name = String(req.body.name || "") .trim() .slice(0, 100), { rows } = await pool.query( "UPDATE diary_people SET name=$1,notes=$2 WHERE id=$3 AND owner_id=$4 RETURNING *", [ name, String(req.body.notes || "").slice(0, 1000), req.params.id, req.user.id, ], ); if (!rows[0]) return res.status(404).json({ error: "Person not found" }); res.json(rows[0]); } catch (error) { next(error); } }); app.delete("/api/people/:id", auth, async (req, res, next) => { try { const result = await pool.query( "DELETE FROM diary_people WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.id], ); if (!result.rowCount) return res.status(404).json({ error: "Person not found" }); res.status(204).end(); } catch (error) { next(error); } }); app.post("/api/entries/:id/people/:personId", auth, async (req, res, next) => { try { const access = await entryAccess(req.user.id, req.params.id); if (!access?.can_manage) return res.status(403).json({ error: "Entry management required" }); const person = await pool.query( "SELECT 1 FROM diary_people WHERE id=$1 AND (owner_id=$2 OR EXISTS(SELECT 1 FROM diary_entry_people ep JOIN diary_entries e ON e.id=ep.entry_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE ep.person_id=$1 AND (j.owner_id=$2 OR m.user_id=$2)))", [req.params.personId, req.user.id], ); if (!person.rowCount) return res.status(404).json({ error: "Person not found" }); const removed = await pool.query( "DELETE FROM diary_entry_people WHERE entry_id=$1 AND person_id=$2", [req.params.id, req.params.personId], ); if (!removed.rowCount) await pool.query( "INSERT INTO diary_entry_people(entry_id,person_id,created_by) VALUES($1,$2,$3)", [req.params.id, req.params.personId, req.user.id], ); await activity( access.journal_id, req.user.id, removed.rowCount ? "unlinked_person" : "linked_person", req.params.id, { personId: +req.params.personId }, ); res.json({ active: !removed.rowCount }); } catch (error) { next(error); } }); app.post("/api/entries/:id/links", auth, async (req, res, next) => { try { const access = await entryAccess(req.user.id, req.params.id), target = await entryAccess(req.user.id, req.body.targetId), label = String(req.body.label || "related") .trim() .slice(0, 60); if (!access?.can_manage) return res.status(403).json({ error: "Entry management required" }); if (!target || +req.params.id === +req.body.targetId) return res .status(400) .json({ error: "Valid different target entry required" }); const { rows } = await pool.query( "INSERT INTO diary_entry_links(source_entry_id,target_entry_id,created_by,label) VALUES($1,$2,$3,$4) ON CONFLICT(source_entry_id,target_entry_id) DO UPDATE SET label=EXCLUDED.label RETURNING *", [req.params.id, req.body.targetId, req.user.id, label || "related"], ); await activity( access.journal_id, req.user.id, "linked_entry", req.params.id, { targetId: +req.body.targetId, label }, ); res.status(201).json(rows[0]); } catch (error) { next(error); } }); app.get("/api/entries/:id/links", auth, async (req, res, next) => { try { const access = await entryAccess(req.user.id, req.params.id); if (!access) return res.status(404).json({ error: "Entry not found" }); const visibleLinkSql = `(e.user_id=$2 OR j.owner_id=$2 OR m.user_id=$2 OR e.visibility='public')`, outbound = await pool.query( `SELECT l.id,l.label,e.id AS entry_id,e.title,e.entry_date FROM diary_entry_links l JOIN diary_entries e ON e.id=l.target_entry_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE l.source_entry_id=$1 AND ${visibleLinkSql} ORDER BY e.entry_date DESC`, [req.params.id, req.user.id], ), backlinks = await pool.query( `SELECT l.id,l.label,e.id AS entry_id,e.title,e.entry_date FROM diary_entry_links l JOIN diary_entries e ON e.id=l.source_entry_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE l.target_entry_id=$1 AND ${visibleLinkSql} ORDER BY e.entry_date DESC`, [req.params.id, req.user.id], ); res.json({ outbound: outbound.rows, backlinks: backlinks.rows }); } catch (error) { next(error); } }); app.delete("/api/entry-links/:id", auth, async (req, res, next) => { try { const found = await pool.query( "SELECT * FROM diary_entry_links WHERE id=$1", [req.params.id], ), link = found.rows[0], access = link && (await entryAccess(req.user.id, link.source_entry_id)); if (!access?.can_manage) return res.status(403).json({ error: "Entry management required" }); await pool.query("DELETE FROM diary_entry_links WHERE id=$1", [ req.params.id, ]); res.status(204).end(); } catch (error) { next(error); } }); app.post("/api/collections", auth, async (req, res, next) => { try { const access = await writableJournal(req.user.id, req.body.journalId, [ "owner", "editor", ]), name = String(req.body.name || "") .trim() .slice(0, 100), color = /^#[0-9a-f]{6}$/i.test(req.body.color) ? req.body.color : "#bb9af7"; if (!access) return res.status(403).json({ error: "Journal editor required" }); if (!name) return res.status(400).json({ error: "Collection name required" }); const { rows } = await pool.query( "INSERT INTO diary_collections(journal_id,name,color,description,created_by) VALUES($1,$2,$3,$4,$5) RETURNING *", [ access.id, name, color, String(req.body.description || "").slice(0, 500), req.user.id, ], ); res.status(201).json(rows[0]); } catch (error) { if (error.code === "23505") return res.status(409).json({ error: "That collection already exists" }); next(error); } }); app.post( "/api/collections/:id/entries/:entryId", auth, async (req, res, next) => { try { const collection = await pool.query( "SELECT * FROM diary_collections WHERE id=$1", [req.params.id], ), item = collection.rows[0], access = item && (await writableJournal(req.user.id, item.journal_id)), entryPermission = await entryAccess(req.user.id, req.params.entryId); if ( !access || !entryPermission?.can_manage || +entryPermission.journal_id !== +item.journal_id ) return res .status(403) .json({ error: "Collection and entry access required" }); const removed = await pool.query( "DELETE FROM diary_collection_entries WHERE collection_id=$1 AND entry_id=$2", [req.params.id, req.params.entryId], ); if (!removed.rowCount) await pool.query( "INSERT INTO diary_collection_entries(collection_id,entry_id,added_by) VALUES($1,$2,$3)", [req.params.id, req.params.entryId, req.user.id], ); res.json({ active: !removed.rowCount }); } catch (error) { next(error); } }, ); app.delete("/api/collections/:id", auth, async (req, res, next) => { try { const result = await pool.query( "DELETE FROM diary_collections c USING diary_journals j WHERE c.id=$1 AND j.id=c.journal_id AND (j.owner_id=$2 OR EXISTS(SELECT 1 FROM diary_journal_members m WHERE m.journal_id=j.id AND m.user_id=$2 AND m.role='editor'))", [req.params.id, req.user.id], ); if (!result.rowCount) return res.status(404).json({ error: "Collection not found" }); res.status(204).end(); } catch (error) { next(error); } }); app.post("/api/entries", auth, async (req, res, next) => { try { const x = req.body, encrypted = x.encrypted === true, journalAccess = await writableJournal(req.user.id, x.journalId), journalId = journalAccess?.id; if ( !x.date || (encrypted ? !x.encryptedPayload?.ciphertext || !x.encryptedPayload?.iv : !x.body?.length && !x.media?.length) ) return res.status(400).json({ error: "Date and content required" }); if (!journalId) return res.status(400).json({ error: "Valid journal required" }); const { rows } = await pool.query( `INSERT INTO diary_entries(user_id,entry_date,entry_time,title,body,tags,media,link,visibility,mood,location_name,latitude,longitude,pinned,journal_id,is_encrypted,encrypted_payload,reminder_at,weather) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) RETURNING *`, [ req.user.id, x.date, x.time || null, encrypted ? "Encrypted entry" : x.title || "Untitled", JSON.stringify(encrypted ? [] : x.body || []), JSON.stringify(encrypted ? [] : x.tags || []), JSON.stringify(x.media || []), encrypted ? "" : x.link || "", encrypted ? "private" : x.visibility === "public" ? "public" : "private", encrypted ? "" : x.mood || "", encrypted ? "" : x.location || "", encrypted ? null : x.latitude || null, encrypted ? null : x.longitude || null, !!x.pinned, journalId, encrypted, encrypted ? JSON.stringify(x.encryptedPayload) : null, x.reminderAt || null, x.weather || null, ], ); await activity(journalId, req.user.id, "created_entry", rows[0].id, { title: rows[0].title, }); await notifyJournalMembers(journalId, req.user.id, rows[0].id, "shared_entry_created"); res .status(201) .json(entry({ ...rows[0], display_name: req.user.display_name })); } catch (e) { next(e); } }); app.put("/api/entries/:id", auth, async (req, res, next) => { try { const x = req.body, encrypted = x.encrypted === true, currentEntry = await pool.query( "SELECT user_id FROM diary_entries WHERE id=$1", [req.params.id], ), ownsEntry = +currentEntry.rows[0]?.user_id === +req.user.id, journalAccess = await writableJournal( req.user.id, x.journalId, ownsEntry ? ["owner", "editor", "contributor"] : ["owner", "editor"], ), journalId = journalAccess?.id; if ( !x.date || (encrypted ? !x.encryptedPayload?.ciphertext || !x.encryptedPayload?.iv : !x.body?.length && !x.media?.length) ) return res.status(400).json({ error: "Date and content required" }); if (!journalId) return res.status(400).json({ error: "Valid journal required" }); await pool.query( `INSERT INTO diary_entry_revisions(entry_id,user_id,snapshot) SELECT e.id,$2,to_jsonb(e) FROM diary_entries e JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE e.id=$1 AND (e.user_id=$2 OR j.owner_id=$2 OR m.role='editor')`, [req.params.id, req.user.id], ); const { rows } = await pool.query( `UPDATE diary_entries e SET entry_date=$1,entry_time=$20,title=$2,body=$3,tags=$4,media=$5,link=$6,visibility=$7,mood=$8,location_name=$9,latitude=$10,longitude=$11,pinned=$12,journal_id=$13,is_encrypted=$16,encrypted_payload=$17,reminder_at=$18,weather=$19,updated_at=NOW() WHERE e.id=$14 AND (e.user_id=$15 OR EXISTS(SELECT 1 FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$15 WHERE j.id=e.journal_id AND (j.owner_id=$15 OR m.role='editor'))) RETURNING e.*`, [ x.date, encrypted ? "Encrypted entry" : x.title || "Untitled", JSON.stringify(encrypted ? [] : x.body || []), JSON.stringify(encrypted ? [] : x.tags || []), JSON.stringify(x.media || []), encrypted ? "" : x.link || "", encrypted ? "private" : x.visibility === "public" ? "public" : "private", encrypted ? "" : x.mood || "", encrypted ? "" : x.location || "", encrypted ? null : x.latitude || null, encrypted ? null : x.longitude || null, !!x.pinned, journalId, req.params.id, req.user.id, encrypted, encrypted ? JSON.stringify(x.encryptedPayload) : null, x.reminderAt || null, x.weather || null, x.time || null, ], ); if (!rows[0]) return res.status(404).json({ error: "Entry not found" }); await activity( rows[0].journal_id, req.user.id, "updated_entry", rows[0].id, { title: rows[0].title, }, ); await notifyJournalMembers(rows[0].journal_id, req.user.id, rows[0].id, "shared_entry_updated"); res.json(entry({ ...rows[0], display_name: req.user.display_name })); } catch (e) { next(e); } }); app.delete("/api/entries/:id", auth, async (req, res, next) => { try { const x = await pool.query( `UPDATE diary_entries e SET deleted_at=NOW(),updated_at=NOW() WHERE e.id=$1 AND e.deleted_at IS NULL AND (e.user_id=$2 OR EXISTS(SELECT 1 FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE j.id=e.journal_id AND (j.owner_id=$2 OR m.role='editor'))) RETURNING e.journal_id,e.title`, [req.params.id, req.user.id], ); if (!x.rowCount) return res.status(404).json({ error: "Not found" }); await activity( x.rows[0].journal_id, req.user.id, "trashed_entry", req.params.id, { title: x.rows[0].title, }, ); res.status(204).end(); } catch (e) { next(e); } }); app.get("/api/trash", auth, async (req, res, next) => { try { const { rows } = await pool.query( `SELECT e.*,u.display_name,true AS owner FROM diary_entries e JOIN diary_users u ON u.id=e.user_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 WHERE e.deleted_at IS NOT NULL AND (e.user_id=$1 OR j.owner_id=$1 OR m.role='editor') ORDER BY e.deleted_at DESC`, [req.user.id], ); res.json(rows.map(entry)); } catch (e) { next(e); } }); app.post("/api/entries/:id/restore", auth, async (req, res, next) => { try { const { rows } = await pool.query( `UPDATE diary_entries e SET deleted_at=NULL,updated_at=NOW() WHERE e.id=$1 AND (e.user_id=$2 OR EXISTS(SELECT 1 FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE j.id=e.journal_id AND (j.owner_id=$2 OR m.role='editor'))) RETURNING e.*`, [req.params.id, req.user.id], ); rows[0] ? res.json(entry({ ...rows[0], display_name: req.user.display_name })) : res.status(404).json({ error: "Entry not found" }); } catch (e) { next(e); } }); app.delete("/api/entries/:id/permanent", auth, async (req, res, next) => { try { const x = await pool.query( `DELETE FROM diary_entries e WHERE e.id=$1 AND e.deleted_at IS NOT NULL AND (e.user_id=$2 OR EXISTS(SELECT 1 FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE j.id=e.journal_id AND (j.owner_id=$2 OR m.role='editor')))`, [req.params.id, req.user.id], ); x.rowCount ? res.status(204).end() : res.status(404).json({ error: "Entry not found" }); } catch (e) { next(e); } }); app.get("/api/entries/:id/revisions", auth, async (req, res, next) => { try { const { rows } = await pool.query( `SELECT r.id,r.created_at,r.snapshot->>'title' AS title FROM diary_entry_revisions r JOIN diary_entries e ON e.id=r.entry_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE r.entry_id=$1 AND (e.user_id=$2 OR j.owner_id=$2 OR m.role='editor') ORDER BY r.created_at DESC`, [req.params.id, req.user.id], ); res.json(rows); } catch (e) { next(e); } }); app.post( "/api/entries/:id/revisions/:revisionId/restore", auth, async (req, res, next) => { const client = await pool.connect(); try { await client.query("BEGIN"); const current = await client.query( `SELECT e.* FROM diary_entries e WHERE e.id=$1 AND (e.user_id=$2 OR EXISTS(SELECT 1 FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE j.id=e.journal_id AND (j.owner_id=$2 OR m.role='editor'))) FOR UPDATE`, [req.params.id, req.user.id], ); if (!current.rows[0]) { await client.query("ROLLBACK"); return res.status(404).json({ error: "Entry not found" }); } const revision = await client.query( "SELECT snapshot FROM diary_entry_revisions WHERE id=$1 AND entry_id=$2", [req.params.revisionId, req.params.id], ); if (!revision.rows[0]) { await client.query("ROLLBACK"); return res.status(404).json({ error: "Revision not found" }); } await client.query( "INSERT INTO diary_entry_revisions(entry_id,user_id,snapshot) VALUES($1,$2,$3)", [req.params.id, req.user.id, current.rows[0]], ); const s = revision.rows[0].snapshot; const { rows } = await client.query( `UPDATE diary_entries SET entry_date=$1,title=$2,body=$3,tags=$4,media=$5,link=$6,visibility=$7,mood=$8,location_name=$9,latitude=$10,longitude=$11,pinned=$12,journal_id=$13,deleted_at=NULL,updated_at=NOW() WHERE id=$14 RETURNING *`, [ s.entry_date, s.title, s.body, s.tags, s.media, s.link, s.visibility, s.mood, s.location_name, s.latitude, s.longitude, s.pinned, s.journal_id || current.rows[0].journal_id, req.params.id, ], ); await client.query("COMMIT"); res.json(entry({ ...rows[0], display_name: req.user.display_name })); } catch (e) { await client.query("ROLLBACK"); next(e); } finally { client.release(); } }, ); async function ownedEntries(userId) { const { rows } = await pool.query( "SELECT e.*,u.display_name,true AS owner FROM diary_entries e JOIN diary_users u ON u.id=e.user_id WHERE e.user_id=$1 AND e.deleted_at IS NULL ORDER BY e.entry_date DESC,e.created_at DESC", [userId], ); return rows; } async function completeUserExport(userId) { const queries = { profile: "SELECT id,username,email,display_name,bio,email_verified_at,two_factor_enabled,is_admin,disabled_at,self_deactivated,deletion_requested_at,deletion_scheduled_for,created_at FROM diary_users WHERE id=$1", journals: "SELECT * FROM diary_journals WHERE owner_id=$1 ORDER BY id", memberships: "SELECT m.*,j.name AS journal_name,j.owner_id FROM diary_journal_members m JOIN diary_journals j ON j.id=m.journal_id WHERE m.user_id=$1 ORDER BY m.journal_id", invitations: "SELECT id,journal_id,email,role,expires_at,accepted_at,created_at FROM diary_journal_invitations WHERE invited_by=$1 ORDER BY id", entries: "SELECT * FROM diary_entries WHERE user_id=$1 ORDER BY id", revisions: "SELECT * FROM diary_entry_revisions WHERE user_id=$1 ORDER BY id", comments: "SELECT * FROM diary_comments WHERE user_id=$1 ORDER BY id", reactions: "SELECT * FROM diary_reactions WHERE user_id=$1 ORDER BY id", activity: "SELECT * FROM diary_activity WHERE actor_id=$1 ORDER BY id", notifications: "SELECT * FROM diary_notifications WHERE user_id=$1 ORDER BY id", publicLinks: "SELECT id,entry_id,expires_at,revoked_at,view_count,created_at,last_viewed_at,(password_hash IS NOT NULL) AS password_protected FROM diary_public_links WHERE created_by=$1 ORDER BY id", entryLinks: "SELECT * FROM diary_entry_links WHERE created_by=$1 ORDER BY id", people: "SELECT * FROM diary_people WHERE owner_id=$1 ORDER BY id", entryPeople: "SELECT * FROM diary_entry_people WHERE created_by=$1 ORDER BY entry_id,person_id", collections: "SELECT * FROM diary_collections WHERE created_by=$1 ORDER BY id", collectionEntries: "SELECT * FROM diary_collection_entries WHERE added_by=$1 ORDER BY collection_id,entry_id", passkeys: "SELECT id,name,device_type,backed_up,transports,created_at,last_used_at FROM diary_passkeys WHERE user_id=$1 ORDER BY id", sessions: "SELECT id,created_at,last_seen_at,ip_address,user_agent,expires_at FROM diary_sessions WHERE user_id=$1 ORDER BY created_at", loginHistory: "SELECT id,identifier,success,method,ip_address,user_agent,created_at FROM diary_login_history WHERE user_id=$1 ORDER BY id", personalBackups: "SELECT id,bytes,created_at FROM diary_user_backups WHERE user_id=$1 ORDER BY created_at", registrationInvites: "SELECT id,max_uses,use_count,expires_at,created_at FROM diary_registration_invites WHERE created_by=$1 ORDER BY id", }; const results = await Promise.all( Object.entries(queries).map(async ([name, sql]) => [ name, (await pool.query(sql, [userId])).rows, ]), ); const data = Object.fromEntries(results); data.profile = data.profile[0]; return { format: "field-log-complete-account", version: 1, exportedAt: new Date().toISOString(), ...data, }; } app.get("/api/export/json", auth, async (req, res, next) => { try { const exported = await completeUserExport(req.user.id); res.setHeader( "Content-Disposition", `attachment; filename=field-log-${new Date().toISOString().slice(0, 10)}.json`, ); res.json(exported); } catch (e) { next(e); } }); app.get("/api/export/pdf", auth, async (req, res, next) => { try { const rows = await ownedEntries(req.user.id); res.setHeader("Content-Type", "application/pdf"); res.setHeader( "Content-Disposition", `attachment; filename=field-log-${new Date().toISOString().slice(0, 10)}.pdf`, ); const doc = new PDFDocument({ margin: 54, bufferPages: true }); doc.pipe(res); doc .fontSize(24) .text("DAYMARK") .fontSize(10) .fillColor("#666") .text(`Exported ${new Date().toLocaleString()}`) .moveDown(2); for (const r of rows) { doc .fillColor("#111") .fontSize(16) .text(r.title) .fontSize(9) .fillColor("#666") .text( `${r.entry_date instanceof Date ? r.entry_date.toISOString().slice(0, 10) : String(r.entry_date).slice(0, 10)} / ${(r.tags || []).join(", ")}`, ) .moveDown(0.5) .fontSize(11) .fillColor("#222") .text((r.body || []).join("\n\n"), { lineGap: 3 }) .moveDown(2); if (doc.y > 700) doc.addPage(); } doc.end(); } catch (e) { next(e); } }); app.get("/api/export/zip", auth, async (req, res, next) => { try { const rows = await ownedEntries(req.user.id), exported = await completeUserExport(req.user.id); res.setHeader("Content-Type", "application/zip"); res.setHeader( "Content-Disposition", `attachment; filename=field-log-${new Date().toISOString().slice(0, 10)}.zip`, ); const zip = new ZipArchive({ zlib: { level: 9 } }); zip.on("error", next); zip.pipe(res); zip.append(JSON.stringify(exported, null, 2), { name: "field-log.json" }); for (const r of rows) { const date = r.entry_date instanceof Date ? r.entry_date.toISOString().slice(0, 10) : String(r.entry_date).slice(0, 10), safe = `${date}-${r.id}-${r.title}` .replace(/[^a-z0-9._-]+/gi, "-") .slice(0, 100); zip.append( `# ${r.title}\n\n${date}\n\n${(r.body || []).join("\n\n")}\n\n${(r.tags || []).map((t) => `#${t}`).join(" ")}\n`, { name: `entries/${safe}.md` }, ); for (const [i, m] of (r.media || []).entries()) { if (!m.key) continue; try { zip.append(await mediaStore.getObject(mediaBucket, m.key), { name: `media/${r.id}/${i}-${String(m.name || "attachment").replace(/[\\/]/g, "-")}`, }); } catch {} } } await zip.finalize(); } catch (e) { next(e); } }); async function appendUserBackup(zip, userId) { const rows = await ownedEntries(userId), exported = await completeUserExport(userId); zip.append(JSON.stringify(exported, null, 2), { name: "field-log.json" }); for (const row of rows) { const date = row.entry_date instanceof Date ? row.entry_date.toISOString().slice(0, 10) : String(row.entry_date).slice(0, 10), safe = `${date}-${row.id}-${row.title}` .replace(/[^a-z0-9._-]+/gi, "-") .slice(0, 100); zip.append( `# ${row.title}\n\n${date}\n\n${(row.body || []).join("\n\n")}\n\n${(row.tags || []).map((tag) => `#${tag}`).join(" ")}\n`, { name: `entries/${safe}.md` }, ); for (const [index, media] of (row.media || []).entries()) { if (!media.key) continue; try { zip.append(await mediaStore.getObject(mediaBucket, media.key), { name: `media/${row.id}/${index}-${String(media.name || "attachment").replace(/[\\/]/g, "-")}`, }); } catch {} } } } app.get("/api/user-backups", auth, async (req, res, next) => { try { const { rows } = await pool.query( "SELECT id,bytes,created_at FROM diary_user_backups WHERE user_id=$1 ORDER BY created_at DESC", [req.user.id], ); res.json(rows); } catch (error) { next(error); } }); app.post("/api/user-backups", auth, async (req, res, next) => { const id = crypto.randomUUID(), fileName = `user-${req.user.id}-${id}.zip`, target = path.join(backupPath, fileName); try { await mkdir(backupPath, { recursive: true }); await new Promise(async (resolve, reject) => { const output = createWriteStream(target), zip = new ZipArchive({ zlib: { level: 9 } }); output.on("close", resolve); output.on("error", reject); zip.on("error", reject); zip.pipe(output); try { await appendUserBackup(zip, req.user.id); await zip.finalize(); } catch (error) { reject(error); } }); const information = await stat(target); const { rows } = await pool.query( "INSERT INTO diary_user_backups(id,user_id,file_name,bytes) VALUES($1,$2,$3,$4) RETURNING id,bytes,created_at", [id, req.user.id, fileName, information.size], ); res.status(201).json(rows[0]); } catch (error) { await unlink(target).catch(() => {}); next(error); } }); app.get("/api/user-backups/:id/download", auth, async (req, res, next) => { try { const { rows } = await pool.query( "SELECT file_name FROM diary_user_backups WHERE id=$1 AND user_id=$2", [req.params.id, req.user.id], ); if (!rows.length) return res.status(404).json({ error: "Backup not found" }); res.download(path.join(backupPath, path.basename(rows[0].file_name)), `diary-backup-${req.params.id}.zip`); } catch (error) { next(error); } }); app.delete("/api/user-backups/:id", auth, async (req, res, next) => { try { const { rows } = await pool.query( "DELETE FROM diary_user_backups WHERE id=$1 AND user_id=$2 RETURNING file_name", [req.params.id, req.user.id], ); if (!rows.length) return res.status(404).json({ error: "Backup not found" }); await unlink(path.join(backupPath, path.basename(rows[0].file_name))).catch(() => {}); res.status(204).end(); } catch (error) { next(error); } }); app.get("/api/account/lifecycle", auth, async (req, res, next) => { try { const [account, journals] = await Promise.all([ pool.query("SELECT deletion_requested_at,deletion_scheduled_for FROM diary_users WHERE id=$1", [req.user.id]), pool.query("SELECT id,name FROM diary_journals WHERE owner_id=$1 ORDER BY is_default DESC,name", [req.user.id]), ]); res.json({ ...account.rows[0], journals: journals.rows }); } catch (error) { next(error); } }); app.post("/api/account/deactivate", auth, async (req, res, next) => { try { const current = await pool.query("SELECT password_hash,is_admin FROM diary_users WHERE id=$1", [req.user.id]); if (!(await verify(req.body.password || "", current.rows[0].password_hash))) return res.status(401).json({ error: "Current password is incorrect" }); if (current.rows[0].is_admin) { const admins = await pool.query("SELECT count(*)::int AS count FROM diary_users WHERE is_admin AND disabled_at IS NULL"); if (admins.rows[0].count <= 1) return res.status(409).json({ error: "Promote another administrator before deactivating" }); } await pool.query("UPDATE diary_users SET disabled_at=NOW(),self_deactivated=true WHERE id=$1", [req.user.id]); await pool.query("DELETE FROM diary_sessions WHERE user_id=$1", [req.user.id]); res.clearCookie("diary_session", { path: "/" }); res.json({ deactivated: true }); } catch (error) { next(error); } }); app.post("/api/account/transfer", auth, async (req, res, next) => { const client = await pool.connect(); try { const target = await client.query("SELECT id,username FROM diary_users WHERE username=lower($1) AND disabled_at IS NULL", [String(req.body.username || "")]); if (!target.rows.length || +target.rows[0].id === +req.user.id) return res.status(400).json({ error: "Choose another active account" }); const journalId = req.body.journalId === "all" ? null : Number(req.body.journalId); await client.query("BEGIN"); const transferred = await client.query( `UPDATE diary_journals SET owner_id=$1,is_default=false WHERE owner_id=$2 ${journalId ? "AND id=$3" : ""} RETURNING id`, journalId ? [target.rows[0].id, req.user.id, journalId] : [target.rows[0].id, req.user.id], ); if (!transferred.rowCount) { await client.query("ROLLBACK"); return res.status(404).json({ error: "Owned journal not found" }); } await client.query("DELETE FROM diary_journal_members WHERE user_id=$1 AND journal_id=ANY($2::bigint[])", [target.rows[0].id, transferred.rows.map((row) => row.id)]); for (const journal of transferred.rows) await client.query("INSERT INTO diary_journal_members(journal_id,user_id,role) VALUES($1,$2,'editor') ON CONFLICT(journal_id,user_id) DO UPDATE SET role='editor'", [journal.id, req.user.id]); await client.query("COMMIT"); res.json({ transferred: transferred.rowCount, owner: target.rows[0].username }); } catch (error) { await client.query("ROLLBACK").catch(() => {}); next(error); } finally { client.release(); } }); app.post("/api/account/deletion", auth, async (req, res, next) => { try { const current = await pool.query("SELECT password_hash,is_admin FROM diary_users WHERE id=$1", [req.user.id]); if (!(await verify(req.body.password || "", current.rows[0].password_hash))) return res.status(401).json({ error: "Current password is incorrect" }); if (current.rows[0].is_admin) { const admins = await pool.query("SELECT count(*)::int AS count FROM diary_users WHERE is_admin AND disabled_at IS NULL AND deletion_scheduled_for IS NULL"); if (admins.rows[0].count <= 1) return res.status(409).json({ error: "Promote another administrator before scheduling deletion" }); } const { rows } = await pool.query("UPDATE diary_users SET deletion_requested_at=NOW(),deletion_scheduled_for=NOW()+INTERVAL '30 days' WHERE id=$1 RETURNING deletion_scheduled_for", [req.user.id]); res.json(rows[0]); } catch (error) { next(error); } }); app.delete("/api/account/deletion", auth, async (req, res, next) => { try { await pool.query("UPDATE diary_users SET deletion_requested_at=NULL,deletion_scheduled_for=NULL WHERE id=$1", [req.user.id]); res.status(204).end(); } catch (error) { next(error); } }); app.get("/api/security/login-history.csv", auth, async (req, res, next) => { try { const { rows } = await pool.query("SELECT success,method,ip_address,user_agent,created_at FROM diary_login_history WHERE user_id=$1 ORDER BY created_at DESC", [req.user.id]); const quote = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`; res.setHeader("Content-Type", "text/csv; charset=utf-8"); res.setHeader("Content-Disposition", "attachment; filename=account-security-activity.csv"); res.send(["success,method,ip_address,user_agent,created_at", ...rows.map((row) => [row.success, row.method, row.ip_address, row.user_agent, row.created_at.toISOString()].map(quote).join(","))].join("\n")); } catch (error) { next(error); } }); app.delete("/api/account", auth, async (req, res, next) => { const client = await pool.connect(); try { if (req.body.username !== req.user.username) return res.status(400).json({ error: "Enter your username exactly" }); const current = await client.query( "SELECT password_hash,is_admin FROM diary_users WHERE id=$1", [req.user.id], ); if (!(await verify(req.body.password || "", current.rows[0].password_hash))) return res.status(401).json({ error: "Current password is incorrect" }); await client.query("BEGIN"); if (current.rows[0].is_admin) { const admins = await client.query( "SELECT id FROM diary_users WHERE is_admin AND disabled_at IS NULL FOR SHARE", ); if (admins.rowCount <= 1) { await client.query("ROLLBACK"); return res.status(409).json({ error: "Promote another administrator before deleting this account", }); } } await client.query( "DELETE FROM diary_entries WHERE user_id=$1 OR journal_id IN (SELECT id FROM diary_journals WHERE owner_id=$1)", [req.user.id], ); await client.query("DELETE FROM diary_users WHERE id=$1", [req.user.id]); await client.query("COMMIT"); const keys = []; for await (const object of mediaStore.listObjectsV2( mediaBucket, `${req.user.id}/`, true, )) keys.push(object.name); if (keys.length) await mediaStore.removeObjects(mediaBucket, keys); res.clearCookie("diary_session", { path: "/" }); res.json({ deleted: true }); } catch (error) { await client.query("ROLLBACK").catch(() => {}); next(error); } finally { client.release(); } }); app.post( "/api/import/json", auth, upload.single("file"), async (req, res, next) => { const client = await pool.connect(); try { if (!req.file) return res .status(400) .json({ error: "JSON or Markdown file required" }); const text = req.file.buffer.toString("utf8"); let items; if (/\.md$/i.test(req.file.originalname)) { const lines = text.split(/\r?\n/), title = ( lines.find((l) => /^#\s+/.test(l)) || "# Imported note" ).replace(/^#\s+/, ""), date = (text.match(/\b\d{4}-\d{2}-\d{2}\b/) || [])[0] || new Date().toISOString().slice(0, 10), tags = [...text.matchAll(/(?:^|\s)#([\w-]+)/g)].map((m) => m[1]); items = [ { title, date, tags, body: text .replace(/^#\s+.*$/m, "") .replace(date, "") .trim() .split(/\n\s*\n/), }, ]; } else { const data = JSON.parse(text); items = Array.isArray(data) ? data : data.entries; } if (!Array.isArray(items) || items.length > 10000) return res.status(400).json({ error: "Invalid Daymark import" }); const defaultJournal = await writableJournal(req.user.id, null); await client.query("BEGIN"); let imported = 0; for (const x of items) { if (!/^\d{4}-\d{2}-\d{2}$/.test(x.date || "")) continue; await client.query( `INSERT INTO diary_entries(user_id,entry_date,title,body,tags,media,link,visibility,mood,location_name,latitude,longitude,pinned,journal_id) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, [ req.user.id, x.date, x.title || "Untitled", JSON.stringify(Array.isArray(x.body) ? x.body : []), JSON.stringify(Array.isArray(x.tags) ? x.tags : []), JSON.stringify( Array.isArray(x.media) ? x.media.filter((m) => String(m.url || "").startsWith("data:")) : [], ), x.link || "", "private", x.mood || "", x.location || "", x.latitude || null, x.longitude || null, !!x.pinned, defaultJournal?.id, ], ); imported++; } await client.query("COMMIT"); res.status(201).json({ imported }); } catch (e) { await client.query("ROLLBACK"); if (e instanceof SyntaxError) return res.status(400).json({ error: "Invalid JSON" }); next(e); } finally { client.release(); } }, ); app.use(express.static(path.join(root, "dist"), { maxAge: "1d" })); app.get("*all", (_q, r) => r.sendFile(path.join(root, "dist", "index.html"))); app.use((e, _q, r, _n) => { console.error(e); r.status(500).json({ error: "Internal server error" }); }); async function purgeScheduledAccounts() { const { rows } = await pool.query("SELECT id FROM diary_users WHERE deletion_scheduled_for IS NOT NULL AND deletion_scheduled_for<=NOW()"); for (const user of rows) { const backups = await pool.query("SELECT file_name FROM diary_user_backups WHERE user_id=$1", [user.id]); await pool.query("DELETE FROM diary_users WHERE id=$1", [user.id]); const keys = []; for await (const object of mediaStore.listObjectsV2(mediaBucket, `${user.id}/`, true)) keys.push(object.name); if (keys.length) await mediaStore.removeObjects(mediaBucket, keys); for (const backup of backups.rows) await unlink(path.join(backupPath, path.basename(backup.file_name))).catch(() => {}); } } migrate() .then(() => { app.listen(port, "0.0.0.0", () => console.log(`Daymark listening on :${port}`)); deliverNotificationEmails().catch((error) => console.error(error)); setInterval(() => deliverNotificationEmails().catch((error) => console.error(error)), 60000).unref(); purgeScheduledAccounts().catch((error) => console.error(error)); setInterval(() => purgeScheduledAccounts().catch((error) => console.error(error)), 3600000).unref(); }) .catch((e) => { console.error(e); process.exit(1); });