- 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
298 lines
12 KiB
JavaScript
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'}`);
|
|
});
|