From 1adf88d195b4fd21442739212d982041e2676bcc Mon Sep 17 00:00:00 2001 From: bagg-builder Date: Sat, 2 May 2026 22:35:27 +0000 Subject: [PATCH] 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 --- .env.example | 15 ++ .gitignore | 6 + Dockerfile | 23 ++ db.js | 383 ++++++++++++++++++++++++++++++++ docker-compose.yml | 36 +++ package.json | 14 ++ public/app.js | 383 ++++++++++++++++++++++++++++++++ public/index.html | 89 ++++++++ public/login.html | 61 +++++ public/manifest.json | 33 +++ public/style.css | 513 +++++++++++++++++++++++++++++++++++++++++++ public/sw.js | 72 ++++++ server.js | 297 +++++++++++++++++++++++++ 13 files changed, 1925 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 db.js create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/login.html create mode 100644 public/manifest.json create mode 100644 public/style.css create mode 100644 public/sw.js create mode 100644 server.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7619f72 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +PORT=3000 +DATABASE_PATH=./data/bagg.sqlite + +# Initial admin user (created on first run if not exists) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=changeme + +# OAuth2 client credentials for iOS/Safari Wallabag app +# Set these in the iOS app settings: +# Server URL: https://your-domain +# Username/Password: your credentials above +# Client ID: bagg_ios +# Client Secret: bagg_secret +ADMIN_CLIENT_ID=bagg_ios +ADMIN_CLIENT_SECRET=bagg_secret diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..34ad509 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +data/ +*.sqlite +*.sqlite-wal +*.sqlite-shm +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..803ae8e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-bookworm-slim + +# Build tools for better-sqlite3 native compilation +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY . . + +RUN mkdir -p /data + +ENV PORT=3000 +ENV DATABASE_PATH=/data/bagg.sqlite +ENV NODE_ENV=production + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/db.js b/db.js new file mode 100644 index 0000000..e97d2dc --- /dev/null +++ b/db.js @@ -0,0 +1,383 @@ +'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, +}; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8eacfb0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + app: + image: node:22-bookworm-slim + working_dir: /app + environment: + PORT: "3000" + DATABASE_PATH: /data/bagg.sqlite + NODE_ENV: production + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme_please} + ADMIN_CLIENT_ID: ${ADMIN_CLIENT_ID:-bagg_ios} + ADMIN_CLIENT_SECRET: ${ADMIN_CLIENT_SECRET:-bagg_secret} + labels: + fibe.gg/repo_url: https://git-next.fibe.live/viktorvsk/bagg + fibe.gg/source_mount: /app + fibe.gg/start_command: > + sh -c "apt-get update -qq && apt-get install -y --no-install-recommends python3 make g++ 2>/dev/null; + npm install --omit=dev --silent && + node server.js" + fibe.gg/expose: external:3000 + fibe.gg/production: "false" + fibe.gg/healthcheck_path: /api/v1/version + fibe.gg/healthcheck_interval: 15s + fibe.gg/healthcheck_timeout: 5s + fibe.gg/healthcheck_retries: "5" + fibe.gg/healthcheck_start_period: 90s + volumes: + - bagg-data:/data + +volumes: + bagg-data: + +x-fibe.gg: + metadata: + description: Wallabag-compatible read-later service with PWA frontend + category: Web diff --git a/package.json b/package.json new file mode 100644 index 0000000..48b7185 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "bagg", + "version": "0.1.0", + "description": "Wallabag-compatible read-later service", + "main": "server.js", + "engines": { "node": ">=20" }, + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "better-sqlite3": "^9.6.0", + "express": "^4.19.2" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..649f4d4 --- /dev/null +++ b/public/app.js @@ -0,0 +1,383 @@ +'use strict'; + +// ── Theme ───────────────────────────────────────────────────────────────────── + +const savedTheme = localStorage.getItem('bagg_theme'); +if (savedTheme) document.documentElement.setAttribute('data-theme', savedTheme); + +function isDark() { + const t = document.documentElement.getAttribute('data-theme'); + if (t === 'dark') return true; + if (t === 'light') return false; + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} + +function applyThemeIcons() { + const dark = isDark(); + document.getElementById('icon-sun').style.display = dark ? 'block' : 'none'; + document.getElementById('icon-moon').style.display = dark ? 'none' : 'block'; +} + +document.getElementById('btn-theme').addEventListener('click', () => { + const next = isDark() ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem('bagg_theme', next); + applyThemeIcons(); +}); + +applyThemeIcons(); + +// ── Offline detection ───────────────────────────────────────────────────────── + +const offlineBanner = document.getElementById('offline-banner'); +function updateOnlineStatus() { + offlineBanner.classList.toggle('show', !navigator.onLine); +} +window.addEventListener('online', updateOnlineStatus); +window.addEventListener('offline', updateOnlineStatus); +updateOnlineStatus(); + +// ── Toast ───────────────────────────────────────────────────────────────────── + +function showToast(msg, duration = 2500) { + const el = document.createElement('div'); + el.className = 'toast'; + el.textContent = msg; + document.getElementById('toasts').appendChild(el); + setTimeout(() => el.remove(), duration); +} + +// ── State ───────────────────────────────────────────────────────────────────── + +const state = { + filter: 'all', // all | unread | starred | archived + page: 1, + pages: 1, + total: 0, + entries: [], + loading: false, +}; + +// ── Logout ──────────────────────────────────────────────────────────────────── + +document.getElementById('btn-logout').addEventListener('click', async () => { + await fetch('/web/logout', { method: 'POST' }); + location.href = '/login.html'; +}); + +// ── Tab switching ───────────────────────────────────────────────────────────── + +document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.filter = btn.dataset.filter; + state.page = 1; + loadEntries(); + }); +}); + +// ── API helpers ─────────────────────────────────────────────────────────────── + +async function apiFetch(path, opts = {}) { + const r = await fetch(path, { + headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, + ...opts, + }); + if (r.status === 204) return null; + const data = await r.json(); + if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`); + return data; +} + +// ── Load entries ────────────────────────────────────────────────────────────── + +async function loadEntries() { + if (state.loading) return; + state.loading = true; + renderEntries(); + + const params = new URLSearchParams({ page: state.page, perPage: 50 }); + if (state.filter === 'unread') { params.set('archive', '0'); } + if (state.filter === 'archived') { params.set('archive', '1'); } + if (state.filter === 'starred') { params.set('starred', '1'); } + + try { + const data = await apiFetch(`/web/api/entries?${params}`); + state.entries = data.items; + state.page = data.page; + state.pages = data.pages; + state.total = data.total; + } catch (e) { + showToast('Failed to load entries'); + } + + state.loading = false; + renderEntries(); +} + +// ── Render ──────────────────────────────────────────────────────────────────── + +function timeAgo(isoStr) { + const secs = Math.floor((Date.now() - new Date(isoStr)) / 1000); + if (secs < 60) return 'just now'; + if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; + if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; + if (secs < 86400 * 30) return `${Math.floor(secs / 86400)}d ago`; + return new Date(isoStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function faviconUrl(domain) { + if (!domain) return null; + return `https://www.google.com/s2/favicons?sz=32&domain=${encodeURIComponent(domain)}`; +} + +function entryHTML(e) { + const domain = e.domain_name || ''; + const title = e.title || e.url; + const fav = faviconUrl(domain); + const favHTML = fav + ? `` + : domain.slice(0, 2).toUpperCase(); + + return ` +
+
${favHTML}
+
+
${escHtml(title)}
+ +
+
+ + + +
+
`; +} + +function renderEntries() { + const container = document.getElementById('entries-container'); + + if (state.loading) { + container.innerHTML = `

