[feat](web): serve the SPA and web actions on the single listener
Add the browser edge (contract rev 8.2/9): a Preact/Vite SPA under web/, an embedded placeholder bundle, the /web/* auth and action handlers, and the explicit browser-action allowlist. The SPA and the API share the one plaintext :8080 listener; there is no second port and no peer edge, so main.go also drops the mTLS listener and the identity mounts in the debug compose fall away. /_manifest announces the web block only while the SPA is mounted (honesty rule), always with the single service port. The DSNs move to the shared database's scram-sha-256 credentials, matching rev 9's removal of the PKI layer. Committed web/bun.lock because web/package.json and vite.config.ts are new in this commit. src/web/dist/index.html is a hand-written placeholder, not a generated bundle: it keeps bare `go build`/`go vet` working from a clean checkout, while /web/dist (the real build output) stays gitignored.
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
// Global fetch wrapper for the frozen cookie edge (UX §4.6).
|
||||
// - same-origin relative /web/* URLs only; never sends an auth header
|
||||
// - on 401 from /web/action/* or /web/auth/me: ONE shared (single-flight)
|
||||
// bodyless POST /web/auth/refresh, then retry the original once
|
||||
// - refresh 401 (ERR_REFRESH_*) -> session cleared -> /login?next=...
|
||||
// - refresh 502 -> keep session, transient banner (no retry loop, no redirect)
|
||||
// - error extraction: JSON error.message -> code map -> plain-text map ->
|
||||
// "Something went wrong (HTTP <status>)"
|
||||
|
||||
import { signal } from '@preact/signals';
|
||||
import type { User } from '../types';
|
||||
import { KEYS, readJSON, writeJSON } from './storage';
|
||||
|
||||
// --- Session / profile signals -------------------------------------------------
|
||||
export const profile = signal<{ username: string; email: string } | null>(null);
|
||||
|
||||
// Set when the session is cleared mid-session so the app can redirect to login.
|
||||
export const sessionCleared = signal(false);
|
||||
|
||||
// --- Transient connection banner ------------------------------------------------
|
||||
export type BannerKind = 'error' | 'warn' | 'info';
|
||||
export const banner = signal<{ kind: BannerKind; text: string } | null>(null);
|
||||
|
||||
let bannerTimer: number | undefined;
|
||||
export function showBanner(kind: BannerKind, text: string, autoClearMs = 6000): void {
|
||||
banner.value = { kind, text };
|
||||
if (bannerTimer) window.clearTimeout(bannerTimer);
|
||||
bannerTimer = window.setTimeout(() => {
|
||||
if (banner.value?.text === text) banner.value = null;
|
||||
}, autoClearMs);
|
||||
}
|
||||
export function clearBanner(): void {
|
||||
banner.value = null;
|
||||
}
|
||||
|
||||
// --- ApiError --------------------------------------------------------------------
|
||||
export class ApiError extends Error {
|
||||
readonly status: number | null;
|
||||
readonly code: string | null;
|
||||
readonly offline: boolean;
|
||||
constructor(status: number | null, message: string, code?: string | null, offline = false) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code ?? null;
|
||||
this.offline = offline;
|
||||
}
|
||||
}
|
||||
|
||||
export function isOfflineError(e: unknown): e is ApiError {
|
||||
return e instanceof ApiError && e.offline;
|
||||
}
|
||||
|
||||
const CODE_MAP: Record<string, string> = {
|
||||
ERR_CSRF: 'Request blocked — reload the page and try again',
|
||||
ERR_NOT_FOUND: 'Not found',
|
||||
ERR_METHOD_NOT_ALLOWED: 'Something went wrong (unsupported request)',
|
||||
ERR_INVALID_REQUEST: 'The server rejected the request',
|
||||
ERR_INTERNAL: 'Server error — try again',
|
||||
ERR_AUTH_FAILED: 'Incorrect email or password',
|
||||
ERR_EMAIL_TAKEN: 'An account with this email already exists',
|
||||
ERR_USERNAME_TAKEN: 'That username is taken',
|
||||
ERR_TOKEN_INVALID: 'Please sign in again',
|
||||
ERR_TOKEN_EXPIRED: 'Please sign in again',
|
||||
};
|
||||
|
||||
const TEXT_MAP: Record<string, string> = {
|
||||
'note not found': 'Not found',
|
||||
'missing or invalid note id': 'Not found',
|
||||
'invalid payload': 'The server rejected the request',
|
||||
'action not implemented': 'Something went wrong (unsupported request)',
|
||||
'method not allowed': 'Something went wrong (unsupported request)',
|
||||
};
|
||||
|
||||
export function profileFromUser(user: User): { username: string; email: string } {
|
||||
return { username: user.username, email: user.email };
|
||||
}
|
||||
|
||||
export function cacheProfile(profileValue: { username: string; email: string }): void {
|
||||
writeJSON(KEYS.profile, profileValue);
|
||||
}
|
||||
|
||||
// --- Error extraction --------------------------------------------------------------
|
||||
export async function toApiError(res: Response): Promise<ApiError> {
|
||||
const status = res.status;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = undefined;
|
||||
}
|
||||
if (body && typeof body === 'object') {
|
||||
const err = (body as { error?: unknown }).error;
|
||||
if (err && typeof err === 'object') {
|
||||
const e = err as { code?: unknown; message?: unknown };
|
||||
if (typeof e.message === 'string' && e.message.length > 0) {
|
||||
return new ApiError(status, e.message, typeof e.code === 'string' ? e.code : null);
|
||||
}
|
||||
if (typeof e.code === 'string') {
|
||||
const code = e.code;
|
||||
const msg =
|
||||
CODE_MAP[code] ??
|
||||
(status >= 500 ? CODE_MAP.ERR_INTERNAL : 'Something went wrong');
|
||||
return new ApiError(status, msg, code);
|
||||
}
|
||||
}
|
||||
}
|
||||
// plain text
|
||||
let text = '';
|
||||
try {
|
||||
text = await res.text();
|
||||
} catch {
|
||||
text = '';
|
||||
}
|
||||
const lower = text.trim().toLowerCase();
|
||||
const mapped =
|
||||
TEXT_MAP[lower] ??
|
||||
(status >= 500 ? CODE_MAP.ERR_INTERNAL : `Something went wrong (HTTP ${status})`);
|
||||
return new ApiError(status, mapped);
|
||||
}
|
||||
|
||||
// --- Refresh (single-flight) --------------------------------------------------------
|
||||
export type RefreshResult = 'ok' | 'cleared' | 'transient';
|
||||
let refreshInFlight: Promise<RefreshResult> | null = null;
|
||||
|
||||
async function doRefresh(): Promise<RefreshResult> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch('/web/auth/refresh', { method: 'POST' });
|
||||
} catch {
|
||||
// network error / offline — keep session, do not clear
|
||||
return 'transient';
|
||||
}
|
||||
if (res.status === 200) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data && data.user) {
|
||||
const p = profileFromUser(data.user);
|
||||
profile.value = p;
|
||||
cacheProfile(p);
|
||||
}
|
||||
} catch {
|
||||
/* ignore body parse */
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
if (res.status === 502) {
|
||||
showBanner('warn', 'Connection problem — your session is still active.');
|
||||
return 'transient';
|
||||
}
|
||||
// 401 (ERR_REFRESH_* / ERR_UNAUTHENTICATED) or other — cookies already cleared
|
||||
return 'cleared';
|
||||
}
|
||||
|
||||
export function singleFlightRefresh(): Promise<RefreshResult> {
|
||||
if (refreshInFlight) return refreshInFlight;
|
||||
refreshInFlight = doRefresh().finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
return refreshInFlight;
|
||||
}
|
||||
|
||||
export function handleSessionCleared(nextPath: string): void {
|
||||
sessionCleared.value = true;
|
||||
const target = `/login?next=${encodeURIComponent(nextPath)}`;
|
||||
window.history.replaceState({}, '', target);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
// --- Main fetch wrapper -------------------------------------------------------------
|
||||
export interface FetchOpts extends RequestInit {
|
||||
refreshOn401?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, opts: FetchOpts = {}): Promise<Response> {
|
||||
const { refreshOn401 = true, timeoutMs, ...init } = opts;
|
||||
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||
throw new ApiError(null, 'Network error', null, true);
|
||||
}
|
||||
|
||||
let controller: AbortController | undefined;
|
||||
let timer: number | undefined;
|
||||
if (timeoutMs) {
|
||||
controller = new AbortController();
|
||||
timer = window.setTimeout(() => controller?.abort(), timeoutMs);
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, { ...init, signal: controller?.signal });
|
||||
} catch {
|
||||
if (controller?.signal.aborted) throw new ApiError(null, 'Request timed out', null, true);
|
||||
throw new ApiError(null, 'Network error', null, true);
|
||||
} finally {
|
||||
if (timer) window.clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (res.status === 401 && refreshOn401) {
|
||||
const result = await singleFlightRefresh();
|
||||
if (result === 'ok') {
|
||||
try {
|
||||
res = await fetch(path, init);
|
||||
} catch {
|
||||
throw new ApiError(null, 'Network error', null, true);
|
||||
}
|
||||
} else if (result === 'cleared') {
|
||||
handleSessionCleared(path);
|
||||
throw new ApiError(401, 'Please sign in again', 'ERR_UNAUTHENTICATED');
|
||||
} else {
|
||||
// transient — keep session, surface banner (already shown by refresh)
|
||||
throw new ApiError(null, 'Connection problem — your session is still active.', null, true);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Perform a request and throw ApiError on any non-2xx (also used for 204). */
|
||||
export async function apiRequest<T = Response>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
opts: FetchOpts = {}
|
||||
): Promise<T> {
|
||||
const res = await apiFetch(path, { ...init, ...opts });
|
||||
if (!res.ok) throw await toApiError(res);
|
||||
if (res.status === 204 || res.status === 200 && res.headers.get('content-length') === '0') {
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export function jsonBody(body: unknown, method = 'POST'): RequestInit {
|
||||
return {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Boot / auth gate (UX §3.2).
|
||||
// GET /web/auth/me (8s) -> 200 authed | 401 -> single-flight refresh -> retry
|
||||
// refresh 401 -> anonymous -> /login?next= | network/502 -> "unknown" state
|
||||
// (shell + cached profile + "Can't reach the server." + Retry). Never
|
||||
// auto-redirect to /login on unknown.
|
||||
|
||||
import { signal } from '@preact/signals';
|
||||
import {
|
||||
apiFetch,
|
||||
ApiError,
|
||||
profile,
|
||||
profileFromUser,
|
||||
cacheProfile,
|
||||
singleFlightRefresh,
|
||||
} from './api';
|
||||
import type { AuthState, User } from '../types';
|
||||
import { KEYS, readJSON, removeKey } from './storage';
|
||||
|
||||
export const authState = signal<AuthState>('loading');
|
||||
// Message shown in the shell banner when state === 'unknown'.
|
||||
export const bootError = signal<string | null>(null);
|
||||
// Bump to re-run boot (Retry).
|
||||
export const bootTick = signal(0);
|
||||
|
||||
export function cachedProfileUsername(): string {
|
||||
const p = readJSON<{ username?: string } | null>(KEYS.profile, null);
|
||||
return p?.username ?? '';
|
||||
}
|
||||
|
||||
export function setAuthed(user: User): void {
|
||||
authState.value = 'authed';
|
||||
const p = profileFromUser(user);
|
||||
profile.value = p;
|
||||
cacheProfile(p);
|
||||
}
|
||||
|
||||
export function setAnonymous(): void {
|
||||
authState.value = 'anonymous';
|
||||
// web.profile is kept for the boot shell (only logout clears it).
|
||||
}
|
||||
|
||||
export async function boot(): Promise<void> {
|
||||
authState.value = 'loading';
|
||||
bootError.value = null;
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/web/auth/me', { refreshOn401: false, timeoutMs: 8000 });
|
||||
if (res.status === 200) {
|
||||
const data = (await res.json()) as { user?: User };
|
||||
if (data?.user) {
|
||||
setAuthed(data.user);
|
||||
return;
|
||||
}
|
||||
authState.value = 'unknown';
|
||||
bootError.value = "Can't reach the server.";
|
||||
return;
|
||||
}
|
||||
if (res.status === 401) {
|
||||
const result = await singleFlightRefresh();
|
||||
if (result === 'ok') {
|
||||
const res2 = await apiFetch('/web/auth/me', { refreshOn401: false, timeoutMs: 8000 });
|
||||
if (res2.status === 200) {
|
||||
const data = (await res2.json()) as { user?: User };
|
||||
if (data?.user) {
|
||||
setAuthed(data.user);
|
||||
return;
|
||||
}
|
||||
}
|
||||
authState.value = 'unknown';
|
||||
bootError.value = "Can't reach the server.";
|
||||
return;
|
||||
}
|
||||
if (result === 'cleared') {
|
||||
setAnonymous();
|
||||
return;
|
||||
}
|
||||
// transient
|
||||
authState.value = 'unknown';
|
||||
bootError.value = "Can't reach the server.";
|
||||
return;
|
||||
}
|
||||
// 502 or any other non-200
|
||||
authState.value = 'unknown';
|
||||
bootError.value = "Can't reach the server.";
|
||||
} catch (e) {
|
||||
// network error / timeout / ApiError -> unknown
|
||||
if (e instanceof ApiError) {
|
||||
authState.value = 'unknown';
|
||||
bootError.value = "Can't reach the server.";
|
||||
return;
|
||||
}
|
||||
authState.value = 'unknown';
|
||||
bootError.value = "Can't reach the server.";
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistic sign-out: clear profile, go to /login, fire-and-forget logout. */
|
||||
export function logout(): void {
|
||||
removeKey(KEYS.profile);
|
||||
profile.value = null;
|
||||
authState.value = 'anonymous';
|
||||
const next = '/notes';
|
||||
const target = `/login?next=${encodeURIComponent(next)}`;
|
||||
window.history.replaceState({}, '', target);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
// bodyless fire-and-forget (edge always answers 200)
|
||||
fetch('/web/auth/logout', { method: 'POST' }).catch(() => {
|
||||
/* best effort */
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Per-route editor drafts (UX §3.2 / O3). Keyed by route ("new" or noteId) in
|
||||
// web.drafts. Debounced persistence + restore-with-notice on return.
|
||||
|
||||
import type { Draft } from '../types';
|
||||
import { KEYS, readJSON, writeJSON, removeKey } from './storage';
|
||||
|
||||
type DraftsMap = Record<string, Draft>;
|
||||
|
||||
export function getDraft(key: string): Draft | null {
|
||||
const map = readJSON<DraftsMap>(KEYS.drafts, {});
|
||||
return map[key] ?? null;
|
||||
}
|
||||
|
||||
export function saveDraft(key: string, draft: Omit<Draft, 'savedAt'>): void {
|
||||
const map = readJSON<DraftsMap>(KEYS.drafts, {});
|
||||
map[key] = { ...draft, savedAt: Date.now() };
|
||||
writeJSON(KEYS.drafts, map);
|
||||
}
|
||||
|
||||
export function clearDraft(key: string): void {
|
||||
const map = readJSON<DraftsMap>(KEYS.drafts, {});
|
||||
if (map[key]) {
|
||||
delete map[key];
|
||||
writeJSON(KEYS.drafts, map);
|
||||
}
|
||||
if (Object.keys(map).length === 0) removeKey(KEYS.drafts);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
/** Re-render the caller on an interval (60s tick for relative times). */
|
||||
export function useNow(intervalMs = 60000): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
setNow(Date.now());
|
||||
const id = window.setInterval(() => setNow(Date.now()), intervalMs);
|
||||
const onVis = () => {
|
||||
if (!document.hidden) setNow(Date.now());
|
||||
};
|
||||
const onFocus = () => setNow(Date.now());
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Relative-time formatting, ported verbatim from keepnote-client
|
||||
// HomeScreen.tsx:15-31: "just now", Nm ago, Nh ago, Nd ago, then "Mon d".
|
||||
|
||||
const MONTHS = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
|
||||
export function relativeTime(iso: string | undefined, now: number): string {
|
||||
if (!iso) return '';
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return '';
|
||||
const diff = Math.max(0, now - t);
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
const d = new Date(t);
|
||||
return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
|
||||
}
|
||||
|
||||
// Whitespace-collapse content and truncate at 140 chars + "…".
|
||||
export function snippet(content: string): string {
|
||||
const collapsed = content.replace(/\s+/g, ' ').trim();
|
||||
if (!collapsed) return '';
|
||||
if (collapsed.length <= 140) return collapsed;
|
||||
return collapsed.slice(0, 140) + '…';
|
||||
}
|
||||
|
||||
export function uid(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return 'id-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// localStorage persistence helpers + the frozen key table (UX §3.2).
|
||||
// Keys: settings.theme, notes.cache, notes.sync.pending, notes.sync.lastSynced,
|
||||
// web.profile, web.drafts. No tokens are ever stored (HttpOnly cookies only).
|
||||
|
||||
export const KEYS = {
|
||||
theme: 'settings.theme',
|
||||
cache: 'notes.cache',
|
||||
pending: 'notes.sync.pending',
|
||||
lastSynced: 'notes.sync.lastSynced',
|
||||
profile: 'web.profile',
|
||||
drafts: 'web.drafts',
|
||||
} as const;
|
||||
|
||||
export function readJSON<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw == null) return fallback;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJSON(key: string, value: unknown): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* storage full/unavailable — keep the app functional in-memory */
|
||||
}
|
||||
}
|
||||
|
||||
export function readString(key: string, fallback: string): string {
|
||||
try {
|
||||
const v = localStorage.getItem(key);
|
||||
return v == null ? fallback : v;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeString(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function removeKey(key: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { signal } from '@preact/signals';
|
||||
import { KEYS, readString, writeString } from './storage';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
export const theme = signal<Theme>('light');
|
||||
|
||||
export function initTheme(): void {
|
||||
const stored = readString(KEYS.theme, 'light');
|
||||
const t: Theme = stored === 'dark' ? 'dark' : 'light';
|
||||
theme.value = t;
|
||||
applyTheme(t);
|
||||
}
|
||||
|
||||
export function applyTheme(t: Theme): void {
|
||||
document.documentElement.dataset.theme = t;
|
||||
document.documentElement.style.colorScheme = t;
|
||||
writeString(KEYS.theme, t);
|
||||
}
|
||||
|
||||
export function setTheme(t: Theme): void {
|
||||
theme.value = t;
|
||||
applyTheme(t);
|
||||
}
|
||||
|
||||
export function toggleTheme(): void {
|
||||
setTheme(theme.value === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
Reference in New Issue
Block a user