User profile, activity editing, search, OSM links, moderator role,

opt-in /<username>/list, and a feedback channel

Six related features that touch the user model and activity UX:

1. **User profile** (display_name, username, public_list_enabled).
   New `display_name`, `username` (UNIQUE, slug-shaped), and
   `public_list_enabled` columns. PATCH /api/auth/profile is a partial
   update — pass only the fields you want to change, null to clear.
   MeResponse exposes all three. Display name is shown on public
   activities and in the nav; falls back to the email prefix when
   unset.

2. **Change password from the profile editor.** Existing
   /api/auth/password endpoint surfaced in the new Profile.svelte;
   the local-decrypt failure path on a wrong current password is
   mapped to a clean error.

3. **Edit existing activities.** ActivityForm becomes dual-purpose
   (create or edit). Title, tags, date/time, location, and
   visibility are all editable. Visibility transitions decrypt or
   re-encrypt client-side as needed before PATCH, and the IndexedDB
   private-tag index is kept in sync diff-style.

4. **Search.** A search input on Home filters across visible
   activities. Private rows are searched against their decrypted
   cleartext (decrypted once and memoised via $derived, so the work
   is amortised across keystrokes). Matches across title, tags,
   location label, and (for public) author display name.

5. **OpenStreetMap links.** Each row with a location renders the
   label as an OSM link. Smart: coords if present
   (?mlat=&mlon=&map=15/lat/lng → pinned view), else
   /search?query=. Built with the WHATWG URL constructor so
   Norwegian characters and commas survive.

6. **Moderator role + semi-delete by owner.** New is_moderator
   column on users. Owners always delete their own rows; moderators
   can additionally delete any semi or public activity (private is
   excluded — it's invisible to others, so there's no moderation
   case). README documents the manual promotion via sqlite3.

7. **Opt-in /<username>/list.** New server route
   /api/users/:username/list returns the user's public activities
   when both `username` is set AND `public_list_enabled = 1`. 404
   when either condition fails — same response in both cases so the
   route doesn't leak username existence for users who haven't opted
   in. SPA-side, App.svelte parses window.location.pathname on
   mount; falls back to "/" via history.replaceState after
   authenticating from a deep link.

8. **Feedback channel.** New `feedback` table. POST /api/feedback
   for any authenticated user; GET /api/feedback gated to
   moderators. The Feedback.svelte component is dual-mode — the
   form is universal; the list view auto-loads only for
   moderators. Submitter identity (email + display name) is shown
   to moderators so they can follow up; not exposed to the
   submitter themselves.

Schema migrations land via the existing ensureColumn() helper so
scaffold DBs upgrade cleanly. The username UNIQUE constraint is
applied as a partial unique index (WHERE username IS NOT NULL) so
multiple users with NULL usernames don't collide.

All 26 existing tests still pass; typecheck clean for both
tsconfigs; Vite build succeeds.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 12:44:33 +02:00
commit 6f4c11c7a6
16 changed files with 1152 additions and 107 deletions

View file