Loading…

`; + return; + } + + if (state.entries.length === 0) { + const msgs = { + all: ['No links saved yet.', 'Tap + to save your first link.'], + unread: ['Nothing to read.', 'Links you haven\'t archived appear here.'], + starred: ['No starred links.', 'Star important links to find them quickly.'], + archived: ['Nothing archived.', 'Archived links live here.'], + }; + const [title, sub] = msgs[state.filter] || msgs.all; + container.innerHTML = ` +
+ + + +

${title}

+ ${sub} +
`; + return; + } + + let html = '
' + state.entries.map(entryHTML).join('') + '
'; + + if (state.pages > 1) { + html += ` + `; + } + + container.innerHTML = html; + + // Pagination + container.querySelector('#btn-prev')?.addEventListener('click', () => { state.page--; loadEntries(); }); + container.querySelector('#btn-next')?.addEventListener('click', () => { state.page++; loadEntries(); }); + + // Entry actions (event delegation) + container.addEventListener('click', handleEntryAction); +} + +function escHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// ── Entry actions ───────────────────────────────────────────────────────────── + +async function handleEntryAction(e) { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + + const id = +btn.dataset.id; + const action = btn.dataset.action; + const entry = state.entries.find(en => en.id === id); + if (!entry) return; + + if (action === 'star') { + try { + const updated = await apiFetch(`/web/api/entries/${id}`, { + method: 'PATCH', + body: JSON.stringify({ is_starred: entry.is_starred ? 0 : 1 }), + }); + Object.assign(entry, updated); + renderEntries(); + } catch { showToast('Failed to update'); } + } + + if (action === 'archive') { + try { + const updated = await apiFetch(`/web/api/entries/${id}`, { + method: 'PATCH', + body: JSON.stringify({ is_archived: entry.is_archived ? 0 : 1 }), + }); + Object.assign(entry, updated); + // Remove from current view if filter doesn't match + if (state.filter === 'unread' && updated.is_archived) state.entries = state.entries.filter(en => en.id !== id); + if (state.filter === 'archived' && !updated.is_archived) state.entries = state.entries.filter(en => en.id !== id); + renderEntries(); + showToast(updated.is_archived ? 'Archived' : 'Moved to unread'); + } catch { showToast('Failed to update'); } + } + + if (action === 'delete') { + if (!confirm('Delete this link?')) return; + try { + await apiFetch(`/web/api/entries/${id}`, { method: 'DELETE' }); + state.entries = state.entries.filter(en => en.id !== id); + renderEntries(); + showToast('Deleted'); + } catch { showToast('Failed to delete'); } + } +} + +// ── Add link modal ──────────────────────────────────────────────────────────── + +const modalAdd = document.getElementById('modal-add'); +const inputUrl = document.getElementById('input-url'); +const inputTitle = document.getElementById('input-title'); + +document.getElementById('btn-add').addEventListener('click', () => { + modalAdd.classList.add('open'); + inputUrl.value = ''; + inputTitle.value = ''; + setTimeout(() => inputUrl.focus(), 50); +}); + +document.getElementById('btn-modal-cancel').addEventListener('click', closeModal); + +modalAdd.addEventListener('click', e => { + if (e.target === modalAdd) closeModal(); +}); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') closeModal(); +}); + +function closeModal() { + modalAdd.classList.remove('open'); +} + +document.getElementById('btn-modal-save').addEventListener('click', saveLink); + +inputUrl.addEventListener('keydown', e => { if (e.key === 'Enter') saveLink(); }); + +async function saveLink() { + const url = inputUrl.value.trim(); + const title = inputTitle.value.trim(); + if (!url) { inputUrl.focus(); return; } + + // Basic URL sanity + try { new URL(url); } catch { + showToast('Invalid URL'); + inputUrl.focus(); + return; + } + + const btn = document.getElementById('btn-modal-save'); + btn.disabled = true; + btn.textContent = 'Saving…'; + + try { + const entry = await apiFetch('/web/api/entries', { + method: 'POST', + body: JSON.stringify({ url, title: title || undefined }), + }); + closeModal(); + showToast('Saved!'); + // Prepend to list if on 'all' or 'unread' filter + if (state.filter === 'all' || state.filter === 'unread') { + state.entries.unshift(entry); + state.total++; + renderEntries(); + } + } catch (e) { + if (e.message?.includes('Already')) { + showToast('Already saved'); + closeModal(); + } else { + showToast('Failed to save'); + } + } finally { + btn.disabled = false; + btn.textContent = 'Save'; + } +} + +// ── Service Worker ──────────────────────────────────────────────────────────── + +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(() => {}); +} + +// ── PWA install prompt ──────────────────────────────────────────────────────── + +let deferredPrompt = null; +window.addEventListener('beforeinstallprompt', e => { + e.preventDefault(); + deferredPrompt = e; + // Show subtle install hint after a delay + setTimeout(() => { + if (deferredPrompt) showToast('Tip: add bagg to your home screen', 4000); + }, 8000); +}); + +// ── Init ────────────────────────────────────────────────────────────────────── + +loadEntries(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..01baabf --- /dev/null +++ b/public/index.html @@ -0,0 +1,89 @@ + + + + + + bagg + + + + + + + + + + +
You're offline — showing cached data
+ +
+ + +
+
+ + + + +
+
+ +
+
+
+
+

Loading…

+
+
+
+
+ + + + + + + + +
+ + + + diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..7848835 --- /dev/null +++ b/public/login.html @@ -0,0 +1,61 @@ + + + + + + Sign in — bagg + + + + + + + + +
+ +
+ + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..a626f6f --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,33 @@ +{ + "name": "bagg", + "short_name": "bagg", + "description": "Read later, actually.", + "start_url": "/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#f8f8f6", + "theme_color": "#2563eb", + "icons": [ + { + "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%232563eb'/%3E%3Cpath d='M30 20h40a5 5 0 0 1 5 5v50a5 5 0 0 1-5 5H30a5 5 0 0 1-5-5V25a5 5 0 0 1 5-5z' fill='white' opacity='.9'/%3E%3Cline x1='35' y1='38' x2='65' y2='38' stroke='%232563eb' stroke-width='4' stroke-linecap='round'/%3E%3Cline x1='35' y1='50' x2='65' y2='50' stroke='%232563eb' stroke-width='4' stroke-linecap='round'/%3E%3Cline x1='35' y1='62' x2='52' y2='62' stroke='%232563eb' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "screenshots": [], + "categories": ["productivity", "utilities"], + "prefer_related_applications": false +} diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..e698b0e --- /dev/null +++ b/public/style.css @@ -0,0 +1,513 @@ +/* ── Variables ─────────────────────────────────────────────────────────────── */ +:root { + --bg: #f8f8f6; + --bg2: #ffffff; + --bg3: #eeecea; + --border: #e0ddd8; + --text: #1a1916; + --text2: #6b6860; + --text3: #9b9890; + --accent: #2563eb; + --accent-h: #1d4ed8; + --danger: #dc2626; + --star: #f59e0b; + --radius: 10px; + --shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.05); + --shadow-lg: 0 10px 25px rgba(0,0,0,.12); + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + --transition: .15s ease; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #141413; + --bg2: #1e1e1c; + --bg3: #2a2a28; + --border: #333330; + --text: #f0ede8; + --text2: #9b9890; + --text3: #6b6860; + --shadow: 0 1px 3px rgba(0,0,0,.3), 0 1px 2px rgba(0,0,0,.2); + } +} + +[data-theme="dark"] { + --bg: #141413; + --bg2: #1e1e1c; + --bg3: #2a2a28; + --border: #333330; + --text: #f0ede8; + --text2: #9b9890; + --text3: #6b6860; + --shadow: 0 1px 3px rgba(0,0,0,.3), 0 1px 2px rgba(0,0,0,.2); +} + +/* ── Reset ─────────────────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { font-size: 16px; -webkit-text-size-adjust: 100%; } +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + min-height: 100dvh; + line-height: 1.5; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +button { + font-family: var(--font); + cursor: pointer; + border: none; + background: none; + color: inherit; +} + +input { + font-family: var(--font); + font-size: 1rem; + background: var(--bg2); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: .5rem .75rem; + outline: none; + width: 100%; + transition: border-color var(--transition); +} +input:focus { border-color: var(--accent); } + +/* ── Login Page ────────────────────────────────────────────────────────────── */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100dvh; + padding: 1.5rem; +} + +.login-card { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: var(--shadow-lg); + padding: 2.5rem 2rem; + width: 100%; + max-width: 360px; +} + +.login-card h1 { + font-size: 1.6rem; + font-weight: 700; + letter-spacing: -.03em; + margin-bottom: .25rem; +} + +.login-card .tagline { + color: var(--text2); + font-size: .9rem; + margin-bottom: 2rem; +} + +.login-card label { + display: block; + font-size: .85rem; + font-weight: 500; + color: var(--text2); + margin-bottom: .35rem; + margin-top: 1rem; +} + +.login-card label:first-of-type { margin-top: 0; } + +.btn-primary { + display: block; + width: 100%; + background: var(--accent); + color: #fff; + border-radius: var(--radius); + padding: .65rem 1rem; + font-size: 1rem; + font-weight: 600; + text-align: center; + transition: background var(--transition); + margin-top: 1.5rem; +} +.btn-primary:hover { background: var(--accent-h); } + +.login-error { + background: #fef2f2; + border: 1px solid #fecaca; + color: var(--danger); + border-radius: var(--radius); + padding: .6rem .85rem; + font-size: .875rem; + margin-bottom: 1rem; +} + +[data-theme="dark"] .login-error { + background: #3b1515; + border-color: #7f1d1d; +} + +/* ── App Layout ────────────────────────────────────────────────────────────── */ +#app { display: flex; flex-direction: column; min-height: 100dvh; } + +.navbar { + background: var(--bg2); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; + padding: 0 1rem; +} + +.navbar-inner { + max-width: 720px; + margin: 0 auto; + height: 52px; + display: flex; + align-items: center; + gap: .75rem; +} + +.nav-brand { + font-weight: 700; + font-size: 1.1rem; + letter-spacing: -.03em; + color: var(--text); + flex: 1; +} + +.nav-btn { + width: 36px; + height: 36px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text2); + transition: background var(--transition), color var(--transition); +} +.nav-btn:hover { background: var(--bg3); color: var(--text); } + +/* ── Filter tabs ───────────────────────────────────────────────────────────── */ +.tabs { + background: var(--bg2); + border-bottom: 1px solid var(--border); + padding: 0 1rem; + overflow-x: auto; + scrollbar-width: none; +} +.tabs::-webkit-scrollbar { display: none; } + +.tabs-inner { + max-width: 720px; + margin: 0 auto; + display: flex; + gap: 0; +} + +.tab-btn { + padding: .65rem 1rem; + font-size: .875rem; + font-weight: 500; + color: var(--text2); + border-bottom: 2px solid transparent; + white-space: nowrap; + transition: color var(--transition), border-color var(--transition); +} +.tab-btn:hover { color: var(--text); } +.tab-btn.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* ── Entry list ────────────────────────────────────────────────────────────── */ +.content { + flex: 1; + padding: .75rem 1rem; + max-width: 720px; + margin: 0 auto; + width: 100%; +} + +.entries-list { + display: flex; + flex-direction: column; + gap: .5rem; +} + +.entry-card { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: .85rem 1rem; + display: flex; + align-items: flex-start; + gap: .75rem; + box-shadow: var(--shadow); + transition: box-shadow var(--transition); + position: relative; +} +.entry-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.1); } +.entry-card.archived { opacity: .55; } + +.entry-favicon { + width: 20px; + height: 20px; + border-radius: 4px; + flex-shrink: 0; + margin-top: 2px; + background: var(--bg3); + display: flex; + align-items: center; + justify-content: center; + font-size: .65rem; + color: var(--text3); + overflow: hidden; +} +.entry-favicon img { width: 100%; height: 100%; object-fit: cover; } + +.entry-body { flex: 1; min-width: 0; } + +.entry-title { + font-size: .9375rem; + font-weight: 500; + color: var(--text); + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: .2rem; +} + +.entry-meta { + font-size: .78rem; + color: var(--text3); + display: flex; + align-items: center; + gap: .5rem; + flex-wrap: wrap; +} + +.entry-domain { color: var(--accent); font-weight: 500; } + +.entry-actions { + display: flex; + align-items: center; + gap: .15rem; + flex-shrink: 0; +} + +.action-btn { + width: 32px; + height: 32px; + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text3); + transition: background var(--transition), color var(--transition); + font-size: .8rem; +} +.action-btn:hover { background: var(--bg3); color: var(--text); } +.action-btn.active-star { color: var(--star); } +.action-btn.active-archive { color: var(--accent); } +.action-btn.danger:hover { color: var(--danger); background: #fef2f2; } + +[data-theme="dark"] .action-btn.danger:hover { background: #3b1515; } + +/* ── Empty state ───────────────────────────────────────────────────────────── */ +.empty-state { + text-align: center; + padding: 4rem 1rem; + color: var(--text3); +} +.empty-state svg { margin-bottom: 1rem; opacity: .4; } +.empty-state p { font-size: 1rem; } +.empty-state small { font-size: .85rem; } + +/* ── Loading ───────────────────────────────────────────────────────────────── */ +.loading { + text-align: center; + padding: 3rem 1rem; + color: var(--text3); +} +.spinner { + width: 28px; height: 28px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .7s linear infinite; + margin: 0 auto 1rem; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── FAB ───────────────────────────────────────────────────────────────────── */ +.fab { + position: fixed; + bottom: calc(1.5rem + env(safe-area-inset-bottom)); + right: 1.5rem; + width: 52px; + height: 52px; + background: var(--accent); + color: #fff; + border-radius: 50%; + box-shadow: 0 4px 14px rgba(37,99,235,.4); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + transition: background var(--transition), transform var(--transition); + z-index: 200; +} +.fab:hover { background: var(--accent-h); transform: scale(1.05); } +.fab:active { transform: scale(.96); } + +/* ── Modal ─────────────────────────────────────────────────────────────────── */ +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + z-index: 300; + align-items: flex-end; + justify-content: center; + padding-bottom: env(safe-area-inset-bottom); +} +.modal-overlay.open { display: flex; } + +.modal { + background: var(--bg2); + border-radius: 16px 16px 0 0; + padding: 1.5rem 1.25rem; + width: 100%; + max-width: 540px; + animation: slide-up .22s ease; +} + +@media (min-width: 540px) { + .modal-overlay { align-items: center; padding: 1rem; } + .modal { border-radius: 16px; } +} + +@keyframes slide-up { + from { transform: translateY(24px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.modal h2 { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 1rem; +} + +.modal-actions { + display: flex; + gap: .75rem; + margin-top: 1.25rem; +} + +.btn-secondary { + flex: 1; + padding: .6rem 1rem; + background: var(--bg3); + border-radius: var(--radius); + font-size: .9375rem; + font-weight: 500; + text-align: center; + transition: background var(--transition); +} +.btn-secondary:hover { background: var(--border); } + +.btn-primary-sm { + flex: 1; + padding: .6rem 1rem; + background: var(--accent); + color: #fff; + border-radius: var(--radius); + font-size: .9375rem; + font-weight: 600; + text-align: center; + transition: background var(--transition); +} +.btn-primary-sm:hover { background: var(--accent-h); } + +.modal label { + display: block; + font-size: .85rem; + font-weight: 500; + color: var(--text2); + margin-bottom: .4rem; + margin-top: .9rem; +} +.modal label:first-of-type { margin-top: 0; } + +/* ── Toast ─────────────────────────────────────────────────────────────────── */ +.toast-container { + position: fixed; + bottom: calc(5rem + env(safe-area-inset-bottom)); + left: 50%; + transform: translateX(-50%); + z-index: 500; + display: flex; + flex-direction: column; + align-items: center; + gap: .5rem; + pointer-events: none; +} + +.toast { + background: var(--text); + color: var(--bg); + padding: .6rem 1.1rem; + border-radius: 100px; + font-size: .875rem; + font-weight: 500; + white-space: nowrap; + animation: toast-in .2s ease; + pointer-events: auto; +} + +@keyframes toast-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Offline banner ────────────────────────────────────────────────────────── */ +.offline-banner { + display: none; + background: var(--text); + color: var(--bg); + text-align: center; + font-size: .8rem; + padding: .4rem; + font-weight: 500; +} +.offline-banner.show { display: block; } + +/* ── Pagination ────────────────────────────────────────────────────────────── */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: .5rem; + margin-top: 1.25rem; + margin-bottom: 1.5rem; +} + +.page-btn { + padding: .45rem .9rem; + border-radius: 8px; + font-size: .875rem; + font-weight: 500; + background: var(--bg2); + border: 1px solid var(--border); + color: var(--text2); + transition: background var(--transition), color var(--transition); +} +.page-btn:hover:not(:disabled) { background: var(--bg3); color: var(--text); } +.page-btn:disabled { opacity: .4; cursor: default; } +.page-info { font-size: .8rem; color: var(--text3); } diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..bdace25 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,72 @@ +const CACHE_NAME = 'bagg-v1'; +const STATIC_ASSETS = [ + '/login.html', + '/style.css', + '/app.js', + '/manifest.json', +]; + +// ── Install: cache static assets ───────────────────────────────────────────── +self.addEventListener('install', e => { + e.waitUntil( + caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS)) + ); + self.skipWaiting(); +}); + +// ── Activate: clean old caches ──────────────────────────────────────────────── +self.addEventListener('activate', e => { + e.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +// ── Fetch strategy ──────────────────────────────────────────────────────────── +self.addEventListener('fetch', e => { + const { request } = e; + const url = new URL(request.url); + + // Skip non-GET and cross-origin requests + if (request.method !== 'GET' || url.origin !== location.origin) return; + + // API requests: network-first, no cache + if (url.pathname.startsWith('/web/api/') || url.pathname.startsWith('/api/')) { + e.respondWith(fetch(request).catch(() => new Response('{"error":"offline"}', { + headers: { 'Content-Type': 'application/json' } + }))); + return; + } + + // Static assets: cache-first + if (STATIC_ASSETS.includes(url.pathname) || url.pathname.startsWith('/icons/')) { + e.respondWith( + caches.match(request).then(cached => cached || fetch(request).then(res => { + if (res.ok) { + const clone = res.clone(); + caches.open(CACHE_NAME).then(c => c.put(request, clone)); + } + return res; + })) + ); + return; + } + + // Navigation (HTML pages): network-first, fallback to cache + if (request.mode === 'navigate') { + e.respondWith( + fetch(request) + .then(res => { + if (res.ok) { + const clone = res.clone(); + caches.open(CACHE_NAME).then(c => c.put(request, clone)); + } + return res; + }) + .catch(() => caches.match(request).then(cached => cached || caches.match('/login.html'))) + ); + return; + } +}); diff --git a/server.js b/server.js new file mode 100644 index 0000000..45912ff --- /dev/null +++ b/server.js @@ -0,0 +1,297 @@ +'use strict'; + +const express = require('express'); +const path = require('path'); +const crypto = require('crypto'); +const db = require('./db'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// ── Middleware ──────────────────────────────────────────────────────────────── + +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(express.static(path.join(__dirname, 'public'))); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function parseCookies(header = '') { + return Object.fromEntries( + header.split(';') + .map(c => c.trim().split('=')) + .filter(p => p.length === 2) + .map(([k, v]) => [k.trim(), decodeURIComponent(v.trim())]) + ); +} + +function formatEntry(row) { + const tags = db.getTagsForEntry(row.id); + return { + id: row.id, + uid: null, + title: row.title || row.url, + url: row.url, + hashed_url: row.hashed_url, + origin_url: null, + is_archived: row.is_archived, + is_starred: row.is_starred, + content: row.content, + created_at: new Date(row.created_at * 1000).toISOString(), + updated_at: new Date(row.updated_at * 1000).toISOString(), + published_at: row.published_at ? new Date(row.published_at * 1000).toISOString() : null, + published_by: null, + starred_at: row.starred_at ? new Date(row.starred_at * 1000).toISOString() : null, + archived_at: row.archived_at ? new Date(row.archived_at * 1000).toISOString() : null, + annotations: [], + mimetype: null, + language: row.language || null, + reading_time: row.reading_time || 0, + domain_name: row.domain_name || null, + preview_picture: row.preview_picture || null, + tags: tags.map(t => ({ id: t.id, label: t.label, slug: t.slug })), + _links: { self: { href: `/api/v1/entries/${row.id}` } } + }; +} + +// ── API auth middleware ─────────────────────────────────────────────────────── + +function apiAuth(req, res, next) { + const auth = req.headers.authorization || ''; + if (!auth.startsWith('Bearer ')) { + return res.status(401).json({ error: 'unauthorized', error_description: 'Missing or invalid token' }); + } + const tokenData = db.getTokenByAccessToken(auth.slice(7)); + if (!tokenData) { + return res.status(401).json({ error: 'unauthorized', error_description: 'Token invalid or expired' }); + } + req.userId = tokenData.user_id; + next(); +} + +// ── Web session auth middleware ─────────────────────────────────────────────── + +function webAuth(req, res, next) { + const cookies = parseCookies(req.headers.cookie); + const session = cookies.bagg_session ? db.getWebSession(cookies.bagg_session) : null; + if (!session) { + return res.redirect('/login.html'); + } + req.userId = session.user_id; + req.user = db.getUserById(session.user_id); + req.sessionToken = cookies.bagg_session; + next(); +} + +// ── Wallabag API: auth ──────────────────────────────────────────────────────── + +app.get('/api/v1/version', (req, res) => { + res.json({ major: 2, minor: 6, patch: 0 }); +}); + +app.post('/api/v1/oauth/v2/token', (req, res) => { + const { grant_type, username, password, client_id, client_secret, refresh_token } = req.body; + + if (grant_type === 'password') { + const user = username ? db.getUserByUsername(username) : null; + if (!user || !db.verifyPassword(password, user.password_hash, user.salt)) { + return res.status(401).json({ error: 'invalid_credentials', error_description: 'Bad credentials' }); + } + const client = db.getOAuthClient(client_id, client_secret); + if (!client) { + return res.status(401).json({ error: 'invalid_client', error_description: 'Unknown client' }); + } + const access = crypto.randomBytes(32).toString('hex'); + const refresh = crypto.randomBytes(32).toString('hex'); + db.createOAuthToken(access, refresh, user.id); + return res.json({ access_token: access, expires_in: 3600, token_type: 'bearer', scope: 'deposit', refresh_token: refresh }); + } + + if (grant_type === 'refresh_token') { + const old = refresh_token ? db.getTokenByRefreshToken(refresh_token) : null; + if (!old) { + return res.status(401).json({ error: 'invalid_grant', error_description: 'Refresh token not found' }); + } + const { access_token, refresh_token: new_refresh } = db.rotateOAuthTokenFix(old); + return res.json({ access_token, expires_in: 3600, token_type: 'bearer', scope: 'deposit', refresh_token: new_refresh }); + } + + res.status(400).json({ error: 'unsupported_grant_type' }); +}); + +// ── Wallabag API: entries ───────────────────────────────────────────────────── + +app.get('/api/v1/entries', apiAuth, (req, res) => { + const { page = 1, perPage = 30, archive, starred, sort = 'created', order = 'desc' } = req.query; + const { items, total } = db.getEntries(req.userId, { page, perPage, archive, starred, sort, order }); + const pages = Math.max(1, Math.ceil(total / +perPage)); + + res.json({ + _embedded: { items: items.map(formatEntry) }, + _links: { + self: { href: `/api/v1/entries?page=${page}` }, + first: { href: `/api/v1/entries?page=1` }, + last: { href: `/api/v1/entries?page=${pages}` }, + ...(+page < pages ? { next: { href: `/api/v1/entries?page=${+page + 1}` } } : {}), + ...(+page > 1 ? { prev: { href: `/api/v1/entries?page=${+page - 1}` } } : {}) + }, + page: +page, + limit: +perPage, + pages, + total + }); +}); + +// Must be before /:id to avoid "exists" being parsed as an ID +app.get('/api/v1/entries/exists', apiAuth, (req, res) => { + const { url, urls } = req.query; + if (urls) { + // Batch check + const list = Array.isArray(urls) ? urls : [urls]; + const result = {}; + for (const u of list) { + const exists = db.entryExistsByUrl(u, req.userId); + result[u] = exists ? { id: exists.id } : false; + } + return res.json(result); + } + if (!url) return res.status(400).json({ error: 'Missing url parameter' }); + const exists = db.entryExistsByUrl(url, req.userId); + res.json(exists ? { id: exists.id } : false); +}); + +app.get('/api/v1/entries/:id', apiAuth, (req, res) => { + const entry = db.getEntryById(+req.params.id, req.userId); + if (!entry) return res.status(404).json({ error: 'Not Found' }); + res.json(formatEntry(entry)); +}); + +app.post('/api/v1/entries', apiAuth, (req, res) => { + const { url, title, content, tags, archive, starred } = req.body; + if (!url) return res.status(400).json({ error: 'url is required' }); + + const entry = db.createEntry(req.userId, { url, title, content, is_archived: archive, is_starred: starred }); + + // Handle tags + if (tags && Array.isArray(tags)) { + for (const label of tags) { + if (typeof label === 'string' && label.trim()) { + const tag = db.getOrCreateTag(req.userId, label.trim()); + db.addTagToEntry(entry.id, tag.id); + } + } + } + + res.status(200).json(formatEntry(db.getEntryById(entry.id, req.userId))); +}); + +app.patch('/api/v1/entries/:id', apiAuth, (req, res) => { + const updated = db.updateEntry(+req.params.id, req.userId, req.body); + if (!updated) return res.status(404).json({ error: 'Not Found' }); + res.json(formatEntry(updated)); +}); + +app.delete('/api/v1/entries/:id', apiAuth, (req, res) => { + const deleted = db.deleteEntry(+req.params.id, req.userId); + if (!deleted) return res.status(404).json({ error: 'Not Found' }); + res.status(204).send(); +}); + +// ── Wallabag API: tags ──────────────────────────────────────────────────────── + +app.get('/api/v1/tags', apiAuth, (req, res) => { + const tags = db.getAllTags(req.userId); + res.json(tags.map(t => ({ id: t.id, label: t.label, slug: t.slug }))); +}); + +app.post('/api/v1/entry/:id/tags', apiAuth, (req, res) => { + const entry = db.getEntryById(+req.params.id, req.userId); + if (!entry) return res.status(404).json({ error: 'Not Found' }); + const { tags } = req.body; + const created = []; + if (Array.isArray(tags)) { + for (const label of tags) { + if (typeof label === 'string' && label.trim()) { + const tag = db.getOrCreateTag(req.userId, label.trim()); + db.addTagToEntry(entry.id, tag.id); + created.push({ id: tag.id, label: tag.label, slug: tag.slug }); + } + } + } + res.json(created); +}); + +app.delete('/api/v1/entry/:id/tag/:tagId', apiAuth, (req, res) => { + const removed = db.removeTagFromEntry(+req.params.id, +req.params.tagId); + if (!removed) return res.status(404).json({ error: 'Not Found' }); + res.status(204).send(); +}); + +// ── Web UI: auth endpoints ──────────────────────────────────────────────────── + +app.post('/web/login', express.urlencoded({ extended: false }), (req, res) => { + const { username, password } = req.body; + const user = username ? db.getUserByUsername(username) : null; + if (!user || !db.verifyPassword(password, user.password_hash, user.salt)) { + return res.redirect('/login.html?error=1'); + } + const token = db.createWebSession(user.id); + res.setHeader('Set-Cookie', `bagg_session=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${60 * 60 * 24 * 30}`); + res.redirect('/'); +}); + +app.post('/web/logout', webAuth, (req, res) => { + db.deleteWebSession(req.sessionToken); + res.setHeader('Set-Cookie', 'bagg_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0'); + res.redirect('/login.html'); +}); + +// ── Web UI: protected API (for frontend JS) ─────────────────────────────────── + +app.get('/web/api/me', webAuth, (req, res) => { + res.json({ id: req.user.id, username: req.user.username }); +}); + +app.get('/web/api/entries', webAuth, (req, res) => { + const { page = 1, perPage = 50, archive, starred, sort = 'created', order = 'desc' } = req.query; + const { items, total } = db.getEntries(req.userId, { page, perPage, archive, starred, sort, order }); + const pages = Math.max(1, Math.ceil(total / +perPage)); + res.json({ + items: items.map(formatEntry), + page: +page, pages, total, perPage: +perPage + }); +}); + +app.post('/web/api/entries', webAuth, express.json(), (req, res) => { + const { url, title } = req.body; + if (!url) return res.status(400).json({ error: 'url is required' }); + const existing = db.entryExistsByUrl(url, req.userId); + if (existing) return res.status(409).json({ error: 'Already saved', id: existing.id }); + const entry = db.createEntry(req.userId, { url, title }); + res.status(201).json(formatEntry(entry)); +}); + +app.patch('/web/api/entries/:id', webAuth, express.json(), (req, res) => { + const updated = db.updateEntry(+req.params.id, req.userId, req.body); + if (!updated) return res.status(404).json({ error: 'Not Found' }); + res.json(formatEntry(updated)); +}); + +app.delete('/web/api/entries/:id', webAuth, (req, res) => { + const deleted = db.deleteEntry(+req.params.id, req.userId); + if (!deleted) return res.status(404).json({ error: 'Not Found' }); + res.status(204).send(); +}); + +// ── Web UI: serve index for client-side routing ─────────────────────────────── + +app.get('/', webAuth, (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +// ── Start ───────────────────────────────────────────────────────────────────── + +app.listen(PORT, '0.0.0.0', () => { + console.log(`[bagg] listening on :${PORT}`); + console.log(`[bagg] client_id=${process.env.ADMIN_CLIENT_ID || 'bagg_ios'}`); +});