diff --git a/frontend/src/components/ActivityForm.svelte b/frontend/src/components/ActivityForm.svelte index ef0ad95..986df24 100644 --- a/frontend/src/components/ActivityForm.svelte +++ b/frontend/src/components/ActivityForm.svelte @@ -188,6 +188,7 @@ diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte index 47f8a61..c0f9276 100644 --- a/frontend/src/components/ActivityRow.svelte +++ b/frontend/src/components/ActivityRow.svelte @@ -222,7 +222,13 @@ {activity.title} - {activity.visibility === 'semi' ? 'Anonym' : 'Offentlig'} + {#if activity.visibility === 'semi'} + Anonym + {:else if activity.visibility === 'friends'} + Venner + {:else} + Offentlig + {/if} {#if activity.description} diff --git a/frontend/src/components/FriendsPanel.svelte b/frontend/src/components/FriendsPanel.svelte new file mode 100644 index 0000000..7985f03 --- /dev/null +++ b/frontend/src/components/FriendsPanel.svelte @@ -0,0 +1,184 @@ + + +
+

Venner

+

+ Vennskap er enveis. Du kan se aktiviteter merket «Venner» fra noen som + har lagt deg til, og motsatt. Brukere må ha satt et brukernavn for å + være søkbare. +

+ + {#if loadError}{/if} + +
+ +
+ + +
+ {#if addError}{/if} +
+ + {#if loading} +

Laster …

+ {:else} +

Vennene mine ({outgoing.length})

+ {#if outgoing.length === 0} +

Du har ikke lagt til noen ennå.

+ {/if} + {#each outgoing as f (f.user_id)} +
+
+ + {displayName(f)} + {#if f.username}· @{f.username}{/if} + + +
+
+ {/each} + +

Har lagt meg til ({incoming.length})

+ {#if incoming.length === 0} +

Ingen har lagt deg til ennå.

+ {/if} + {#each incoming as f (f.user_id)} +
+
+ + {displayName(f)} + {#if f.username}· @{f.username}{/if} + + +
+
+ {/each} + + {#if blocked.length} +

Blokkerte ({blocked.length})

+ {#each blocked as b (b.user_id)} +
+
+ + {displayName(b)} + {#if b.username}· @{b.username}{/if} + + +
+
+ {/each} + {/if} + {/if} +
diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte index 78acccc..37f3b5f 100644 --- a/frontend/src/components/Home.svelte +++ b/frontend/src/components/Home.svelte @@ -114,6 +114,7 @@ : filtered.filter((a) => a.visibility !== 'private' && a.viewer_bookmarked), ); const myPrivate = $derived(filtered.filter((a) => a.visibility === 'private')); + const friends = $derived(filtered.filter((a) => a.visibility === 'friends')); const semi = $derived(filtered.filter((a) => a.visibility === 'semi')); const pub = $derived(filtered.filter((a) => a.visibility === 'public')); @@ -203,6 +204,19 @@ {/each} {/if} + {#if friends.length} +

Venner

+ {#each friends as a (a.id)} + (editing = act)} + onChanged={onChanged} + /> + {/each} + {/if} + {#if semi.length}

Anonyme

{#each semi as a (a.id)} diff --git a/frontend/src/components/Personvern.svelte b/frontend/src/components/Personvern.svelte index 8b6dcb7..d6efa48 100644 --- a/frontend/src/components/Personvern.svelte +++ b/frontend/src/components/Personvern.svelte @@ -21,7 +21,7 @@ serveren faktisk ser av det du skriver.

-

De tre synlighetsnivåene

+

De fire synlighetsnivåene

+

Venner og blokkering

+

+ Vennskap i Vinterliste er enveis. Du kan legge til en bruker som venn + uten at de må godkjenne — det er din egen «hvem får se mine venner-bare- + oppføringer»-liste. Den du legger til, ser i sin tur at du har lagt dem + til, og kan velge å gjøre det samme andre veien om de vil. +

+

+ Brukere må ha satt opp et brukernavn (URL-vennlig kortform) for å være + søkbare. Vi bruker brukernavnet for å finne hverandre — ikke eposten, som + er en kontaktidentifikator vi ikke deler. +

+

+ Hvis noen har lagt deg til som venn, og du heller vil slippe det, kan du + blokkere dem. Blokkering virker symmetrisk på + venner-bare-innhold: når én av dere har blokkert den andre, ser ingen av + dere lenger venner-bare-oppføringer fra den andre. Blokkering påvirker + ikke offentlige eller anonyme oppføringer — det som er åpent for alle, er + åpent for alle. +

+

Ende-til-ende-kryptering, kort forklart

For private oppføringer skjer all kryptering i nettleseren din. Når du diff --git a/frontend/src/components/Profile.svelte b/frontend/src/components/Profile.svelte index 7d584b1..b2f87da 100644 --- a/frontend/src/components/Profile.svelte +++ b/frontend/src/components/Profile.svelte @@ -4,6 +4,7 @@ import { changePassword } from '../lib/auth'; import { session } from '../lib/session.svelte'; import { downloadExport } from '../lib/export'; + import FriendsPanel from './FriendsPanel.svelte'; import type { InviteEntry } from '../../../shared/types'; interface Props { @@ -216,6 +217,8 @@ + +

Eksporter

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3fb4e0a..47d9421 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,6 +6,7 @@ import type { PublicListResponse, FeedbackSubmitRequest, FeedbackEntry, FeedbackUpdateRequest, AdminUser, AdminRoleUpdate, PublicSettings, SettingsUpdateRequest, InviteEntry, + FriendEntry, BlockEntry, AddByUsernameRequest, } from '../../../shared/types'; const BASE = '/api'; @@ -119,4 +120,17 @@ export const api = { createInvite: () => http('/invites', { method: 'POST' }), cancelInvite: (token: string) => http<{ ok: true }>(`/invites/${encodeURIComponent(token)}`, { method: 'DELETE' }), + + // --- friends & blocks ----------------------------------------------------- + listFriends: () => http('/friends'), + listIncomingFriends: () => http('/friends/incoming'), + addFriend: (body: AddByUsernameRequest) => + http('/friends', { method: 'POST', body: JSON.stringify(body) }), + removeFriend: (userId: string) => + http<{ ok: true }>(`/friends/${encodeURIComponent(userId)}`, { method: 'DELETE' }), + listBlocks: () => http('/friends/blocks'), + blockUser: (userId: string) => + http('/friends/blocks', { method: 'POST', body: JSON.stringify({ user_id: userId }) }), + unblockUser: (userId: string) => + http<{ ok: true }>(`/friends/blocks/${encodeURIComponent(userId)}`, { method: 'DELETE' }), }; diff --git a/frontend/src/lib/export.ts b/frontend/src/lib/export.ts index 6c0f9e3..8e7cd79 100644 --- a/frontend/src/lib/export.ts +++ b/frontend/src/lib/export.ts @@ -33,7 +33,7 @@ interface NormalisedRow { tags: string[]; loc_label: string | null; scheduled_at: number | null; - visibility: 'private' | 'semi' | 'public'; + visibility: 'private' | 'semi' | 'public' | 'friends'; } /** Decrypt a private activity into the same shape as semi/public rows. */ diff --git a/frontend/src/styles.css b/frontend/src/styles.css index eba111e..e28ea99 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -192,6 +192,10 @@ nav.top > .row { .vis-badge.private { background: rgba(31,111,235,0.15); color: var(--accent); } .vis-badge.semi { background: rgba(127,127,127,0.18); color: var(--muted); } .vis-badge.public { background: rgba(46,160,67,0.18); color: #2ea043; } +.vis-badge.friends { background: rgba(241,165,40,0.20); color: #b67100; } +@media (prefers-color-scheme: dark) { + .vis-badge.friends { color: #f1a528; } +} .error { color: var(--danger); margin-top: 0.5rem; } diff --git a/server/activities.ts b/server/activities.ts index bf41b7b..f5b79be 100644 --- a/server/activities.ts +++ b/server/activities.ts @@ -20,7 +20,7 @@ import type { */ export const activitiesRoutes = new Hono<{ Variables: AppVariables }>(); -const VALID_VIS = new Set(['private', 'semi', 'public']); +const VALID_VIS = new Set(['private', 'semi', 'public', 'friends']); interface ActivityRow { id: string; @@ -155,6 +155,31 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity { return a; } const attrib = ownerAttribution(row.owner_id); + if (row.visibility === 'friends') { + // Visibility check already happened upstream; we only get here if the + // viewer is allowed to see the row. Attribution is fine because being a + // friend implies knowing the owner. + const attribF = ownerAttribution(row.owner_id); + return { + id: row.id, + visibility: 'friends', + owner_id: row.owner_id, + owner_display: attribF.display, + owner_username: attribF.username, + title: row.title ?? '', + description: row.description, + tags, + loc_label: row.loc_label, + loc_lat: row.loc_lat, + loc_lng: row.loc_lng, + scheduled_at: row.scheduled_at, + heart_count: hearts.count, + viewer_hearted: hearts.hearted, + viewer_bookmarked: bookmarked, + created_at: row.created_at, + updated_at: row.updated_at, + }; + } const a: ActivityPublic = { id: row.id, visibility: 'public', @@ -201,7 +226,8 @@ function validateForVisibility(body: CreateActivityRequest): string | null { // --- GET /api/activities ---------------------------------------------------- // Returns: all public + semi activities (visible to anyone), plus the caller's -// own private activities (if logged in). +// own private activities AND friends-only activities owned by users who have +// added the caller to their friends list (with mutual block-filtering). activitiesRoutes.get('/', (c) => { const viewerId = currentUserId(c); const db = getDb(); @@ -209,8 +235,28 @@ activitiesRoutes.get('/', (c) => { const params: string[] = []; let where = `visibility IN ('public','semi')`; if (viewerId) { + // Own private: where += ` OR (visibility = 'private' AND owner_id = ?)`; params.push(viewerId); + // Own friends-only (always visible to you, even if you don't appear in + // your own friend list — which would be impossible anyway given the + // self-friending CHECK): + where += ` OR (visibility = 'friends' AND owner_id = ?)`; + params.push(viewerId); + // Friends-only owned by someone who has added me, with no block in + // either direction. + where += ` + OR ( + visibility = 'friends' + AND owner_id IN (SELECT owner_id FROM friends WHERE friend_id = ?) + AND NOT EXISTS ( + SELECT 1 FROM user_blocks + WHERE (blocker_id = activities.owner_id AND blocked_id = ?) + OR (blocker_id = ? AND blocked_id = activities.owner_id) + ) + ) + `; + params.push(viewerId, viewerId, viewerId); } const rows = db @@ -225,9 +271,31 @@ activitiesRoutes.get('/:id', (c) => { const viewerId = currentUserId(c); const row = getDb().prepare('SELECT * FROM activities WHERE id = ?').get(c.req.param('id')) as ActivityRow | null; if (!row) return c.json({ error: 'not_found' }, 404); + + // Apply the same visibility rules as the list endpoint. We return 404 + // (not 403) for hidden rows so the endpoint doesn't double as an existence + // oracle for activity ids. if (row.visibility === 'private' && row.owner_id !== viewerId) { return c.json({ error: 'not_found' }, 404); } + if (row.visibility === 'friends') { + if (!viewerId) return c.json({ error: 'not_found' }, 404); + if (row.owner_id !== viewerId) { + const db = getDb(); + const isFriend = !!db + .prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?') + .get(row.owner_id, viewerId); + if (!isFriend) return c.json({ error: 'not_found' }, 404); + const blocked = !!db + .prepare(` + SELECT 1 FROM user_blocks + WHERE (blocker_id = ? AND blocked_id = ?) + OR (blocker_id = ? AND blocked_id = ?) + `) + .get(row.owner_id, viewerId, viewerId, row.owner_id); + if (blocked) return c.json({ error: 'not_found' }, 404); + } + } return c.json(serialize(row, viewerId)); }); diff --git a/server/db.ts b/server/db.ts index 100aa86..97b30ca 100644 --- a/server/db.ts +++ b/server/db.ts @@ -54,7 +54,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [ `CREATE TABLE IF NOT EXISTS activities ( id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), - visibility TEXT NOT NULL CHECK (visibility IN ('private','semi','public')), + -- 'friends' = visible only to the owner's friend list, with blocks filtering + -- in both directions. NOT encrypted server-side (it's an access-list + -- visibility, not a cryptographic one); see /personvern for the trade-off. + -- NOTE: SQLite can't ALTER a CHECK constraint; old scaffold DBs created + -- before this commit will still have the 3-value CHECK and reject + -- friends-only inserts. Drop and recreate the DB for dev, or run a + -- table-rebuild migration for production. + visibility TEXT NOT NULL CHECK (visibility IN ('private','semi','public','friends')), ciphertext BLOB, nonce BLOB, title TEXT, @@ -140,6 +147,32 @@ const SCHEMA_STATEMENTS: readonly string[] = [ claimed_by_user_id TEXT REFERENCES users(id) )`, `CREATE INDEX IF NOT EXISTS invites_inviter_idx ON invites(inviter_user_id)`, + // Friends: one-way edges. (owner_id, friend_id) means owner_id has added + // friend_id to their friend list. owner_id's friends-only activities are + // visible to friend_id (subject to block filtering below). + `CREATE TABLE IF NOT EXISTS friends ( + owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + friend_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + PRIMARY KEY (owner_id, friend_id), + -- Sanity: can't friend yourself. Saves a UI check. + CHECK (owner_id <> friend_id) + )`, + // Lookup-by-friend (the incoming list) needs an index because the PK is + // (owner_id, friend_id) — leftmost column is owner_id, so queries by + // friend_id alone would full-scan without help. + `CREATE INDEX IF NOT EXISTS friends_friend_idx ON friends(friend_id)`, + // User blocks: directional. (blocker_id, blocked_id) means blocker_id has + // blocked blocked_id. Friends-only visibility filters out content in + // either direction (mutual exclusion). + `CREATE TABLE IF NOT EXISTS user_blocks ( + blocker_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + blocked_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + PRIMARY KEY (blocker_id, blocked_id), + CHECK (blocker_id <> blocked_id) + )`, + `CREATE INDEX IF NOT EXISTS user_blocks_blocked_idx ON user_blocks(blocked_id)`, ]; const PRAGMAS: readonly string[] = [ @@ -172,6 +205,65 @@ function ensureColumn(db: Database, table: string, column: string, type: string) db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`).run(); } +/** + * Rebuild the activities table if its CHECK constraint predates 'friends'. + * SQLite cannot ALTER a CHECK constraint, so the only way forward is to copy + * the table into a fresh one with the updated constraint and rename. Data + * preserved; indexes recreated below. No-op if the constraint already + * mentions 'friends'. + */ +function ensureActivitiesCheckIncludesFriends(db: Database): void { + const row = db + .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='activities'") + .get() as { sql: string } | null; + if (!row) return; // table didn't exist; CREATE TABLE just made it with the new check + if (row.sql.includes("'friends'")) return; // already up to date + + // SQLite's recommended table-rebuild dance. foreign_keys=OFF lets us + // drop+rename without cascade-failing on activity_tags / activity_hearts / + // bookmarks. After the rebuild we run foreign_key_check to confirm + // everything still points somewhere real. + db.prepare('PRAGMA foreign_keys = OFF').run(); + const txn = db.transaction(() => { + db.prepare(` + CREATE TABLE activities_new ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + visibility TEXT NOT NULL CHECK (visibility IN ('private','semi','public','friends')), + ciphertext BLOB, + nonce BLOB, + title TEXT, + description TEXT, + scheduled_at INTEGER, + loc_label TEXT, + loc_lat REAL, + loc_lng REAL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `).run(); + db.prepare(` + INSERT INTO activities_new + (id, owner_id, visibility, ciphertext, nonce, title, description, + scheduled_at, loc_label, loc_lat, loc_lng, created_at, updated_at) + SELECT id, owner_id, visibility, ciphertext, nonce, title, description, + scheduled_at, loc_label, loc_lat, loc_lng, created_at, updated_at + FROM activities + `).run(); + db.prepare('DROP TABLE activities').run(); + db.prepare('ALTER TABLE activities_new RENAME TO activities').run(); + db.prepare('CREATE INDEX IF NOT EXISTS activities_visibility_idx ON activities(visibility)').run(); + db.prepare('CREATE INDEX IF NOT EXISTS activities_owner_idx ON activities(owner_id)').run(); + }); + txn(); + // foreign_key_check returns rows for any orphaned FK; should be empty. + const orphans = db.prepare('PRAGMA foreign_key_check').all(); + if (orphans.length > 0) { + throw new Error(`Migration left orphan FKs: ${JSON.stringify(orphans)}`); + } + db.prepare('PRAGMA foreign_keys = ON').run(); +} + export function getDb(): Database { if (dbInstance) return dbInstance; @@ -199,6 +291,10 @@ export function getDb(): Database { ensureColumn(db, 'feedback', 'done_at', 'INTEGER'); ensureColumn(db, 'feedback', 'done_by', 'TEXT'); ensureColumn(db, 'activities', 'description', 'TEXT'); + // Activities visibility CHECK: gained 'friends' as a fourth value. The + // ALTER must rebuild the table, which is expensive — guarded inside the + // helper. + ensureActivitiesCheckIncludesFriends(db); 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 diff --git a/server/friends.ts b/server/friends.ts new file mode 100644 index 0000000..35338ee --- /dev/null +++ b/server/friends.ts @@ -0,0 +1,183 @@ +import { Hono } from 'hono'; +import { getDb } from './db'; +import { requireAuth, type AppVariables } from './session'; +import type { FriendEntry, BlockEntry, AddByUsernameRequest } from '../shared/types'; + +/** + * Friends and blocks. + * + * Friendship is one-way. (owner, friend) means owner has added friend to + * their list; owner's friends-only activities become visible to friend + * (subject to block filtering). Friend doesn't need to reciprocate. + * + * Blocks are also one-way at the DB level, but the visibility filter checks + * blocks in *both directions* — once anyone in a pair blocks the other, + * friends-only content between them stops flowing in either direction. + * + * Discovery: users are added by `username` (the URL slug). A user without a + * username can't be added; this is intentional — opting into a username is + * the "I want to be findable" signal. Documented in /personvern. + */ +export const friendsRoutes = new Hono<{ Variables: AppVariables }>(); + +interface UserLookupRow { + id: string; + username: string | null; + display_name: string | null; +} + +function lookupByUsername(username: string): UserLookupRow | null { + return getDb() + .prepare('SELECT id, username, display_name FROM users WHERE username = ?') + .get(username.trim().toLowerCase()) as UserLookupRow | null; +} + +function rowToEntry(row: { + user_id: string; + username: string | null; + display_name: string | null; + since: number; +}): FriendEntry { + return { + user_id: row.user_id, + username: row.username, + display_name: row.display_name, + since: row.since, + }; +} + +// --- GET /api/friends ------------------------------------------------------ +// My outgoing list (people I've added). +friendsRoutes.get('/', requireAuth, (c) => { + const userId = c.get('userId'); + const rows = getDb() + .prepare(` + SELECT f.friend_id AS user_id, u.username, u.display_name, + f.created_at AS since + FROM friends f + JOIN users u ON u.id = f.friend_id + WHERE f.owner_id = ? + ORDER BY f.created_at DESC + `) + .all(userId) as { user_id: string; username: string | null; display_name: string | null; since: number }[]; + return c.json(rows.map(rowToEntry)); +}); + +// --- GET /api/friends/incoming --------------------------------------------- +// Who has added ME as a friend. +friendsRoutes.get('/incoming', requireAuth, (c) => { + const userId = c.get('userId'); + const rows = getDb() + .prepare(` + SELECT f.owner_id AS user_id, u.username, u.display_name, + f.created_at AS since + FROM friends f + JOIN users u ON u.id = f.owner_id + WHERE f.friend_id = ? + ORDER BY f.created_at DESC + `) + .all(userId) as { user_id: string; username: string | null; display_name: string | null; since: number }[]; + return c.json(rows.map(rowToEntry)); +}); + +// --- POST /api/friends ----------------------------------------------------- +// Add a friend by username. Idempotent — re-adding is a no-op rather than 409. +friendsRoutes.post('/', requireAuth, async (c) => { + const userId = c.get('userId'); + const body = (await c.req.json().catch(() => null)) as AddByUsernameRequest | null; + if (!body || typeof body.username !== 'string' || !body.username.trim()) { + return c.json({ error: 'missing:username' }, 400); + } + + const target = lookupByUsername(body.username); + if (!target) return c.json({ error: 'user_not_found' }, 404); + if (target.id === userId) return c.json({ error: 'cannot_friend_self' }, 400); + + getDb() + .prepare( + 'INSERT OR IGNORE INTO friends (owner_id, friend_id, created_at) VALUES (?, ?, ?)', + ) + .run(userId, target.id, Date.now()); + + const since = (getDb() + .prepare('SELECT created_at FROM friends WHERE owner_id = ? AND friend_id = ?') + .get(userId, target.id) as { created_at: number }).created_at; + + const entry: FriendEntry = { + user_id: target.id, + username: target.username, + display_name: target.display_name, + since, + }; + return c.json(entry, 201); +}); + +// --- DELETE /api/friends/:userId ------------------------------------------- +friendsRoutes.delete('/:userId', requireAuth, (c) => { + const userId = c.get('userId'); + const targetId = c.req.param('userId'); + getDb().prepare('DELETE FROM friends WHERE owner_id = ? AND friend_id = ?').run(userId, targetId); + return c.json({ ok: true }); +}); + +// --- GET /api/friends/blocks ----------------------------------------------- +// My blocked-users list. Routed under /friends so the UI surface stays in +// one place (friends + blocks are part of the same "people I deal with" page). +friendsRoutes.get('/blocks', requireAuth, (c) => { + const userId = c.get('userId'); + const rows = getDb() + .prepare(` + SELECT b.blocked_id AS user_id, u.username, u.display_name, + b.created_at AS since + FROM user_blocks b + JOIN users u ON u.id = b.blocked_id + WHERE b.blocker_id = ? + ORDER BY b.created_at DESC + `) + .all(userId) as { user_id: string; username: string | null; display_name: string | null; since: number }[]; + const entries: BlockEntry[] = rows.map(rowToEntry); + return c.json(entries); +}); + +// --- POST /api/friends/blocks ---------------------------------------------- +// Block by user_id (the incoming list already gives us the id; no username +// dance needed). Block is idempotent. +friendsRoutes.post('/blocks', requireAuth, async (c) => { + const userId = c.get('userId'); + const body = (await c.req.json().catch(() => null)) as { user_id?: string } | null; + if (!body || typeof body.user_id !== 'string' || !body.user_id.trim()) { + return c.json({ error: 'missing:user_id' }, 400); + } + if (body.user_id === userId) return c.json({ error: 'cannot_block_self' }, 400); + + const target = getDb() + .prepare('SELECT id, username, display_name FROM users WHERE id = ?') + .get(body.user_id) as UserLookupRow | null; + if (!target) return c.json({ error: 'user_not_found' }, 404); + + getDb() + .prepare( + 'INSERT OR IGNORE INTO user_blocks (blocker_id, blocked_id, created_at) VALUES (?, ?, ?)', + ) + .run(userId, target.id, Date.now()); + + const since = (getDb() + .prepare('SELECT created_at FROM user_blocks WHERE blocker_id = ? AND blocked_id = ?') + .get(userId, target.id) as { created_at: number }).created_at; + + const entry: BlockEntry = { + user_id: target.id, + username: target.username, + display_name: target.display_name, + since, + }; + return c.json(entry, 201); +}); + +// --- DELETE /api/friends/blocks/:userId ------------------------------------ +friendsRoutes.delete('/blocks/:userId', requireAuth, (c) => { + const userId = c.get('userId'); + const targetId = c.req.param('userId'); + getDb().prepare('DELETE FROM user_blocks WHERE blocker_id = ? AND blocked_id = ?').run(userId, targetId); + return c.json({ ok: true }); +}); diff --git a/server/index.ts b/server/index.ts index ccf3cc4..f34203b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -9,6 +9,7 @@ import { feedbackRoutes } from './feedback'; import { adminRoutes } from './admin'; import { settingsRoutes } from './settings'; import { invitesRoutes } from './invites'; +import { friendsRoutes } from './friends'; // Initialise DB up front so the server fails fast on schema problems. getDb(); @@ -33,6 +34,7 @@ app.route('/api/feedback', feedbackRoutes); app.route('/api/admin', adminRoutes); app.route('/api/settings', settingsRoutes); app.route('/api/invites', invitesRoutes); +app.route('/api/friends', friendsRoutes); // In production, serve the built Svelte SPA. The static helper is registered // for the asset directory and for the top-level files that Vite copies from diff --git a/shared/types.ts b/shared/types.ts index e402b0f..c048884 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -2,7 +2,7 @@ // the SQL schema in server/db.ts and the request/response handlers in // server/{auth,activities,tags}.ts. -export type Visibility = 'private' | 'semi' | 'public'; +export type Visibility = 'private' | 'semi' | 'public' | 'friends'; // --- Auth -------------------------------------------------------------------- export interface SignupRequest { @@ -238,7 +238,53 @@ export interface ActivityPrivate { updated_at: number; } -export type Activity = ActivityPublic | ActivitySemi | ActivityPrivate; +/** + * Friends-only activity. Shape mirrors ActivityPublic — the viewer is + * definitionally a friend of the owner, so attribution is fine. NOT + * encrypted; this is an access-list visibility, not a cryptographic one. + * See /personvern for the trade-off. + */ +export interface ActivityFriends { + id: string; + visibility: 'friends'; + owner_id: string; + owner_display: string | null; + owner_username: string | null; + title: string; + description: string | null; + tags: string[]; + loc_label: string | null; + loc_lat: number | null; + loc_lng: number | null; + scheduled_at: number | null; + heart_count: number; + viewer_hearted: boolean; + viewer_bookmarked: boolean; + created_at: number; + updated_at: number; +} + +export type Activity = ActivityPublic | ActivitySemi | ActivityPrivate | ActivityFriends; + +// --- Friends & blocks ------------------------------------------------------ +/** Both the outgoing-friends list and the incoming-friends list. */ +export interface FriendEntry { + user_id: string; + username: string | null; + display_name: string | null; + since: number; +} + +export interface BlockEntry { + user_id: string; + username: string | null; + display_name: string | null; + since: number; +} + +export interface AddByUsernameRequest { + username: string; +} export interface CreateActivityRequest { visibility: Visibility;