bagg-app/db.js
bagg-builder 1adf88d195 phase0: wallabag-compat API + PWA frontend
- Wallabag v2 API compatible (OAuth2, entries CRUD, tags)
- Express + SQLite (better-sqlite3), zero extra deps
- Gated web UI with session auth
- PWA: service worker, manifest, offline support
- Mobile-first design, dark mode, FAB + modal
2026-05-02 22:35:27 +00:00

384 lines
14 KiB
JavaScript

'use strict';
const Database = require('better-sqlite3');
const crypto = require('crypto');
const path = require('path');
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'data', 'bagg.sqlite');
// Ensure data dir exists
const fs = require('fs');
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// ── Schema ────────────────────────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS web_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS oauth_clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL UNIQUE,
client_secret TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS oauth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
access_token TEXT NOT NULL UNIQUE,
refresh_token TEXT UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
url TEXT NOT NULL,
hashed_url TEXT NOT NULL,
title TEXT,
content TEXT,
domain_name TEXT,
preview_picture TEXT,
language TEXT,
reading_time INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0,
is_starred INTEGER NOT NULL DEFAULT 0,
published_at INTEGER,
archived_at INTEGER,
starred_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_entries_user_id ON entries(user_id);
CREATE INDEX IF NOT EXISTS idx_entries_is_archived ON entries(user_id, is_archived);
CREATE INDEX IF NOT EXISTS idx_entries_is_starred ON entries(user_id, is_starred);
CREATE INDEX IF NOT EXISTS idx_entries_hashed_url ON entries(user_id, hashed_url);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label TEXT NOT NULL,
slug TEXT NOT NULL,
UNIQUE(user_id, slug)
);
CREATE TABLE IF NOT EXISTS entries_tags (
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (entry_id, tag_id)
);
`);
// ── Seed: create default user + client from env ───────────────────────────────
function hashPassword(password, salt) {
return crypto.scryptSync(password, salt, 64).toString('hex');
}
function verifyPassword(password, hash, salt) {
return crypto.timingSafeEqual(
Buffer.from(hashPassword(password, salt), 'hex'),
Buffer.from(hash, 'hex')
);
}
function randomToken() {
return crypto.randomBytes(32).toString('hex');
}
function slugify(str) {
return str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
// Seed admin user if ADMIN_USERNAME/ADMIN_PASSWORD set and no users exist
const adminUsername = process.env.ADMIN_USERNAME;
const adminPassword = process.env.ADMIN_PASSWORD;
if (adminUsername && adminPassword) {
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(adminUsername);
if (!existing) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = hashPassword(adminPassword, salt);
db.prepare('INSERT OR IGNORE INTO users (username, password_hash, salt) VALUES (?, ?, ?)')
.run(adminUsername, hash, salt);
console.log(`[db] Created admin user: ${adminUsername}`);
}
}
// Seed default OAuth2 client
const clientId = process.env.ADMIN_CLIENT_ID || 'bagg_ios';
const clientSecret = process.env.ADMIN_CLIENT_SECRET || 'bagg_secret';
db.prepare('INSERT OR IGNORE INTO oauth_clients (client_id, client_secret) VALUES (?, ?)')
.run(clientId, clientSecret);
// ── User queries ──────────────────────────────────────────────────────────────
function getUserByUsername(username) {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
}
function getUserById(id) {
return db.prepare('SELECT id, username, created_at FROM users WHERE id = ?').get(id);
}
// ── Web session queries ───────────────────────────────────────────────────────
function createWebSession(userId) {
const token = randomToken();
const expiresAt = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30; // 30 days
db.prepare('INSERT INTO web_sessions (token, user_id, expires_at) VALUES (?, ?, ?)')
.run(token, userId, expiresAt);
return token;
}
function getWebSession(token) {
const now = Math.floor(Date.now() / 1000);
return db.prepare(
'SELECT * FROM web_sessions WHERE token = ? AND expires_at > ?'
).get(token, now);
}
function deleteWebSession(token) {
db.prepare('DELETE FROM web_sessions WHERE token = ?').run(token);
}
// ── OAuth client queries ──────────────────────────────────────────────────────
function getOAuthClient(clientId, clientSecret) {
return db.prepare(
'SELECT * FROM oauth_clients WHERE client_id = ? AND client_secret = ?'
).get(clientId, clientSecret);
}
// ── OAuth token queries ───────────────────────────────────────────────────────
function createOAuthToken(accessToken, refreshToken, userId) {
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
db.prepare(
'INSERT INTO oauth_tokens (access_token, refresh_token, user_id, expires_at) VALUES (?, ?, ?, ?)'
).run(accessToken, refreshToken, userId, expiresAt);
}
function getTokenByAccessToken(token) {
const now = Math.floor(Date.now() / 1000);
return db.prepare(
'SELECT * FROM oauth_tokens WHERE access_token = ? AND expires_at > ?'
).get(token, now);
}
function getTokenByRefreshToken(token) {
return db.prepare(
'SELECT * FROM oauth_tokens WHERE refresh_token = ?'
).get(token);
}
function rotateOAuthToken(oldId, newAccess, newRefresh) {
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
db.prepare('DELETE FROM oauth_tokens WHERE id = ?').run(oldId);
db.prepare(
'INSERT INTO oauth_tokens (access_token, refresh_token, user_id, expires_at) SELECT ?, ?, user_id, ? FROM oauth_tokens WHERE id = ?'
).run(newAccess, newRefresh, expiresAt, oldId);
// oldId is gone, re-insert with the user_id from the deleted row -- use a different approach
}
// Fix: keep user_id available before deletion
function rotateOAuthTokenFix(oldToken) {
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
const newAccess = randomToken();
const newRefresh = randomToken();
db.prepare('DELETE FROM oauth_tokens WHERE id = ?').run(oldToken.id);
db.prepare(
'INSERT INTO oauth_tokens (access_token, refresh_token, user_id, expires_at) VALUES (?, ?, ?, ?)'
).run(newAccess, newRefresh, oldToken.user_id, expiresAt);
return { access_token: newAccess, refresh_token: newRefresh };
}
// ── Entry queries ─────────────────────────────────────────────────────────────
function hashUrl(url) {
return crypto.createHash('sha1').update(url).digest('hex');
}
function extractDomain(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return null; }
}
const GET_ENTRIES = db.prepare(`
SELECT * FROM entries
WHERE user_id = :userId
AND (:archive IS NULL OR is_archived = :archive)
AND (:starred IS NULL OR is_starred = :starred)
ORDER BY
CASE WHEN :sort = 'created' AND :order = 'desc' THEN created_at END DESC,
CASE WHEN :sort = 'created' AND :order = 'asc' THEN created_at END ASC,
CASE WHEN :sort = 'updated' AND :order = 'desc' THEN updated_at END DESC,
CASE WHEN :sort = 'updated' AND :order = 'asc' THEN updated_at END ASC,
CASE WHEN :sort = 'id' AND :order = 'desc' THEN id END DESC,
CASE WHEN :sort = 'id' AND :order = 'asc' THEN id END ASC,
created_at DESC
LIMIT :limit OFFSET :offset
`);
const COUNT_ENTRIES = db.prepare(`
SELECT COUNT(*) as count FROM entries
WHERE user_id = :userId
AND (:archive IS NULL OR is_archived = :archive)
AND (:starred IS NULL OR is_starred = :starred)
`);
function getEntries(userId, { page = 1, perPage = 30, archive, starred, sort = 'created', order = 'desc' } = {}) {
const archiveVal = archive !== undefined ? (archive === '1' || archive === 1 ? 1 : 0) : null;
const starredVal = starred !== undefined ? (starred === '1' || starred === 1 ? 1 : 0) : null;
const params = {
userId,
archive: archiveVal,
starred: starredVal,
sort,
order,
limit: +perPage,
offset: (+page - 1) * +perPage
};
const items = GET_ENTRIES.all(params);
const { count } = COUNT_ENTRIES.get(params);
return { items, total: count };
}
function getEntryById(id, userId) {
return db.prepare('SELECT * FROM entries WHERE id = ? AND user_id = ?').get(id, userId);
}
function entryExistsByUrl(url, userId) {
const hash = hashUrl(url);
return db.prepare('SELECT id FROM entries WHERE hashed_url = ? AND user_id = ?').get(hash, userId);
}
function createEntry(userId, { url, title, content, is_archived = 0, is_starred = 0, tags }) {
const domain = extractDomain(url);
const hash = hashUrl(url);
const now = Math.floor(Date.now() / 1000);
const result = db.prepare(`
INSERT INTO entries (user_id, url, hashed_url, title, content, domain_name, is_archived, is_starred, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(userId, url, hash, title || null, content || null, domain, +!!is_archived, +!!is_starred, now, now);
return getEntryById(result.lastInsertRowid, userId);
}
function updateEntry(id, userId, fields) {
const entry = getEntryById(id, userId);
if (!entry) return null;
const now = Math.floor(Date.now() / 1000);
const updates = [];
const values = [];
if (fields.title !== undefined) { updates.push('title = ?'); values.push(fields.title); }
if (fields.content !== undefined) { updates.push('content = ?'); values.push(fields.content); }
if (fields.language !== undefined) { updates.push('language = ?'); values.push(fields.language); }
if (fields.is_archived !== undefined) {
updates.push('is_archived = ?');
values.push(+!!fields.is_archived);
updates.push('archived_at = ?');
values.push(fields.is_archived ? now : null);
}
if (fields.is_starred !== undefined) {
updates.push('is_starred = ?');
values.push(+!!fields.is_starred);
updates.push('starred_at = ?');
values.push(fields.is_starred ? now : null);
}
if (updates.length === 0) return entry;
updates.push('updated_at = ?');
values.push(now);
values.push(id, userId);
db.prepare(`UPDATE entries SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`).run(...values);
return getEntryById(id, userId);
}
function deleteEntry(id, userId) {
return db.prepare('DELETE FROM entries WHERE id = ? AND user_id = ?').run(id, userId).changes > 0;
}
// ── Tag queries ───────────────────────────────────────────────────────────────
function getTagsForEntry(entryId) {
return db.prepare(`
SELECT t.* FROM tags t
JOIN entries_tags et ON et.tag_id = t.id
WHERE et.entry_id = ?
`).all(entryId);
}
function getAllTags(userId) {
return db.prepare('SELECT * FROM tags WHERE user_id = ? ORDER BY label').all(userId);
}
function getOrCreateTag(userId, label) {
const slug = slugify(label);
const existing = db.prepare('SELECT * FROM tags WHERE user_id = ? AND slug = ?').get(userId, slug);
if (existing) return existing;
const result = db.prepare('INSERT INTO tags (user_id, label, slug) VALUES (?, ?, ?)').run(userId, label, slug);
return { id: result.lastInsertRowid, user_id: userId, label, slug };
}
function addTagToEntry(entryId, tagId) {
db.prepare('INSERT OR IGNORE INTO entries_tags (entry_id, tag_id) VALUES (?, ?)').run(entryId, tagId);
}
function removeTagFromEntry(entryId, tagId) {
return db.prepare('DELETE FROM entries_tags WHERE entry_id = ? AND tag_id = ?').run(entryId, tagId).changes > 0;
}
// ── Exports ───────────────────────────────────────────────────────────────────
module.exports = {
hashPassword,
verifyPassword,
randomToken,
getUserByUsername,
getUserById,
createWebSession,
getWebSession,
deleteWebSession,
getOAuthClient,
createOAuthToken,
getTokenByAccessToken,
getTokenByRefreshToken,
rotateOAuthTokenFix,
getEntries,
getEntryById,
entryExistsByUrl,
createEntry,
updateEntry,
deleteEntry,
getTagsForEntry,
getAllTags,
getOrCreateTag,
addTagToEntry,
removeTagFromEntry,
hashUrl,
};