Public landing, owner-list links, owner-conditional semi, PWA + mobile
Four related UX/privacy/install changes.
1. **Logged-out lands on the public list.** The root route now shows the
same Home view as a logged-in user, minus their own private rows and
the "Ny aktivitet" button. The nav exposes a "Logg inn" button when
no session is present. Login becomes one click away, not the forced
landing — anyone can browse the public + semi list anonymously.
2. **Public activities link to /<owner_username>/list.** When a public
activity's owner has opted into a public list, the "Lagt til av X"
line renders X as a link to /<username>/list. Server populates
`owner_username` on every public-row serialisation (null when the
owner hasn't opted in, so the client just renders plain text).
3. **Conditional owner_id on semi rows.** The server now serialises
`owner_id` on a semi row ONLY when the viewer IS the owner. The
wire type's `ActivitySemi.owner_id` is therefore optional. This
solves the semi-delete UX without leaking attribution: owners see
Edit/Delete buttons on their own semi rows; non-owners get the same
bare row they got before. The privacy property is enforced at the
API boundary, not in client-side render logic.
4. **Mobile-friendly + installable PWA.**
- `manifest.webmanifest` with name, theme color, standalone display,
and a maskable SVG icon (icon.svg).
- Service worker (sw.js): cache-first for the bundled shell;
network-only for /api/* (we never cache session-dependent or
ciphertext data — see the comment in sw.js for the rationale).
Falls back to the SPA shell for navigation requests when offline.
- SW registered in main.ts only in production builds (import.meta.env.PROD).
- viewport-fit=cover + env(safe-area-inset-*) padding so content
doesn't slip under iOS notches when installed.
- WCAG 2.5.5 touch-target sizing: min-height: 44px on buttons,
with an explicit opt-out for tag-close buttons (24×24 still meets
the 2.5.8 minimum).
- 16px font on form inputs below 480px so iOS doesn't auto-zoom.
Server-side: server/index.ts now serves manifest, icon, and sw.js
from frontend/dist alongside /assets/*. The catch-all still serves
index.html so the SPA's /<username>/list path routing keeps working.
Smoke-tested with a production-mode server: manifest returns the
correct application/manifest+json MIME, SVG renders, sw.js is
loadable, and unknown paths fall through to index.html as expected.
26 tests still pass; both tsconfigs typecheck (frontend now pulls
vite/client types for import.meta.env.PROD); Vite build succeeds.
This commit is contained in:
parent
6f4c11c7a6
commit
f0b4d735b5
15 changed files with 317 additions and 54 deletions
17
README.md
17
README.md
|
|
@ -126,6 +126,23 @@ podman run --replace --name vinterliste \
|
|||
The container exposes `/api/health` for healthchecks and bakes the build date /
|
||||
git revision into both OCI labels and `/etc/build-info`.
|
||||
|
||||
## Installable (PWA) + mobile
|
||||
|
||||
The SPA ships with a web app manifest (`/manifest.webmanifest`), an SVG icon
|
||||
(`/icon.svg`), and a small service worker (`/sw.js`) that caches the bundled
|
||||
shell for offline reads. The API itself is **never** cached — sessions and
|
||||
ciphertexts must come fresh from the server. On supported browsers
|
||||
(Chrome/Edge on Android and desktop, Firefox with the flag) you'll see an
|
||||
"Install" prompt; on iOS you can Add to Home Screen but iOS doesn't render
|
||||
SVG icons, so the home-screen icon will fall back to the page screenshot.
|
||||
|
||||
Layout adapts to small screens via:
|
||||
|
||||
- `viewport` set to `width=device-width, initial-scale=1, viewport-fit=cover`
|
||||
- safe-area insets in `padding` so content doesn't slip under iOS notches
|
||||
- `min-height: 44px` on buttons (WCAG 2.5.5 enhanced touch target)
|
||||
- `font-size: 16px` on inputs below 480px so iOS doesn't auto-zoom
|
||||
|
||||
## Promoting a moderator
|
||||
|
||||
Moderators can delete any `semi` or `public` activity (not `private` — those
|
||||
|
|
|
|||
|
|
@ -2,8 +2,15 @@
|
|||
<html lang="nb">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- viewport-fit=cover lets us paint under iOS notches; the safe-area
|
||||
insets in styles.css take care of keeping content readable. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#1f6feb" />
|
||||
<meta name="description" content="Ende-til-ende-kryptert liste over vinteraktiviteter." />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Vinterliste</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
54
frontend/public/icon.svg
Normal file
54
frontend/public/icon.svg
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Vinterliste">
|
||||
<!-- Background tile, large enough to satisfy `maskable` safe zone (centre 40% padding). -->
|
||||
<rect width="512" height="512" rx="96" fill="#1f6feb"/>
|
||||
|
||||
<!-- Stylised snowflake — 6 arms with side branches, drawn from the centre. -->
|
||||
<g stroke="#ffffff" stroke-width="22" stroke-linecap="round" fill="none">
|
||||
<!-- Six main arms, rotated 60° each. -->
|
||||
<g transform="translate(256 256)">
|
||||
<g>
|
||||
<line x1="0" y1="0" x2="0" y2="-150"/>
|
||||
<line x1="0" y1="-90" x2="-30" y2="-120"/>
|
||||
<line x1="0" y1="-90" x2="30" y2="-120"/>
|
||||
<line x1="0" y1="-50" x2="-22" y2="-72"/>
|
||||
<line x1="0" y1="-50" x2="22" y2="-72"/>
|
||||
</g>
|
||||
<g transform="rotate(60)">
|
||||
<line x1="0" y1="0" x2="0" y2="-150"/>
|
||||
<line x1="0" y1="-90" x2="-30" y2="-120"/>
|
||||
<line x1="0" y1="-90" x2="30" y2="-120"/>
|
||||
<line x1="0" y1="-50" x2="-22" y2="-72"/>
|
||||
<line x1="0" y1="-50" x2="22" y2="-72"/>
|
||||
</g>
|
||||
<g transform="rotate(120)">
|
||||
<line x1="0" y1="0" x2="0" y2="-150"/>
|
||||
<line x1="0" y1="-90" x2="-30" y2="-120"/>
|
||||
<line x1="0" y1="-90" x2="30" y2="-120"/>
|
||||
<line x1="0" y1="-50" x2="-22" y2="-72"/>
|
||||
<line x1="0" y1="-50" x2="22" y2="-72"/>
|
||||
</g>
|
||||
<g transform="rotate(180)">
|
||||
<line x1="0" y1="0" x2="0" y2="-150"/>
|
||||
<line x1="0" y1="-90" x2="-30" y2="-120"/>
|
||||
<line x1="0" y1="-90" x2="30" y2="-120"/>
|
||||
<line x1="0" y1="-50" x2="-22" y2="-72"/>
|
||||
<line x1="0" y1="-50" x2="22" y2="-72"/>
|
||||
</g>
|
||||
<g transform="rotate(240)">
|
||||
<line x1="0" y1="0" x2="0" y2="-150"/>
|
||||
<line x1="0" y1="-90" x2="-30" y2="-120"/>
|
||||
<line x1="0" y1="-90" x2="30" y2="-120"/>
|
||||
<line x1="0" y1="-50" x2="-22" y2="-72"/>
|
||||
<line x1="0" y1="-50" x2="22" y2="-72"/>
|
||||
</g>
|
||||
<g transform="rotate(300)">
|
||||
<line x1="0" y1="0" x2="0" y2="-150"/>
|
||||
<line x1="0" y1="-90" x2="-30" y2="-120"/>
|
||||
<line x1="0" y1="-90" x2="30" y2="-120"/>
|
||||
<line x1="0" y1="-50" x2="-22" y2="-72"/>
|
||||
<line x1="0" y1="-50" x2="22" y2="-72"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
20
frontend/public/manifest.webmanifest
Normal file
20
frontend/public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "Vinterliste",
|
||||
"short_name": "Vinterliste",
|
||||
"description": "Ende-til-ende-kryptert liste over vinteraktiviteter",
|
||||
"lang": "nb-NO",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#161616",
|
||||
"theme_color": "#1f6feb",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
73
frontend/public/sw.js
Normal file
73
frontend/public/sw.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// Vinterliste service worker.
|
||||
//
|
||||
// Two strategies:
|
||||
// - Static assets (the bundled JS/CSS under /assets/, plus the SPA shell):
|
||||
// cache-first. The bundle filenames are hashed by Vite, so a new deploy
|
||||
// ships under a new URL and gets fetched fresh; old entries are pruned
|
||||
// when the SW version (CACHE_NAME) bumps.
|
||||
// - API calls (/api/*): network-only. We never cache responses that depend
|
||||
// on the caller's session — that'd be a privacy disaster for an E2E app.
|
||||
// Letting them fail with a network error when offline is the right shape:
|
||||
// the UI shows the existing "Kunne ikke laste …" path.
|
||||
//
|
||||
// IMPORTANT: never cache responses that contain ciphertext or any decrypted
|
||||
// payload. Private activity ciphertexts are technically safe to cache (they
|
||||
// require the in-memory DEK to read), but the simpler and safer rule is "no
|
||||
// API caching at all" so we don't accidentally land in trouble later.
|
||||
|
||||
const CACHE_NAME = 'vinterliste-v1';
|
||||
const SHELL_PATHS = ['/', '/index.html', '/icon.svg', '/favicon.svg', '/manifest.webmanifest'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_PATHS)).catch(() => null),
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))),
|
||||
),
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Same-origin only — never touch third-party requests.
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// API: network-only. Don't cache session-dependent data.
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
|
||||
// Everything else: cache-first with network fallback that backfills the cache.
|
||||
event.respondWith(
|
||||
caches.match(req).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(req)
|
||||
.then((res) => {
|
||||
// Only cache successful, basic (same-origin) responses.
|
||||
if (res && res.status === 200 && res.type === 'basic') {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => null);
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() => {
|
||||
// Offline + nothing cached. For navigation requests, fall back to the
|
||||
// SPA shell so the user at least gets a "you're offline" frame the
|
||||
// app can render. For other requests, let the failure surface.
|
||||
if (req.mode === 'navigate') {
|
||||
return caches.match('/index.html');
|
||||
}
|
||||
throw new Error('offline');
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -45,13 +45,15 @@
|
|||
try {
|
||||
const me = await api.me();
|
||||
// We have an active server session but no DEK — the user reloaded the
|
||||
// page. Force them through the login screen so we can re-unlock.
|
||||
// page. Drop the stale server session and pre-fill their email on the
|
||||
// login form, but otherwise show the same public landing as any other
|
||||
// logged-out visitor. They can hit "Logg inn" when they want to unlock.
|
||||
defaultEmail = me.email;
|
||||
await api.logout(); // drop the stale server session
|
||||
view = 'login';
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) view = 'login';
|
||||
else view = 'login';
|
||||
await api.logout();
|
||||
view = 'home';
|
||||
} catch {
|
||||
// No session (or expired). Show the public landing.
|
||||
view = 'home';
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -77,17 +79,23 @@
|
|||
<main>
|
||||
<nav class="top">
|
||||
<h1 style="margin: 0;">Vinterliste</h1>
|
||||
{#if session.user && view !== 'public-list'}
|
||||
{#if view !== 'public-list'}
|
||||
<div class="row">
|
||||
<button type="button" onclick={() => (view = 'feedback')}
|
||||
aria-label="Send tilbakemelding">
|
||||
Tilbakemelding
|
||||
</button>
|
||||
<button type="button" onclick={() => (view = 'profile')}
|
||||
aria-label="Profil">
|
||||
{session.user.display_name?.trim() || session.user.email}
|
||||
</button>
|
||||
<button onclick={onLogout}>Logg ut</button>
|
||||
{#if session.user}
|
||||
<button type="button" onclick={() => (view = 'feedback')}
|
||||
aria-label="Send tilbakemelding">
|
||||
Tilbakemelding
|
||||
</button>
|
||||
<button type="button" onclick={() => (view = 'profile')}
|
||||
aria-label="Profil">
|
||||
{session.user.display_name?.trim() || session.user.email}
|
||||
</button>
|
||||
<button onclick={onLogout}>Logg ut</button>
|
||||
{:else if view !== 'login' && view !== 'signup' && view !== 'recovery'}
|
||||
<button class="primary" type="button" onclick={() => (view = 'login')}>
|
||||
Logg inn
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -68,20 +68,17 @@
|
|||
|
||||
// Authz mirrors server/activities.ts:
|
||||
// - private: owner-only (server only returns yours anyway)
|
||||
// - public: owner OR moderator
|
||||
// - semi: owner OR moderator (but UI can't tell ownership from the
|
||||
// row because owner_id isn't serialised; we expose Delete only
|
||||
// to moderators; owners must use server-side ownership check
|
||||
// when they hit it — but in this UI we can't show a per-row
|
||||
// "you own this" hint without leaking. Acceptable tradeoff
|
||||
// for now; semi-owner-delete still works via Edit path that
|
||||
// the server authorises).
|
||||
// - public: owner if session.user.id === owner_id
|
||||
// - semi: owner if the server included owner_id in this row — the
|
||||
// server only sends it to the owner, so its presence is the
|
||||
// ownership signal. Anyone else sees no owner_id and no buttons.
|
||||
// - moderators can delete (but not edit) any non-private row.
|
||||
const isOwner = $derived(
|
||||
activity.visibility === 'public'
|
||||
? session.user?.id === activity.owner_id
|
||||
: activity.visibility === 'private'
|
||||
? true
|
||||
: false,
|
||||
activity.visibility === 'private'
|
||||
? true
|
||||
: activity.visibility === 'public'
|
||||
? session.user?.id === activity.owner_id
|
||||
: 'owner_id' in activity && activity.owner_id === session.user?.id,
|
||||
);
|
||||
const isModerator = $derived(session.user?.is_moderator === true);
|
||||
const canEdit = $derived(isOwner); // editing always requires ownership
|
||||
|
|
@ -156,7 +153,14 @@
|
|||
{/if}
|
||||
{#if activity.scheduled_at}<p class="muted">🕒 {formatDate(activity.scheduled_at)}</p>{/if}
|
||||
{#if activity.visibility === 'public'}
|
||||
<p class="muted" style="font-size: 0.8rem;">Lagt til av {activity.owner_display}</p>
|
||||
<p class="muted" style="font-size: 0.8rem;">
|
||||
Lagt til av
|
||||
{#if activity.owner_username}
|
||||
<a href={`/${activity.owner_username}/list`}>{activity.owner_display}</a>
|
||||
{:else}
|
||||
{activity.owner_display}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,12 +93,19 @@
|
|||
|
||||
<section aria-label="Aktiviteter">
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
|
||||
<p class="muted" style="margin: 0;">
|
||||
Velkommen, {session.user?.display_name?.trim() || session.user?.email}.
|
||||
Her er aktivitetene dine for vinteren.
|
||||
</p>
|
||||
{#if !showForm && !editing}
|
||||
<button class="primary" onclick={() => (showForm = true)}>Ny aktivitet</button>
|
||||
{#if session.user}
|
||||
<p class="muted" style="margin: 0;">
|
||||
Velkommen, {session.user.display_name?.trim() || session.user.email}.
|
||||
Her er aktivitetene dine for vinteren.
|
||||
</p>
|
||||
{#if !showForm && !editing}
|
||||
<button class="primary" onclick={() => (showForm = true)}>Ny aktivitet</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="muted" style="margin: 0;">
|
||||
Offentlige og halv-offentlige aktiviteter. Logg inn for å legge til
|
||||
dine egne — privat eller offentlig.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,3 +6,15 @@ const target = document.getElementById('app');
|
|||
if (!target) throw new Error('No #app element in DOM');
|
||||
|
||||
mount(App, { target });
|
||||
|
||||
// Register the service worker so the SPA is installable + cached for offline
|
||||
// reads of the shell. Production only — Vite's dev server already does its
|
||||
// own SW dance and registering ours would step on hot module reload. Errors
|
||||
// are swallowed: the SW is an enhancement, not a requirement for the app.
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// SW unavailable (file://, private mode in some browsers, etc.). Ignore.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,12 +32,17 @@ html, body {
|
|||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
min-height: 100vh;
|
||||
/* iOS double-tap zoom is annoying for an app like this; the meta viewport
|
||||
handles the rest. */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 4rem;
|
||||
/* Use env() for safe-area insets so content doesn't slip under iOS notches
|
||||
or under Android gesture areas when installed as a PWA. */
|
||||
padding: 1.5rem max(1rem, env(safe-area-inset-right)) calc(4rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
h1, h2, h3 { line-height: 1.2; margin-top: 0; }
|
||||
|
|
@ -68,6 +73,9 @@ input:focus-visible, button:focus-visible, select:focus-visible, textarea:focus-
|
|||
|
||||
button {
|
||||
cursor: pointer;
|
||||
/* WCAG 2.5.5 enhanced target size is 44×44 CSS px; we hit that with the
|
||||
vertical padding + line-height. Don't shrink below this on small viewports. */
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -117,6 +125,14 @@ label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 500; }
|
|||
margin: 0.15rem 0.2rem 0.15rem 0;
|
||||
}
|
||||
.tag.private { background: rgba(31,111,235,0.15); }
|
||||
/* Tag-close buttons opt out of the 44px touch target — they're 24×24 (WCAG
|
||||
2.5.8 minimum) which is still large enough to tap reliably and keeps the
|
||||
pill from ballooning. The button itself remains keyboard-accessible. */
|
||||
.tag button {
|
||||
min-height: 24px;
|
||||
min-width: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.recovery-code {
|
||||
display: block;
|
||||
|
|
@ -137,6 +153,24 @@ nav.top {
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
nav.top > .row {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Narrow phones: drop the nav-button gap a little and let the title shrink. */
|
||||
@media (max-width: 480px) {
|
||||
main { padding-top: 1rem; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
nav.top h1 { flex: 1 1 100%; }
|
||||
/* Inputs are wide by default; just make sure they don't overflow on tiny screens. */
|
||||
input[type="text"], input[type="email"], input[type="password"],
|
||||
input[type="datetime-local"], input[type="search"], textarea, select {
|
||||
font-size: 16px; /* prevents iOS from auto-zooming on focus */
|
||||
}
|
||||
}
|
||||
|
||||
.vis-badge {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"allowImportingTsExtensions": false,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"types": []
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*", "../shared/**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
|
|
|
|||
|
|
@ -41,15 +41,24 @@ interface ActivityRow {
|
|||
* 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.
|
||||
*
|
||||
* Also returns the user's URL slug if they've opted into a public list; the
|
||||
* client uses that to link the attribution to /<username>/list. Returns null
|
||||
* for the slug whenever the user hasn't opted in, so the link decision is
|
||||
* purely server-side.
|
||||
*/
|
||||
function ownerDisplay(ownerId: string): string {
|
||||
function ownerAttribution(ownerId: string): { display: string; username: string | null } {
|
||||
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;
|
||||
.prepare('SELECT display_name, email, username, public_list_enabled FROM users WHERE id = ?')
|
||||
.get(ownerId) as
|
||||
| { display_name: string | null; email: string; username: string | null; public_list_enabled: number | null }
|
||||
| null;
|
||||
if (!row) return { display: 'ukjent', username: null };
|
||||
const display = (row.display_name && row.display_name.trim())
|
||||
? row.display_name
|
||||
: (row.email.indexOf('@') > 0 ? row.email.slice(0, row.email.indexOf('@')) : row.email);
|
||||
const username = row.public_list_enabled === 1 ? row.username : null;
|
||||
return { display, username };
|
||||
}
|
||||
|
||||
function isModerator(userId: string): boolean {
|
||||
|
|
@ -82,7 +91,9 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
|
|||
}
|
||||
const tags = tagsFor(row.id);
|
||||
if (row.visibility === 'semi') {
|
||||
// owner_id deliberately omitted — see SECURITY.md
|
||||
// owner_id is included ONLY when the viewer IS the owner — that lets the
|
||||
// client render Edit/Delete on the user's own semi rows without leaking
|
||||
// attribution to anyone else. See SECURITY.md.
|
||||
const a: ActivitySemi = {
|
||||
id: row.id,
|
||||
visibility: 'semi',
|
||||
|
|
@ -95,13 +106,16 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
|
|||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
if (viewerId === row.owner_id) a.owner_id = row.owner_id;
|
||||
return a;
|
||||
}
|
||||
const attrib = ownerAttribution(row.owner_id);
|
||||
const a: ActivityPublic = {
|
||||
id: row.id,
|
||||
visibility: 'public',
|
||||
owner_id: row.owner_id,
|
||||
owner_display: ownerDisplay(row.owner_id),
|
||||
owner_display: attrib.display,
|
||||
owner_username: attrib.username,
|
||||
title: row.title ?? '',
|
||||
tags,
|
||||
loc_label: row.loc_label,
|
||||
|
|
|
|||
|
|
@ -28,11 +28,15 @@ 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.
|
||||
// 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
|
||||
// `frontend/public/` (manifest, icons, service worker). Unknown paths fall
|
||||
// through to index.html so the SPA's path routing (/<username>/list) works.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use('/assets/*', serveStatic({ root: './frontend/dist' }));
|
||||
app.get('/favicon.svg', serveStatic({ path: './frontend/dist/favicon.svg' }));
|
||||
for (const file of ['favicon.svg', 'icon.svg', 'manifest.webmanifest', 'sw.js']) {
|
||||
app.get(`/${file}`, serveStatic({ path: `./frontend/dist/${file}` }));
|
||||
}
|
||||
app.get('*', serveStatic({ path: './frontend/dist/index.html' }));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ usersRoutes.get('/:username/list', (c) => {
|
|||
visibility: 'public',
|
||||
owner_id: r.owner_id,
|
||||
owner_display: user.display_name?.trim() || username,
|
||||
// The list itself is at /<username>/list, so we already know the slug.
|
||||
// Surfacing it on each row keeps ActivityRow's rendering uniform.
|
||||
owner_username: username,
|
||||
title: r.title ?? '',
|
||||
tags: tagsFor(r.id),
|
||||
loc_label: r.loc_label,
|
||||
|
|
|
|||
|
|
@ -115,8 +115,11 @@ export interface FeedbackEntry {
|
|||
export interface ActivityPublic {
|
||||
id: string;
|
||||
visibility: 'public';
|
||||
owner_id: string; // serialized for public
|
||||
owner_display: string; // display_name OR derived handle (email prefix)
|
||||
owner_id: string; // serialized for public
|
||||
owner_display: string; // display_name OR derived handle (email prefix)
|
||||
// Owner's URL slug, if they've opted into a public list. When non-null, the
|
||||
// client renders the owner attribution as a link to /<owner_username>/list.
|
||||
owner_username: string | null;
|
||||
title: string;
|
||||
tags: string[];
|
||||
loc_label: string | null;
|
||||
|
|
@ -130,7 +133,10 @@ export interface ActivityPublic {
|
|||
export interface ActivitySemi {
|
||||
id: string;
|
||||
visibility: 'semi';
|
||||
// owner_id deliberately omitted — see SECURITY.md
|
||||
// Set ONLY when the viewer is the owner. Lets the client surface
|
||||
// Edit/Delete on the user's own semi rows without leaking attribution to
|
||||
// anyone else. Stripped server-side for any other viewer; see SECURITY.md.
|
||||
owner_id?: string;
|
||||
title: string;
|
||||
tags: string[];
|
||||
loc_label: string | null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue