- 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
384 lines
14 KiB
JavaScript
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,
|
|
};
|