Friends + friends-only visibility + blocking
A fourth visibility level ("Venner") with one-way friendship and
two-way block filtering, plus the table-rebuild migration that drags
older dev DBs forward.
Visibility model:
- Friendship is directional: (owner, friend) means owner has added
friend to their list. Owner's friends-only activities become
visible to friend; friend isn't automatically friends with owner.
- Blocking is also directional at the DB level (blocker, blocked)
but is checked SYMMETRICALLY at visibility-resolution time: once
either user has blocked the other, friends-only content stops
flowing in either direction. Block does NOT affect public or
anonymous content — those are open to anyone by definition.
- "Friends-only" is an access-list visibility, NOT cryptographic.
The server stores the content in plaintext and serves it only to
authorised viewers. This is documented honestly in /personvern.
Schema:
- activities.visibility CHECK gains 'friends' as a fourth value
- friends(owner_id, friend_id, created_at) — composite PK,
self-friending blocked by CHECK
- user_blocks(blocker_id, blocked_id, created_at) — same shape,
blocking-self also blocked
Migration (server/db.ts):
- SQLite can't ALTER a CHECK constraint, so the migration detects
out-of-date DBs by scanning sqlite_master for the literal
"'friends'" in the activities table's CREATE statement
- If absent, rebuilds activities via the standard SQLite
table-copy-drop-rename dance with foreign_keys briefly off
around the transaction, then runs foreign_key_check to confirm
no FKs were left orphaned (activity_tags, activity_hearts,
bookmarks all point at activities). Smoke-tested on the dev DB:
olemd's user row and moderator/admin flags survived.
Server endpoints (server/friends.ts):
GET /api/friends — my outgoing list
GET /api/friends/incoming — who has added ME
POST /api/friends — add by username (idempotent)
DELETE /api/friends/:userId — remove a friend
GET /api/friends/blocks — my blocked-users list
POST /api/friends/blocks — block by user_id (idempotent)
DELETE /api/friends/blocks/:userId — unblock
Add-by-username (not by email): users must set a username to be
findable. Email stays a private contact identifier.
Activity list filter (server/activities.ts): adds two clauses to the
WHERE — own friends-only, and friends-only owned by a user who has
added me AND there's no block in either direction. Single-activity
GET applies the same check.
Frontend:
- ActivityForm.svelte gains the "Venner" option
- ActivityRow.svelte renders a "Venner" badge with a new amber
vis-badge.friends colour (passes contrast in both themes)
- FriendsPanel.svelte: add-by-username form, outgoing, incoming
(with Block button), and blocked (with Unblock button)
- Profile.svelte mounts FriendsPanel between display fields and
Eksporter
- Home.svelte adds a "Venner" section between private and semi
Docs: Personvern.svelte gains a "Venner og blokkering" section
explaining that friends-only is access-list-not-crypto and pointing
the reader at "private" for actually-sensitive content.
26 tests still pass; typecheck clean; build succeeds. Bundle
36.8 KB → 39.1 KB gzipped (FriendsPanel + new server endpoints +
the Personvern prose).
This commit is contained in:
parent
79ce7059c1
commit
f39fe9ed65
14 changed files with 657 additions and 8 deletions
|
|
@ -188,6 +188,7 @@
|
|||
<label for="vis">Synlighet</label>
|
||||
<select id="vis" bind:value={visibility}>
|
||||
<option value="private">Privat (ende-til-ende-kryptert)</option>
|
||||
<option value="friends">Venner (kun de jeg har lagt til)</option>
|
||||
<option value="semi">Halv-offentlig (uten navn)</option>
|
||||
<option value="public">Offentlig (med navn)</option>
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -222,7 +222,13 @@
|
|||
{activity.title}
|
||||
</a>
|
||||
<span class="vis-badge {activity.visibility}">
|
||||
{activity.visibility === 'semi' ? 'Anonym' : 'Offentlig'}
|
||||
{#if activity.visibility === 'semi'}
|
||||
Anonym
|
||||
{:else if activity.visibility === 'friends'}
|
||||
Venner
|
||||
{:else}
|
||||
Offentlig
|
||||
{/if}
|
||||
</span>
|
||||
</h3>
|
||||
{#if activity.description}
|
||||
|
|
|
|||
184
frontend/src/components/FriendsPanel.svelte
Normal file
184
frontend/src/components/FriendsPanel.svelte
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import type { FriendEntry, BlockEntry } from '../../../shared/types';
|
||||
|
||||
/**
|
||||
* Friends + blocks panel, mounted inside Profile. Three lists:
|
||||
* - Outgoing: people I've added (my friends-only audience)
|
||||
* - Incoming: people who've added me (I see their friends-only content)
|
||||
* - Blocked: people I've blocked (mutual block on friends-only flow)
|
||||
*
|
||||
* Friendship is directional. "A added B" doesn't imply "B added A".
|
||||
* Blocks are also directional but the server filter applies them
|
||||
* symmetrically when evaluating friends-only visibility, so blocking is
|
||||
* effectively bidirectional in practice.
|
||||
*/
|
||||
|
||||
let outgoing: FriendEntry[] = $state([]);
|
||||
let incoming: FriendEntry[] = $state([]);
|
||||
let blocked: BlockEntry[] = $state([]);
|
||||
let loadError: string | null = $state(null);
|
||||
let loading = $state(true);
|
||||
|
||||
// Add-friend form
|
||||
let addUsername = $state('');
|
||||
let addBusy = $state(false);
|
||||
let addError: string | null = $state(null);
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
[outgoing, incoming, blocked] = await Promise.all([
|
||||
api.listFriends(),
|
||||
api.listIncomingFriends(),
|
||||
api.listBlocks(),
|
||||
]);
|
||||
} catch {
|
||||
loadError = 'Kunne ikke laste vennelisten.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function displayName(e: { display_name: string | null; username: string | null }): string {
|
||||
return (e.display_name && e.display_name.trim())
|
||||
|| e.username
|
||||
|| '(ukjent)';
|
||||
}
|
||||
|
||||
async function addFriend(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
addError = null;
|
||||
const name = addUsername.trim().toLowerCase();
|
||||
if (!name) {
|
||||
addError = 'Skriv inn et brukernavn.';
|
||||
return;
|
||||
}
|
||||
addBusy = true;
|
||||
try {
|
||||
const added = await api.addFriend({ username: name });
|
||||
// Dedupe: if the friend was already there, the server returned the same
|
||||
// row; replace by user_id.
|
||||
outgoing = [added, ...outgoing.filter((f) => f.user_id !== added.user_id)];
|
||||
addUsername = '';
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
addError = 'Fant ingen bruker med det brukernavnet. (Brukeren må ha satt opp brukernavn for å være søkbar.)';
|
||||
} else if (err instanceof ApiError && err.status === 400) {
|
||||
addError = 'Du kan ikke legge til deg selv.';
|
||||
} else {
|
||||
addError = 'Klarte ikke å legge til venn.';
|
||||
}
|
||||
} finally {
|
||||
addBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFriend(f: FriendEntry) {
|
||||
if (!confirm(`Fjerne ${displayName(f)} fra vennelisten din?`)) return;
|
||||
try {
|
||||
await api.removeFriend(f.user_id);
|
||||
outgoing = outgoing.filter((x) => x.user_id !== f.user_id);
|
||||
} catch {
|
||||
loadError = 'Klarte ikke å fjerne venn.';
|
||||
}
|
||||
}
|
||||
|
||||
async function block(e: FriendEntry) {
|
||||
if (!confirm(`Blokkere ${displayName(e)}? De vil ikke lenger se aktiviteter du deler med venner.`)) return;
|
||||
try {
|
||||
const blockedRow = await api.blockUser(e.user_id);
|
||||
blocked = [blockedRow, ...blocked.filter((b) => b.user_id !== blockedRow.user_id)];
|
||||
} catch {
|
||||
loadError = 'Klarte ikke å blokkere.';
|
||||
}
|
||||
}
|
||||
|
||||
async function unblock(b: BlockEntry) {
|
||||
try {
|
||||
await api.unblockUser(b.user_id);
|
||||
blocked = blocked.filter((x) => x.user_id !== b.user_id);
|
||||
} catch {
|
||||
loadError = 'Klarte ikke å oppheve blokkering.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="card" aria-labelledby="friends-h">
|
||||
<h3 id="friends-h">Venner</h3>
|
||||
<p class="muted">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{#if loadError}<p class="error" role="alert">{loadError}</p>{/if}
|
||||
|
||||
<form onsubmit={addFriend} style="margin-bottom: 1rem;">
|
||||
<label for="add-friend-u">Legg til ved brukernavn</label>
|
||||
<div class="row" style="align-items: stretch;">
|
||||
<input id="add-friend-u" type="text" bind:value={addUsername}
|
||||
placeholder="brukernavn" pattern="[a-z0-9_-]*"
|
||||
autocomplete="off" style="flex: 1;" />
|
||||
<button class="primary" type="submit" disabled={addBusy}>
|
||||
{addBusy ? 'Legger til …' : 'Legg til'}
|
||||
</button>
|
||||
</div>
|
||||
{#if addError}<p class="error" role="alert" style="margin-top: 0.5rem;">{addError}</p>{/if}
|
||||
</form>
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Laster …</p>
|
||||
{:else}
|
||||
<h4 style="margin-bottom: 0.25rem;">Vennene mine ({outgoing.length})</h4>
|
||||
{#if outgoing.length === 0}
|
||||
<p class="muted">Du har ikke lagt til noen ennå.</p>
|
||||
{/if}
|
||||
{#each outgoing as f (f.user_id)}
|
||||
<article class="card" style="margin: 0.4rem 0; padding: 0.5rem 0.75rem;">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<span>
|
||||
{displayName(f)}
|
||||
{#if f.username}<span class="muted">· @{f.username}</span>{/if}
|
||||
</span>
|
||||
<button class="danger" type="button" onclick={() => removeFriend(f)}>Fjern</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
|
||||
<h4 style="margin: 1rem 0 0.25rem;">Har lagt meg til ({incoming.length})</h4>
|
||||
{#if incoming.length === 0}
|
||||
<p class="muted">Ingen har lagt deg til ennå.</p>
|
||||
{/if}
|
||||
{#each incoming as f (f.user_id)}
|
||||
<article class="card" style="margin: 0.4rem 0; padding: 0.5rem 0.75rem;">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<span>
|
||||
{displayName(f)}
|
||||
{#if f.username}<span class="muted">· @{f.username}</span>{/if}
|
||||
</span>
|
||||
<button class="danger" type="button" onclick={() => block(f)}>Blokker</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
|
||||
{#if blocked.length}
|
||||
<h4 style="margin: 1rem 0 0.25rem;">Blokkerte ({blocked.length})</h4>
|
||||
{#each blocked as b (b.user_id)}
|
||||
<article class="card" style="margin: 0.4rem 0; padding: 0.5rem 0.75rem;">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<span>
|
||||
{displayName(b)}
|
||||
{#if b.username}<span class="muted">· @{b.username}</span>{/if}
|
||||
</span>
|
||||
<button type="button" onclick={() => unblock(b)}>Opphev</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
|
@ -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'));
|
||||
</script>
|
||||
|
|
@ -203,6 +204,19 @@
|
|||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if friends.length}
|
||||
<h2>Venner</h2>
|
||||
{#each friends as a (a.id)}
|
||||
<ActivityRow
|
||||
activity={a}
|
||||
privateCleartext={null}
|
||||
onDeleted={onDeleted}
|
||||
onEdit={(act) => (editing = act)}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if semi.length}
|
||||
<h2>Anonyme</h2>
|
||||
{#each semi as a (a.id)}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
serveren faktisk ser av det du skriver.
|
||||
</p>
|
||||
|
||||
<h3>De tre synlighetsnivåene</h3>
|
||||
<h3>De fire synlighetsnivåene</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Privat:</strong> oppføringen krypteres i nettleseren din før den
|
||||
|
|
@ -29,6 +29,13 @@
|
|||
databasen, kan lese innholdet. Bare du kan låse den opp — med passordet
|
||||
ditt eller gjenopprettingskoden.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Venner:</strong> kun synlig for brukere du har lagt til som
|
||||
venner. Dette er en <em>tilgangsliste</em>, ikke kryptografi — innholdet
|
||||
lagres i klartekst hos oss, men vi serverer det bare til de du har valgt.
|
||||
Det betyr at vi (eller noen med databasetilgang) i prinsippet kan lese
|
||||
det, til forskjell fra «privat». Velg «privat» om innholdet er sensitivt.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Anonym (halv-offentlig):</strong> innholdet er synlig for alle,
|
||||
men navnet ditt vises ikke ved siden av. Vi lagrer en intern referanse
|
||||
|
|
@ -42,6 +49,27 @@
|
|||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>Venner og blokkering</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
Hvis noen har lagt deg til som venn, og du heller vil slippe det, kan du
|
||||
<strong>blokkere</strong> 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.
|
||||
</p>
|
||||
|
||||
<h3>Ende-til-ende-kryptering, kort forklart</h3>
|
||||
<p>
|
||||
For private oppføringer skjer all kryptering i nettleseren din. Når du
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</div>
|
||||
</form>
|
||||
|
||||
<FriendsPanel />
|
||||
|
||||
<section class="card" aria-labelledby="exp-h">
|
||||
<h3 id="exp-h">Eksporter</h3>
|
||||
<p class="muted">
|
||||
|
|
|
|||
|
|
@ -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<InviteEntry>('/invites', { method: 'POST' }),
|
||||
cancelInvite: (token: string) =>
|
||||
http<{ ok: true }>(`/invites/${encodeURIComponent(token)}`, { method: 'DELETE' }),
|
||||
|
||||
// --- friends & blocks -----------------------------------------------------
|
||||
listFriends: () => http<FriendEntry[]>('/friends'),
|
||||
listIncomingFriends: () => http<FriendEntry[]>('/friends/incoming'),
|
||||
addFriend: (body: AddByUsernameRequest) =>
|
||||
http<FriendEntry>('/friends', { method: 'POST', body: JSON.stringify(body) }),
|
||||
removeFriend: (userId: string) =>
|
||||
http<{ ok: true }>(`/friends/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
|
||||
listBlocks: () => http<BlockEntry[]>('/friends/blocks'),
|
||||
blockUser: (userId: string) =>
|
||||
http<BlockEntry>('/friends/blocks', { method: 'POST', body: JSON.stringify({ user_id: userId }) }),
|
||||
unblockUser: (userId: string) =>
|
||||
http<{ ok: true }>(`/friends/blocks/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import type {
|
|||
*/
|
||||
export const activitiesRoutes = new Hono<{ Variables: AppVariables }>();
|
||||
|
||||
const VALID_VIS = new Set<Visibility>(['private', 'semi', 'public']);
|
||||
const VALID_VIS = new Set<Visibility>(['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));
|
||||
});
|
||||
|
||||
|
|
|
|||
98
server/db.ts
98
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
|
||||
|
|
|
|||
183
server/friends.ts
Normal file
183
server/friends.ts
Normal file
|
|
@ -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 });
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue