bagg/server.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

298 lines
12 KiB
JavaScript

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