@ -36,6 +36,29 @@ interface ActivityRow {
updated_at: number;
}
/**
* Build the public-facing handle for an owner. Prefer `display_name`; fall
* back to the part before the `@` in the email so accounts that haven't set
* one still get something less hostile than a UUID slice. Email itself is
* NOT surfaced that's a contact identifier, not an attribution.
*/
function ownerDisplay(ownerId: string): string {
const row = getDb()
.prepare('SELECT display_name, email FROM users WHERE id = ?')
.get(ownerId) as { display_name: string | null; email: string } | null;
if (!row) return 'ukjent';
if (row.display_name && row.display_name.trim()) return row.display_name;
const at = row.email.indexOf('@');
return at > 0 ? row.email.slice(0, at) : row.email;
}
function isModerator(userId: string): boolean {
const row = getDb()
.prepare('SELECT is_moderator FROM users WHERE id = ?')
.get(userId) as { is_moderator: number | null } | null;
return row?.is_moderator === 1;
}
function b64(b: Uint8Array | null): string | null {
return b === null ? null : Buffer.from(b).toString('base64');
}
@ -78,6 +101,7 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
id: row.id,
visibility: 'public',
owner_id: row.owner_id,
owner_display: ownerDisplay(row.owner_id),
title: row.title ?? '',
tags,
loc_label: row.loc_label,
@ -249,14 +273,23 @@ activitiesRoutes.patch('/:id', requireAuth, async (c) => {
});
// --- DELETE /api/activities/:id ---------------------------------------------
// Authz:
// - private: owner only. Other users can't even see private rows, so
// there's no moderation use-case here.
// - semi/public: owner OR moderator. Moderation is the documented purpose
// of keeping owner_id around on semi (see SECURITY.md).
activitiesRoutes.delete('/:id', requireAuth, (c) => {
const userId = c.get('userId');
const id = c.req.param('id');
const db = getDb();
const existing = db.prepare('SELECT owner_id FROM activities WHERE id = ?').get(id) as
| { owner_id: string } | null;
const existing = db
.prepare('SELECT owner_id, visibility FROM activities WHERE id = ?')
.get(id) as { owner_id: string; visibility: Visibility } | null;
if (!existing) return c.json({ error: 'not_found' }, 404);
if (existing.owner_id !== userId) return c.json({ error: 'forbidden' }, 403);
const isOwner = existing.owner_id === userId;
const canModerate = existing.visibility !== 'private' && isModerator(userId);
if (!isOwner && !canModerate) return c.json({ error: 'forbidden' }, 403);
clearActivityTags(id);
db.prepare('DELETE FROM activities WHERE id = ?').run(id);

View file

@ -12,8 +12,59 @@ import type {
PasswordChangeRequest,
RecoveryCompleteRequest,
MeResponse,
ProfileUpdateRequest,
} from '../shared/types';
const MAX_DISPLAY_NAME = 50;
const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{1,30}$/;
/**
* Build the MeResponse payload from a users row. Centralised so every
* endpoint that returns it (signup, login, /me, profile-update) stays in sync.
*/
function loadMe(userId: string): MeResponse | null {
const row = getDb()
.prepare(`
SELECT id, email, display_name, is_moderator, username, public_list_enabled
FROM users WHERE id = ?
`)
.get(userId) as
| {
id: string; email: string;
display_name: string | null;
is_moderator: number | null;
username: string | null;
public_list_enabled: number | null;
}
| null;
if (!row) return null;
return {
id: row.id,
email: row.email,
display_name: row.display_name,
is_moderator: row.is_moderator === 1,
username: row.username,
public_list_enabled: row.public_list_enabled === 1,
};
}
function normaliseDisplayName(raw: unknown): string | null {
if (raw === null) return null;
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
if (!trimmed) return null;
return trimmed.slice(0, MAX_DISPLAY_NAME);
}
function normaliseUsername(raw: unknown): string | null | 'invalid' {
if (raw === null) return null;
if (typeof raw !== 'string') return 'invalid';
const trimmed = raw.trim().toLowerCase();
if (!trimmed) return null;
if (!USERNAME_RE.test(trimmed)) return 'invalid';
return trimmed;
}
/**
* Auth routes. The server's job is narrow:
* - store the user row (salts + wraps + verifier hash)
@ -107,7 +158,8 @@ authRoutes.post('/signup', async (c) => {
issueSession(c, id);
gcSessions();
const me: MeResponse = { id, email };
const me = loadMe(id);
if (!me) return c.json({ error: 'internal_error' }, 500);
return c.json(me);
});
@ -160,7 +212,8 @@ authRoutes.post('/login', async (c) => {
if (!row || !ok) return c.json({ error: 'invalid_credentials' }, 401);
issueSession(c, row.id);
const me: MeResponse = { id: row.id, email };
const me = loadMe(row.id);
if (!me) return c.json({ error: 'invalid_credentials' }, 401);
return c.json(me);
});
@ -174,14 +227,70 @@ authRoutes.post('/logout', async (c) => {
authRoutes.get('/me', async (c) => {
const userId = currentUserId(c);
if (!userId) return c.json({ error: 'unauthorized' }, 401);
const row = getDb()
.prepare('SELECT id, email FROM users WHERE id = ?')
.get(userId) as { id: string; email: string } | null;
if (!row) {
const me = loadMe(userId);
if (!me) {
clearSession(c);
return c.json({ error: 'unauthorized' }, 401);
}
const me: MeResponse = row;
return c.json(me);
});
// --- PATCH /auth/profile ----------------------------------------------------
// Edit the editable parts of the user profile. All fields are optional so the
// client can update just what changed. Email changes are deliberately out of
// scope (login handle; needs a re-verification flow).
authRoutes.patch('/profile', requireAuth, async (c) => {
const userId = c.get('userId');
const body = (await c.req.json().catch(() => null)) as ProfileUpdateRequest | null;
if (!body) return c.json({ error: 'invalid_json' }, 400);
const db = getDb();
const updates: string[] = [];
const params: (string | number | null)[] = [];
if ('display_name' in body) {
updates.push('display_name = ?');
params.push(normaliseDisplayName(body.display_name));
}
if ('username' in body) {
const next = normaliseUsername(body.username);
if (next === 'invalid') {
return c.json({
error: 'invalid:username',
detail: 'lowercase a-z, 0-9, _ or -; 2-31 characters; must start with a letter or digit',
}, 400);
}
if (next !== null) {
// Pre-check uniqueness so we can return a clear 409 instead of the
// SQLite UNIQUE-constraint error bubbling up as a 500.
const taken = db.prepare(
'SELECT 1 FROM users WHERE username = ? AND id <> ?',
).get(next, userId);
if (taken) return c.json({ error: 'username_taken' }, 409);
}
updates.push('username = ?');
params.push(next);
}
if ('public_list_enabled' in body) {
if (typeof body.public_list_enabled !== 'boolean') {
return c.json({ error: 'invalid:public_list_enabled' }, 400);
}
updates.push('public_list_enabled = ?');
params.push(body.public_list_enabled ? 1 : 0);
}
if (updates.length === 0) {
// No-op update is fine — return current state so the client stays in sync.
const me = loadMe(userId);
if (!me) return c.json({ error: 'internal_error' }, 500);
return c.json(me);
}
params.push(userId);
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
const me = loadMe(userId);
if (!me) return c.json({ error: 'internal_error' }, 500);
return c.json(me);
});

View file

@ -27,6 +27,20 @@ const SCHEMA_STATEMENTS: readonly string[] = [
-- See SECURITY.md § "Recovery flow".
rec_auth_salt BLOB NOT NULL,
rec_auth_verifier_hash TEXT NOT NULL,
-- Display name for public attribution. Nullable: a user without one falls
-- back to a derived handle (email prefix) in serialised responses.
display_name TEXT,
-- Moderator flag. Promoted manually via "sqlite3 ... UPDATE" (see README).
-- Moderators can delete any semi/public activity for moderation.
is_moderator INTEGER NOT NULL DEFAULT 0,
-- Optional public URL slug. When set + opt-in, the user's public
-- activities are reachable at "/<username>/list". Distinct from
-- display_name because URL slugs need uniqueness and shape constraints
-- (lowercase, [a-z0-9_-]).
username TEXT UNIQUE,
-- Opt-in flag: gates whether /<username>/list actually returns data.
-- Defaults to 0 so the route stays opt-in.
public_list_enabled INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS activities (
@ -64,6 +78,15 @@ const SCHEMA_STATEMENTS: readonly string[] = [
)`,
`CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions(expires_at)`,
// Feedback: any logged-in user can submit; moderators read.
`CREATE TABLE IF NOT EXISTS feedback (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('feature','bug')),
body TEXT NOT NULL,
created_at INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS feedback_created_idx ON feedback(created_at DESC)`,
];
const PRAGMAS: readonly string[] = [
@ -112,6 +135,16 @@ export function getDb(): Database {
// Old users will need to re-sign-up to fully use recovery; see CLAUDE.md.
ensureColumn(db, 'users', 'rec_auth_salt', 'BLOB');
ensureColumn(db, 'users', 'rec_auth_verifier_hash', 'TEXT');
// Profile fields. is_moderator gets a default at the SQL level so existing
// rows aren't NULL — that would make the role check (`row.is_moderator === 1`)
// ambiguous.
ensureColumn(db, 'users', 'display_name', 'TEXT');
ensureColumn(db, 'users', 'is_moderator', 'INTEGER NOT NULL DEFAULT 0');
ensureColumn(db, 'users', 'username', 'TEXT');
ensureColumn(db, 'users', 'public_list_enabled', 'INTEGER NOT NULL DEFAULT 0');
// UNIQUE index on username via separate CREATE INDEX so the ALTER TABLE
// migration path works (SQLite can't add UNIQUE via ADD COLUMN).
db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS users_username_idx ON users(username) WHERE username IS NOT NULL').run();
dbInstance = db;
return db;

65
server/feedback.ts Normal file
View file

@ -0,0 +1,65 @@
import { Hono } from 'hono';
import { getDb } from './db';
import { requireAuth, type AppVariables } from './session';
import type { FeedbackSubmitRequest, FeedbackEntry } from '../shared/types';
const MAX_BODY = 4000;
/**
* Feedback endpoints.
*
* POST /api/feedback any authenticated user submits
* GET /api/feedback moderators only, returns the whole queue
*
* Submissions are kept verbatim they're for the moderator's eyes, not for
* display to other users. Moderators see the submitter's email and display
* name so they can follow up.
*/
export const feedbackRoutes = new Hono<{ Variables: AppVariables }>();
function isModerator(userId: string): boolean {
const row = getDb()
.prepare('SELECT is_moderator FROM users WHERE id = ?')
.get(userId) as { is_moderator: number | null } | null;
return row?.is_moderator === 1;
}
feedbackRoutes.post('/', requireAuth, async (c) => {
const userId = c.get('userId');
const body = (await c.req.json().catch(() => null)) as FeedbackSubmitRequest | null;
if (!body) return c.json({ error: 'invalid_json' }, 400);
if (body.kind !== 'feature' && body.kind !== 'bug') {
return c.json({ error: 'invalid:kind' }, 400);
}
if (typeof body.body !== 'string' || !body.body.trim()) {
return c.json({ error: 'missing:body' }, 400);
}
const trimmed = body.body.trim().slice(0, MAX_BODY);
const id = crypto.randomUUID();
getDb()
.prepare(
'INSERT INTO feedback (id, user_id, kind, body, created_at) VALUES (?, ?, ?, ?, ?)',
)
.run(id, userId, body.kind, trimmed, Date.now());
const entry: FeedbackEntry = {
id, kind: body.kind, body: trimmed, created_at: Date.now(),
};
return c.json(entry, 201);
});
feedbackRoutes.get('/', requireAuth, (c) => {
const userId = c.get('userId');
if (!isModerator(userId)) return c.json({ error: 'forbidden' }, 403);
const rows = getDb()
.prepare(`
SELECT f.id, f.kind, f.body, f.created_at,
f.user_id, u.email AS user_email, u.display_name AS user_display
FROM feedback f
JOIN users u ON u.id = f.user_id
ORDER BY f.created_at DESC
`)
.all() as FeedbackEntry[];
return c.json(rows);
});

View file

@ -4,6 +4,8 @@ import { getDb } from './db';
import { authRoutes } from './auth';
import { activitiesRoutes } from './activities';
import { tagsRoutes } from './tags';
import { usersRoutes } from './users';
import { feedbackRoutes } from './feedback';
// Initialise DB up front so the server fails fast on schema problems.
getDb();
@ -23,6 +25,8 @@ app.get('/api/health', (c) =>
app.route('/api/auth', authRoutes);
app.route('/api/activities', activitiesRoutes);
app.route('/api/tags', tagsRoutes);
app.route('/api/users', usersRoutes);
app.route('/api/feedback', feedbackRoutes);
// In production, serve the built Svelte SPA. Hono's bun static helper handles
// asset MIME types; everything else falls through to index.html for SPA routing.

73
server/users.ts Normal file
View file

@ -0,0 +1,73 @@
import { Hono } from 'hono';
import { getDb } from './db';
import { tagsFor } from './tags';
import type { PublicListResponse, ActivityPublic } from '../shared/types';
/**
* Opt-in per-user public lists. A user with both a `username` and
* `public_list_enabled = 1` exposes their public activities at
* `/api/users/:username/list`. Otherwise we 404 the route refuses to leak
* whether a username exists when the list isn't enabled.
*/
export const usersRoutes = new Hono();
interface ActivityRow {
id: string;
owner_id: string;
title: string | null;
scheduled_at: number | null;
loc_label: string | null;
loc_lat: number | null;
loc_lng: number | null;
created_at: number;
updated_at: number;
}
usersRoutes.get('/:username/list', (c) => {
const username = c.req.param('username').toLowerCase();
const db = getDb();
const user = db
.prepare('SELECT id, display_name, public_list_enabled FROM users WHERE username = ?')
.get(username) as
| { id: string; display_name: string | null; public_list_enabled: number | null }
| null;
// Either the user doesn't exist, or they haven't opted in. Same response so
// we don't leak existence.
if (!user || user.public_list_enabled !== 1) {
return c.json({ error: 'not_found' }, 404);
}
const rows = db
.prepare(`
SELECT id, owner_id, title, scheduled_at, loc_label, loc_lat, loc_lng,
created_at, updated_at
FROM activities
WHERE owner_id = ? AND visibility = 'public'
ORDER BY created_at DESC
`)
.all(user.id) as ActivityRow[];
const activities: ActivityPublic[] = rows.map((r) => ({
id: r.id,
visibility: 'public',
owner_id: r.owner_id,
owner_display: user.display_name?.trim() || username,
title: r.title ?? '',
tags: tagsFor(r.id),
loc_label: r.loc_label,
loc_lat: r.loc_lat,
loc_lng: r.loc_lng,
scheduled_at: r.scheduled_at,
created_at: r.created_at,
updated_at: r.updated_at,
}));
const resp: PublicListResponse = {
username,
display_name: user.display_name,
activities,
};
return c.json(resp);
});