'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'}`); });