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:
parent
add76be486
commit
6f4c11c7a6
16 changed files with 1152 additions and 107 deletions
|
|
@ -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);
|
||||
|
|
|
|||
123
server/auth.ts
123
server/auth.ts
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
33
server/db.ts
33
server/db.ts
|
|
@ -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
65
server/feedback.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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
73
server/users.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue