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
This commit is contained in:
bagg-builder 2026-05-02 22:35:27 +00:00
commit 1adf88d195
13 changed files with 1925 additions and 0 deletions

15
.env.example Normal file
View File

@ -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

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
data/
*.sqlite
*.sqlite-wal
*.sqlite-shm
.env

23
Dockerfile Normal file
View File

@ -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"]

383
db.js Normal file
View File

@ -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,
};

36
docker-compose.yml Normal file
View File

@ -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

14
package.json Normal file
View File

@ -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"
}
}

383
public/app.js Normal file
View File

@ -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
? `<img src="${fav}" alt="" loading="lazy" onerror="this.style.display='none'">`
: domain.slice(0, 2).toUpperCase();
return `
<div class="entry-card ${e.is_archived ? 'archived' : ''}" data-id="${e.id}">
<div class="entry-favicon">${favHTML}</div>
<div class="entry-body">
<div class="entry-title"
title="${escHtml(title)}"
onclick="window.open('${escHtml(e.url)}','_blank','noopener')"
style="cursor:pointer"
>${escHtml(title)}</div>
<div class="entry-meta">
${domain ? `<span class="entry-domain">${escHtml(domain)}</span>` : ''}
<span>${timeAgo(e.created_at)}</span>
${e.reading_time > 0 ? `<span>${e.reading_time} min</span>` : ''}
</div>
</div>
<div class="entry-actions">
<button class="action-btn ${e.is_starred ? 'active-star' : ''}"
data-action="star" data-id="${e.id}"
title="${e.is_starred ? 'Unstar' : 'Star'}"
aria-label="${e.is_starred ? 'Unstar' : 'Star'}">
<svg width="15" height="15" fill="${e.is_starred ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
</button>
<button class="action-btn ${e.is_archived ? 'active-archive' : ''}"
data-action="archive" data-id="${e.id}"
title="${e.is_archived ? 'Unarchive' : 'Archive'}"
aria-label="${e.is_archived ? 'Unarchive' : 'Archive'}">
<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>
</svg>
</button>
<button class="action-btn danger"
data-action="delete" data-id="${e.id}"
title="Delete" aria-label="Delete">
<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/>
</svg>
</button>
</div>
</div>`;
}
function renderEntries() {
const container = document.getElementById('entries-container');
if (state.loading) {
container.innerHTML = `<div class="loading"><div class="spinner"></div><p>Loading…</p></div>`;
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 = `
<div class="empty-state">
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
</svg>
<p>${title}</p>
<small>${sub}</small>
</div>`;
return;
}
let html = '<div class="entries-list">' + state.entries.map(entryHTML).join('') + '</div>';
if (state.pages > 1) {
html += `
<div class="pagination">
<button class="page-btn" id="btn-prev" ${state.page <= 1 ? 'disabled' : ''}>Prev</button>
<span class="page-info">${state.page} / ${state.pages}</span>
<button class="page-btn" id="btn-next" ${state.page >= state.pages ? 'disabled' : ''}>Next</button>
</div>`;
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── 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();

89
public/index.html Normal file
View File

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>bagg</title>
<link rel="stylesheet" href="/style.css">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb" id="theme-color-meta">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="bagg">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
</head>
<body>
<div class="offline-banner" id="offline-banner">You're offline — showing cached data</div>
<div id="app">
<nav class="navbar">
<div class="navbar-inner">
<span class="nav-brand">bagg</span>
<button class="nav-btn" id="btn-theme" title="Toggle theme" aria-label="Toggle theme">
<svg id="icon-sun" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg id="icon-moon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
<button class="nav-btn" id="btn-logout" title="Sign out" aria-label="Sign out">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</div>
</nav>
<div class="tabs">
<div class="tabs-inner">
<button class="tab-btn active" data-filter="all">All</button>
<button class="tab-btn" data-filter="unread">Unread</button>
<button class="tab-btn" data-filter="starred">Starred</button>
<button class="tab-btn" data-filter="archived">Archived</button>
</div>
</div>
<main class="content">
<div id="entries-container">
<div class="loading">
<div class="spinner"></div>
<p>Loading…</p>
</div>
</div>
</main>
</div>
<!-- FAB -->
<button class="fab" id="btn-add" title="Save a link" aria-label="Save a link">
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<!-- Add link modal -->
<div class="modal-overlay" id="modal-add" role="dialog" aria-modal="true" aria-label="Save a link">
<div class="modal">
<h2>Save a link</h2>
<label for="input-url">URL</label>
<input
type="url"
id="input-url"
placeholder="https://example.com/article"
inputmode="url"
autocomplete="url"
autocapitalize="none"
autocorrect="off"
>
<label for="input-title">Title <span style="color:var(--text3);font-weight:400">(optional)</span></label>
<input
type="text"
id="input-title"
placeholder="Leave empty to use page title"
>
<div class="modal-actions">
<button class="btn-secondary" id="btn-modal-cancel">Cancel</button>
<button class="btn-primary-sm" id="btn-modal-save">Save</button>
</div>
</div>
</div>
<!-- Toast container -->
<div class="toast-container" id="toasts"></div>
<script src="/app.js"></script>
</body>
</html>

61
public/login.html Normal file
View File

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — bagg</title>
<link rel="stylesheet" href="/style.css">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="bagg">
</head>
<body>
<div class="login-page">
<div class="login-card">
<h1>bagg</h1>
<p class="tagline">Read later, actually.</p>
<div id="error-msg" class="login-error" style="display:none">
Wrong username or password.
</div>
<form method="post" action="/web/login">
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
autocomplete="username"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
required
autofocus
>
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
autocomplete="current-password"
required
>
<button type="submit" class="btn-primary">Sign in</button>
</form>
</div>
</div>
<script>
if (location.search.includes('error=1')) {
document.getElementById('error-msg').style.display = 'block';
}
// Detect dark mode pref stored in localStorage
const theme = localStorage.getItem('bagg_theme');
if (theme) document.documentElement.setAttribute('data-theme', theme);
</script>
</body>
</html>

33
public/manifest.json Normal file
View File

@ -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
}

513
public/style.css Normal file
View File

@ -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); }

72
public/sw.js Normal file
View File

@ -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;
}
});

297
server.js Normal file
View File

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