Merge pull request #107 from kristianwind/app-base
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 16m41s
CI / Docker image builds (push) Successful in 2m50s
CI / Go (push) Failing after 26m54s

feat(app): fladen kan tale med en valgfri server, med sessionen som bearer
This commit is contained in:
Kristian Wind
2026-10-23 14:28:20 +02:00
committed by GitHub
20 changed files with 728 additions and 23 deletions
+1
View File
@@ -251,6 +251,7 @@ to put one that the operator would ever see.
| `VERDANDE_ADDR` | `:8080` | Listen address. |
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
| `VERDANDE_SMTP_HOST` | — | Blank disables outbound mail; invite links are shown in the app instead. |
+3
View File
@@ -37,11 +37,14 @@ straight off the internet is keyed on the address it really came from.
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
Any Go duration: `720h`, `30m`, `45s`.
`VERDANDE_APP_ORIGINS` (comma-separated) counts further origins as the iOS app's own, beside `capacitor://localhost`. It exists for the end-to-end suite, which runs the interface from a second host name to test it the way the app does; an instance has no reason to set it.
## Mail
| Variable | Default | What it does |
+11
View File
@@ -69,6 +69,12 @@ type Config struct {
// the deal its operator made by self-hosting.
UpdateCheck bool
// AppOrigins are origins treated like the iOS app's own, capacitor://localhost,
// which is always included. Set by the end-to-end suite, which serves the
// interface from a second host name to test it the way the app runs it. An
// operator has no reason to set it.
AppOrigins []string
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
// from its own interface rather than from another browser tab.
//
@@ -165,6 +171,11 @@ func Load() (*Config, error) {
Edition: env("VERDANDE_EDITION", EditionFull),
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
}
for _, o := range strings.Split(env("VERDANDE_APP_ORIGINS", ""), ",") {
if o = strings.TrimRight(strings.TrimSpace(o), "/"); o != "" {
c.AppOrigins = append(c.AppOrigins, o)
}
}
var err error
if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil {
+22
View File
@@ -8,6 +8,8 @@ import (
"time"
"github.com/coder/websocket"
"github.com/kristianwind/verdande/internal/config"
)
func (ts *testServer) corsDo(t *testing.T, method, path, origin string, header map[string]string) *http.Response {
@@ -200,3 +202,23 @@ func TestACookieSocketStillChecksTheOrigin(t *testing.T) {
t.Errorf("control: the instance's own origin: %v", err)
}
}
// The end-to-end suite runs the interface from a second host name and tells the
// server to count it as the app's. That origin is then the app's in every
// respect, and capacitor://localhost stays one too.
func TestConfiguredAppOriginsCountAsTheApp(t *testing.T) {
ts := newTestServerWith(t, func(c *config.Config) {
c.AppOrigins = []string{"http://app.localhost:8097"}
})
for _, origin := range []string{"http://app.localhost:8097", "capacitor://localhost"} {
resp := ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
t.Errorf("%s: Allow-Origin %q", origin, got)
}
}
// Not a prefix match: a neighbour on another port is nobody.
resp := ts.corsDo(t, "GET", "/api/v1/ping", "http://app.localhost:8098", nil)
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("a neighbouring origin was answered: %q", got)
}
}
+3 -3
View File
@@ -23,10 +23,10 @@ import (
// something this header ever needs to unlock. Every other origin gets no CORS
// headers at all, exactly as before — the browser then keeps a foreign page from
// reading anything, which is the default this must not loosen.
func appCORS(next http.Handler) http.Handler {
func (s *Server) appCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if !isAppOrigin(origin) {
if !s.isAppOrigin(origin) {
next.ServeHTTP(w, r)
return
}
@@ -129,7 +129,7 @@ func (s *Server) handleWebSocketEntry(w http.ResponseWriter, r *http.Request) {
// ticket is not the app. Refused here rather than left to the origin
// check, which compares host names only: capacitor://localhost and an
// instance served at http://localhost have the same one.
if isAppOrigin(r.Header.Get("Origin")) {
if s.isAppOrigin(r.Header.Get("Origin")) {
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "the app opens this socket with a ticket")
return
}
+10 -2
View File
@@ -222,7 +222,7 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
// this Origin — the browser writes the header, not the page — and an app
// login sets no cookie, so there is no session here for a forged request
// to ride on.
if isAppOrigin(r.Header.Get("Origin")) {
if s.isAppOrigin(r.Header.Get("Origin")) {
next.ServeHTTP(w, r)
return
}
@@ -251,12 +251,20 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
// makes to a server arrives from here, whichever server it is.
var appOrigins = []string{"capacitor://localhost"}
func isAppOrigin(origin string) bool {
func (s *Server) isAppOrigin(origin string) bool {
if origin == "" {
return false
}
for _, o := range appOrigins {
if origin == o {
return true
}
}
for _, o := range s.cfg.AppOrigins {
if origin == o {
return true
}
}
return false
}
+1 -1
View File
@@ -279,7 +279,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Route("/api/v1", func(r chi.Router) {
// Before the CSRF lock, so a preflight is answered and never reaches it.
r.Use(appCORS)
r.Use(s.appCORS)
r.Use(s.requireCSRF)
r.Get("/ping", s.handlePing)
+219
View File
@@ -0,0 +1,219 @@
import { test, expect } from '@playwright/test';
import { USER } from './user.js';
/**
* The interface the way the iOS app runs it: from a different origin than the
* server, with no cookie, its session as a bearer token.
*
* The real app is served from capacitor://localhost, which a test browser cannot
* open. `app.localhost` stands in for it — Chromium sends every *.localhost to
* the loopback address, so it is this same server, but to the browser it is a
* different origin, and that difference is the whole thing under test. The server
* is told to treat it as the app's origin (VERDANDE_APP_ORIGINS in the config),
* and a stub of Capacitor's bridge makes the interface believe it is in the app.
*
* bypassCSP because the server's policy is written for its own pages
* (`connect-src 'self'`); the app's pages are inside the app and never carry it.
* Without this the browser would block exactly the cross-origin calls the test
* exists to make, and the failure would read as the app not working.
*/
const PORT = 8097;
const SERVER = `http://localhost:${PORT}`;
test.use({
baseURL: `http://app.localhost:${PORT}`,
bypassCSP: true,
storageState: { cookies: [], origins: [] }
});
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.Capacitor = { isNativePlatform: () => true };
});
});
/** The app's session, read the way the app reads it. */
const storedToken = (page) =>
page.evaluate(() => localStorage.getItem('verdande.app.session') ?? '');
test('appen vælger sin server, logger ind med et token og følger med live', async ({ page }) => {
await page.goto('/');
// First the address — and an address with nothing behind it is said to be so,
// rather than leading to a sign-in form that cannot succeed.
await expect(page.getByText('Hvilken Verdande vil du logge ind på?')).toBeVisible();
await page.getByLabel(/Serveradresse/).fill('http://localhost:1');
await page.getByRole('button', { name: 'Fortsæt' }).click();
await expect(page.getByText(/Kunne ikke nå localhost:1/)).toBeVisible();
await page.getByLabel(/Serveradresse/).fill(SERVER);
await page.getByRole('button', { name: 'Fortsæt' }).click();
// The sign-in form, saying where it signs in to.
await expect(page.getByText(`localhost:${PORT}`)).toBeVisible();
await page.getByLabel(/E-mail/).fill(USER.email);
await page.getByLabel(/Adgangskode/).fill(USER.password);
await page.getByRole('button', { name: 'Log ind', exact: true }).click();
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
// Signed in by token, and by nothing else: no session cookie on either origin.
const token = await storedToken(page);
expect(token).not.toBe('');
const cookies = await page.context().cookies();
expect(cookies.filter((c) => c.name.includes('verdande_session'))).toEqual([]);
// Live sync over a ticketed socket: a project made behind the interface's back
// — straight to the API, as another device would — shows up in the sidebar
// without a reload. Nothing but the socket can have put it there.
const name = `Fra en anden enhed ${Date.now()}`;
const status = await page.evaluate(
async ({ server, token, name }) => {
const res = await fetch(`${server}/api/v1/projects`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
return res.status;
},
{ server: SERVER, token, name }
);
expect(status).toBe(201);
await expect(page.getByRole('navigation', { name: 'Hovedmenu' }).getByText(name)).toBeVisible();
// A picture from the server, which an <img> cannot fetch with a token. It is
// shown from a blob, and the path it came from is kept for the note to save.
const path = await page.evaluate(
async ({ server, token }) => {
const auth = { Authorization: `Bearer ${token}` };
const note = await fetch(`${server}/api/v1/notes`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: '# Billede fra appen' })
}).then((r) => r.json());
// A one-pixel PNG.
const png = Uint8Array.from(
atob(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=='
),
(c) => c.charCodeAt(0)
);
const form = new FormData();
form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png');
const saved = await fetch(`${server}/api/v1/notes/${note.id}/attachments`, {
method: 'POST',
headers: auth,
body: form
}).then((r) => r.json());
const img = document.createElement('img');
img.id = 'appbillede';
img.setAttribute('src', saved.url);
document.body.append(img);
return saved.url;
},
{ server: SERVER, token }
);
const img = page.locator('#appbillede');
await expect(img).toHaveAttribute('data-src', path);
await expect(img).toHaveAttribute('src', /^blob:/);
await expect.poll(() => img.evaluate((el) => el.naturalWidth)).toBe(1);
// Signing out takes the token off the phone, and keeps the server.
await page.evaluate(() => document.getElementById('appbillede')?.remove());
// Two taps: the first asks, the second signs out.
await page.getByRole('button', { name: /Log ud/ }).click();
await page.getByRole('button', { name: /Tryk igen for at logge ud/ }).click();
await expect(page.getByText(`localhost:${PORT}`)).toBeVisible();
expect(await storedToken(page)).toBe('');
// And the token is dead on the server too, not just forgotten here.
const after = await page.evaluate(
async ({ server, token }) =>
(await fetch(`${server}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` } }))
.status,
{ server: SERVER, token }
);
expect(after).toBe(401);
});
/**
* Signs in the way the app does, outside the page, and hands the page the server
* and the session as the app would have stored them.
*/
async function signedInApp(page) {
const origin = { Origin: `http://app.localhost:${PORT}` };
const res = await fetch(`${SERVER}/api/v1/auth/login`, {
method: 'POST',
headers: { ...origin, 'Content-Type': 'application/json' },
body: JSON.stringify({
email: USER.email,
password: USER.password,
client: 'app',
device_name: 'Prøve-iPhone'
})
});
const { session_token: token } = await res.json();
expect(token).toBeTruthy();
await page.addInitScript(
({ server, token }) => {
localStorage.setItem('verdande.app.server', server);
localStorage.setItem('verdande.app.session', token);
},
{ server: SERVER, token }
);
const call = async (method, path, body) => {
const init = { method, headers: { ...origin, Authorization: `Bearer ${token}` } };
if (body instanceof FormData) init.body = body;
else if (body !== undefined) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
const r = await fetch(`${SERVER}/api/v1${path}`, init);
return r.status === 204 ? null : r.json();
};
return call;
}
/**
* The picture in a note is shown from a blob made on the phone. Editing the note
* turns the page back into markdown, and what it must write is the path the
* picture came from — a blob address saved into a note is a picture that is gone
* the next time anybody opens it, on any device.
*/
test('en note med et billede gemmer stien, ikke blob-adressen, når den redigeres i appen', async ({
page
}) => {
const call = await signedInApp(page);
const note = await call('POST', '/notes', { body: '# Billednote fra appen' });
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg==',
'base64'
);
const form = new FormData();
form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png');
const saved = await call('POST', `/notes/${note.id}/attachments`, form);
await call('PATCH', `/notes/${note.id}`, {
body: `# Billednote fra appen\n\n![](${saved.url})\n\nTekst under billedet`
});
await page.goto('/noter?note=' + note.id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const img = ed.locator('img');
await expect(img).toHaveAttribute('src', /^blob:/);
await expect(img).toHaveAttribute('data-src', saved.url);
// Touch the note so it saves, and wait for the save itself.
const patched = page.waitForResponse(
(r) => r.request().method() === 'PATCH' && /\/notes\/[^/]+$/.test(new URL(r.url()).pathname) && r.ok()
);
await ed.getByText('Tekst under billedet').click();
await page.keyboard.press('End');
await page.keyboard.type(' og mere');
await patched;
const after = await call('GET', `/notes/${note.id}`);
expect(after.body).toContain('og mere');
expect(after.body).toContain(`](${saved.url})`);
expect(after.body).not.toContain('blob:');
});
+19 -2
View File
@@ -81,7 +81,8 @@ export default defineConfig({
// Alt undtagen noter-udgaven, som har sin egen server på sin egen port.
// Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor
// den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker.
testIgnore: /edition\.spec\.js/,
// Og app-prøven, der kører fladen fra en anden origin med sin egen login.
testIgnore: /(edition|app)\.spec\.js/,
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup']
},
@@ -120,6 +121,19 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` }
},
/**
* Fladen, som iOS-appen kører den: fra en anden origin end serveren, uden
* cookie, med sessionen som bearer. Se app.spec.js. Afhænger af `setup`,
* fordi den logger ind på kontoen, opsætningen lavede — men låner ikke dens
* cookie, for det er netop det, appen ikke har.
*/
{
name: 'app-origin',
testMatch: /app\.spec\.js/,
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup']
},
{
name: 'webkit-forms',
grep: /@forms/,
@@ -164,7 +178,10 @@ export default defineConfig({
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
VERDANDE_DATA_DIR: dataDir,
// Off: an update check would reach out to GitHub from a test run.
VERDANDE_UPDATE_CHECK: 'false'
VERDANDE_UPDATE_CHECK: 'false',
// Den origin, app-origin-projektet kører fladen fra, regnet som appens
// egen — som capacitor://localhost er det i den rigtige app.
VERDANDE_APP_ORIGINS: `http://app.localhost:${PORT}`
}
},
+33 -6
View File
@@ -24,6 +24,7 @@ export class ApiError extends Error {
import { t } from './i18n.svelte.js';
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
import { apiURL, appSession, authorize, deviceName, isNativeApp } from './platform.js';
/**
* How long a request keeps retrying a network failure before giving up.
@@ -152,19 +153,21 @@ async function request(method, path, body, options = {}) {
const init = {
method,
headers: {},
// The session cookie is httpOnly and same-origin; nothing here handles a token.
credentials: 'same-origin',
signal: options.signal
};
if (body !== undefined) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
// In a browser: the httpOnly session cookie, same-origin, and nothing here
// handles a token. In the iOS app: its session token as a bearer. See
// platform.js.
authorize(init);
// Retries a network failure for a few seconds so a short drop does not lose the
// call; `withRetry` turns an exhausted window into the same `offline` error the
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
const res = await withRetry(() => fetch(apiURL(path), init), options.noRetry);
if (res.status === 204) return null;
@@ -205,7 +208,7 @@ async function upload(path, file, field = 'file') {
form.append(field, file);
const res = await withRetry(() =>
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
fetch(apiURL(path), authorize({ method: 'POST', body: form }))
);
const text = await res.text();
@@ -232,9 +235,30 @@ export const api = {
// --- auth
setupState: () => get('/auth/setup'),
setup: (data) => post('/auth/setup', data),
login: (email, password) => post('/auth/login', { email, password }),
login: async (email, password) => {
if (!isNativeApp()) return post('/auth/login', { email, password });
// The app gets its session in the answer instead of a cookie, and keeps
// it — also while a code is still owed, because the second step is sent
// with it.
const result = await post('/auth/login', {
email,
password,
client: 'app',
device_name: deviceName()
});
if (result?.session_token) appSession.setToken(result.session_token);
return result;
},
loginTOTP: (code) => post('/auth/login/totp', { code }),
logout: () => post('/auth/logout'),
logout: async () => {
try {
return await post('/auth/logout');
} finally {
// Gone from the phone whether or not the server heard: a sign-out that
// left the token behind because the network was down is not one.
if (isNativeApp()) appSession.signOut();
}
},
me: () => get('/auth/me'),
updateProfile: (data) => patch('/auth/me', data),
// Its own route rather than a field on the profile: that one backs a form with
@@ -421,6 +445,9 @@ export const api = {
post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }),
deleteToken: (id) => del(`/tokens/${id}`),
// --- live sync, for the app (see stores.svelte.js)
wsTicket: () => post('/ws-ticket'),
// --- notifications and push
listNotifications: () => get('/notifications'),
markNotificationsRead: (id) => post(id ? `/notifications/${id}/read` : '/notifications/read'),
+114 -5
View File
@@ -21,9 +21,56 @@
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
import { available, signIn } from '$lib/passkey.js';
import { appSession, isNativeApp, normaliseServer } from '$lib/platform.js';
// loading | login | setup | totp | forgot | sent | invite | reset | done
// loading | server | login | setup | totp | forgot | sent | invite | reset | done
let mode = $state('loading');
// --- which server (the iOS app only) ------------------------------------------
const native = isNativeApp();
let server = $state(native ? appSession.server() : '');
let serverHost = $derived(server ? server.replace(/^https?:\/\//, '') : '');
/**
* The app has no server of its own. Before a password field is shown for an
* address somebody typed, the address has to be a Verdande: asked with /ping,
* which answers without a login and names the product. A router's login page,
* a typo that lands on somebody's blog, a server that is down — each gets its
* own sentence here instead of a sign-in form that cannot succeed.
*/
async function chooseServer() {
const url = normaliseServer(server);
if (!url) {
error = t('app.serverInvalid');
return;
}
let answer;
try {
const res = await fetch(`${url}/api/v1/ping`, { credentials: 'omit' });
answer = res.ok ? await res.json() : null;
} catch {
error = t('app.serverUnreachable', { host: url.replace(/^https?:\/\//, '') });
return;
}
if (answer?.product !== 'verdande') {
error = t('app.notVerdande');
return;
}
appSession.setServer(url);
server = url;
await enterServer();
}
/** Asks the chosen server what it wants: sign-in, or — in a browser — setup. */
async function enterServer() {
try {
const state = await api.setupState();
mode = state.needs_setup ? 'setup' : 'login';
} catch {
mode = 'login';
}
}
let email = $state('');
let password = $state('');
let name = $state('');
@@ -37,6 +84,9 @@
let passkeyReady = $state(false);
$effect(() => {
// Not in the app: a passkey is bound to the server's own domain, and the
// app's pages are not on it. Offered there it would fail every time.
if (native) return;
available().then((ok) => (passkeyReady = ok));
});
@@ -94,10 +144,11 @@
mode = linkMode;
return;
}
api
.setupState()
.then((state) => (mode = state.needs_setup ? 'setup' : 'login'))
.catch(() => (mode = 'login'));
if (native && !appSession.server()) {
mode = 'server';
return;
}
enterServer();
});
async function run(fn) {
@@ -120,6 +171,10 @@
run(async () => {
switch (mode) {
case 'server':
await chooseServer();
break;
case 'setup':
await api.setup({
email,
@@ -190,6 +245,34 @@
{#if mode === 'loading'}
<p class="lede">&nbsp;</p>
{:else if mode === 'server'}
<p class="lede">{t('app.serverPrompt')}</p>
<label>
{t('app.server')}
<input
bind:value={server}
use:focusOnMount
type="url"
inputmode="url"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
placeholder="verdande.example.dk"
required
/>
<span class="hint">{t('app.serverHint')}</span>
</label>
<button type="submit" disabled={busy}>{t('app.connect')}</button>
{:else if mode === 'setup' && native}
<!-- The first account starts a browser session, and the app has no use
for a cookie. An instance this new is being set up by whoever runs
it, at a desk; they do it there, and the app signs in after. -->
<p class="lede">{t('app.setupInBrowser', { host: serverHost })}</p>
<button type="button" class="link" onclick={() => (mode = 'server')}>
{t('app.changeServer')}
</button>
{:else if mode === 'setup'}
<p class="lede">{t('auth.firstAccount')}</p>
@@ -303,6 +386,15 @@
{:else}
<p class="lede">{t('auth.signInPrompt')}</p>
{#if native}
<p class="server-line">
<span class="mono">{serverHost}</span>
<button type="button" class="link" onclick={() => (mode = 'server')}>
{t('app.changeServer')}
</button>
</p>
{/if}
<label>
{t('auth.email')}
<input bind:value={email} type="email" autocomplete="username" required />
@@ -463,6 +555,23 @@
color: var(--ink);
}
/* Which server the app is signing in to, and the way back to choosing. */
.server-line {
display: flex;
gap: var(--s2);
align-items: baseline;
justify-content: center;
margin: 0;
font-size: var(--text-sm);
color: var(--ink-faint);
}
.server-line .mono {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.error {
margin: 0;
padding: var(--s3);
+9
View File
@@ -736,6 +736,15 @@ export const da = {
'auth.savePassword': 'Gem adgangskoden',
'auth.passwordChanged': 'Adgangskoden er skiftet. Log ind med den nye.',
'auth.resetSent': 'Hvis adressen har en konto, er der et link på vej. Tjek også spamfilteret.',
'app.serverPrompt': 'Hvilken Verdande vil du logge ind på?',
'app.server': 'Serveradresse',
'app.serverHint': 'Den adresse, du åbner Verdande på i en browser.',
'app.connect': 'Fortsæt',
'app.serverInvalid': 'Det ligner ikke en adresse.',
'app.serverUnreachable': 'Kunne ikke nå {host}. Tjek adressen og din forbindelse.',
'app.notVerdande': 'Der svarer noget på den adresse, men det er ikke en Verdande.',
'app.changeServer': 'Skift server',
'app.setupInBrowser': '{host} har ingen konti endnu. Opret den første i en browser, og log så ind her.',
'auth.signInPrompt': 'Log ind for at fortsætte.',
'auth.signIn': 'Log ind',
'auth.forgot': 'Glemt adgangskode?',
+9
View File
@@ -730,6 +730,15 @@ export const en = {
'auth.passwordChanged': 'The password has been changed. Sign in with the new one.',
'auth.resetSent':
'If that address has an account, a link is on its way. Check the spam filter too.',
'app.serverPrompt': 'Which Verdande do you want to sign in to?',
'app.server': 'Server address',
'app.serverHint': 'The address you open Verdande at in a browser.',
'app.connect': 'Continue',
'app.serverInvalid': 'That does not look like an address.',
'app.serverUnreachable': 'Could not reach {host}. Check the address and your connection.',
'app.notVerdande': 'Something answers at that address, but it is not a Verdande.',
'app.changeServer': 'Change server',
'app.setupInBrowser': '{host} has no accounts yet. Create the first one in a browser, then sign in here.',
'auth.signInPrompt': 'Sign in to continue.',
'auth.signIn': 'Sign in',
'auth.forgot': 'Forgotten your password?',
+92
View File
@@ -0,0 +1,92 @@
/**
* Pictures and files from the server, inside the iOS app.
*
* Attachments are addressed as `/api/v1/attachments/<id>` — in a note's text, in
* a task's file list, in the export links — and in a browser that is the whole
* story: the path is on the page's own origin and the cookie goes with it. In the
* app neither holds. The path would resolve against capacitor://localhost, and an
* `<img>` or a link cannot carry the bearer token even when pointed at the right
* server.
*
* So in the app, and only there, this watches the page: an image whose source is
* an API path is fetched with the token and shown from a blob, and a tap on a
* link to an API path is answered the same way. Done once for the whole document
* rather than in each component, because the paths come from many places — a
* note's own markdown among them — and a component that forgot would show a
* broken picture only in the app, where nobody runs the tests.
*
* The original path is kept in `data-src`. The note editor turns the page back
* into markdown, and a blob address saved into a note is a picture that is gone
* the next time anybody opens it; `richtext.js` reads `data-src` first.
*/
import { apiURL, authorize, isNativeApp } from './platform.js';
const API = '/api/v1/';
const blobs = new Map();
async function blobFor(path) {
if (!blobs.has(path)) {
const promise = fetch(apiURL(path.slice(API.length - 1)), authorize({ method: 'GET' })).then(
async (res) => {
if (!res.ok) throw new Error(`${res.status}`);
return URL.createObjectURL(await res.blob());
}
);
// A failure is not cached: the next sight of the picture tries again.
promise.catch(() => blobs.delete(path));
blobs.set(path, promise);
}
return blobs.get(path);
}
function adopt(img) {
const path = img.getAttribute('src');
// Our own blob address coming back through the observer is not an API path,
// so it stops here; a new API path on an image already adopted is taken up.
if (!path?.startsWith(API) || img.dataset.src === path) return;
img.dataset.src = path;
// Nothing until the picture is here, rather than a broken-image glyph.
img.removeAttribute('src');
blobFor(path)
.then((url) => {
if (img.dataset.src === path) img.src = url;
})
.catch(() => {});
}
function scan(root) {
if (root.nodeType !== 1) return;
if (root.tagName === 'IMG') adopt(root);
for (const img of root.querySelectorAll?.('img[src^="/api/v1/"]') ?? []) adopt(img);
}
async function openLink(event) {
const a = event.target.closest?.('a[href]');
const path = a?.getAttribute('href');
if (!path?.startsWith(API)) return;
event.preventDefault();
try {
window.open(await blobFor(path), '_blank');
} catch {
// Nothing to open; the request's own failure is what there is to say,
// and there is no screen here to say it on.
}
}
/** Called once by the layout. A no-op in a browser. */
export function installNativeMedia() {
if (!isNativeApp() || typeof document === 'undefined') return;
scan(document.body);
new MutationObserver((records) => {
for (const record of records) {
if (record.type === 'attributes') adopt(record.target);
else for (const node of record.addedNodes) scan(node);
}
}).observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src']
});
document.addEventListener('click', openLink, true);
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Where the interface is running, and what that changes about reaching the server.
*
* In a browser the interface and the API share an origin: every path is relative
* and the session is an httpOnly cookie no script can see. In the iOS app the
* interface is bundled into the app and served from capacitor://localhost, and
* the server is whichever one its owner typed in. So the app needs three things
* a browser never does: the server's address, its own session token, and that
* token on every request instead of a cookie.
*
* Everything that differs between the two lives here, so `api.js` and the socket
* ask one question — `apiURL(path)`, `authorize(init)` — and the browser's answer
* is exactly what it was before this file existed.
*/
/** True inside the iOS app. Capacitor puts its bridge on `window` before any of
* our code runs. */
export function isNativeApp() {
return !!globalThis.Capacitor?.isNativePlatform?.();
}
/**
* The app's stored settings: which server, and the session on it.
*
* localStorage for now, behind this one object, so the Keychain plugin can take
* its place without anything else changing. The token is a session — whoever
* holds it is signed in — and the Keychain is where a phone keeps those; until it
* is wired in, this is a WKWebView's own storage, readable only by the app.
*/
const SERVER_KEY = 'verdande.app.server';
const TOKEN_KEY = 'verdande.app.session';
function read(key) {
try {
return localStorage.getItem(key) ?? '';
} catch {
return '';
}
}
function write(key, value) {
try {
if (value) localStorage.setItem(key, value);
else localStorage.removeItem(key);
} catch {
// Storage refused (private mode, quota). The app then asks again next
// launch, which is the right failure: a token that seemed saved and was
// not would look like being signed out for no reason.
}
}
export const appSession = {
server: () => read(SERVER_KEY),
setServer: (url) => write(SERVER_KEY, url),
token: () => read(TOKEN_KEY),
setToken: (token) => write(TOKEN_KEY, token),
/** Signed out: the token goes, the server stays — signing in again is to the
* same place nearly every time. */
signOut: () => write(TOKEN_KEY, '')
};
/** The address of an API path: relative in a browser, on the chosen server in
* the app. */
export function apiURL(path) {
const base = isNativeApp() ? appSession.server() : '';
return `${base}/api/v1${path}`;
}
/**
* Fills in how a request proves who is asking.
*
* A browser sends its cookie, same-origin only. The app sends no cookie at all —
* `omit`, so nothing the WebView picked up from somewhere rides along — and its
* token as a bearer.
*/
export function authorize(init) {
if (!isNativeApp()) {
init.credentials = 'same-origin';
return init;
}
init.credentials = 'omit';
const token = appSession.token();
if (token) {
init.headers = { ...(init.headers ?? {}), Authorization: `Bearer ${token}` };
}
return init;
}
/** What the app calls itself in the list of signed-in devices. */
export function deviceName() {
const ua = navigator.userAgent;
if (/iPad/.test(ua)) return 'iPad';
if (/iPhone/.test(ua)) return 'iPhone';
return 'Verdande-appen';
}
/**
* Normalises what somebody typed as their server's address.
*
* "verdande.example.dk" becomes "https://verdande.example.dk"; a trailing slash
* and a pasted "/api/v1" are dropped. Plain http is kept when typed on purpose,
* because a self-hoster on their own network may have no certificate — iOS will
* refuse it unless the app allows it, and that refusal should be the one they see.
*/
export function normaliseServer(input) {
let s = (input ?? '').trim();
if (!s) return '';
if (!/^https?:\/\//i.test(s)) s = `https://${s}`;
try {
const u = new URL(s);
return `${u.protocol}//${u.host}`;
} catch {
return '';
}
}
+4
View File
@@ -9,12 +9,16 @@
import { api } from './api.js';
import { t } from './i18n.svelte.js';
import { isNativeApp } from './platform.js';
/** Whether this browser can do it at all. Safari before 16.4 cannot, and neither
* can any browser on a page served over plain HTTP from a non-localhost host. */
export function supported() {
return (
typeof window !== 'undefined' &&
// The iOS app gets its notifications from APNs, not Web Push: a WKWebView
// has no push service, and a subscription made there would go nowhere.
!isNativeApp() &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
+4 -1
View File
@@ -267,7 +267,10 @@ function inlineToMarkdown(node) {
// — og den første gemning ville tage hvert billede ud af noten uden at
// nogen havde rørt det.
case 'IMG':
return `![${node.getAttribute('alt') ?? ''}](${node.getAttribute('src') ?? ''})`;
// data-src first: in the iOS app the shown source is a blob made on this
// device, and the path it was made from is what the note keeps. See
// nativemedia.js.
return `![${node.getAttribute('alt') ?? ''}](${node.dataset?.src || node.getAttribute('src') || ''})`;
case 'STRONG':
case 'B':
return inner ? `**${inner}**` : '';
+41 -3
View File
@@ -13,6 +13,23 @@
import { api, ApiError, humanMessage } from './api.js';
import { i18n, t } from './i18n.svelte.js';
import { appSession, isNativeApp } from './platform.js';
/**
* Where the live-sync socket is. A browser opens it on its own origin and the
* cookie goes with the handshake. The app cannot put its token on a handshake,
* so it asks for a one-use ticket first and opens the socket on its server with
* that.
*/
async function socketURL() {
if (!isNativeApp()) {
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
return `${scheme}://${location.host}/api/v1/ws`;
}
const { ticket } = await api.wsTicket();
const base = appSession.server().replace(/^http/, 'ws');
return `${base}/api/v1/ws?ticket=${encodeURIComponent(ticket)}`;
}
class AppState {
user = $state(null);
@@ -229,6 +246,14 @@ class AppState {
async load() {
this.loading = true;
// The app before it knows which server it belongs to: nothing to ask, and
// the sign-in screen is where the address is asked for.
if (isNativeApp() && !appSession.server()) {
this.user = null;
this.loading = false;
return;
}
// Døren, hvis dette vindue allerede er gået gennem den. Genindlæser nogen på
// /noter inde i urd-appen, kommer de ikke forbi /urd igen, og uden den her
// linje ville ansigtet falde af ved hvert F5.
@@ -281,10 +306,23 @@ class AppState {
// --- realtime ---------------------------------------------------------------
connect() {
async connect() {
if (this.#socket) return;
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
const socket = new WebSocket(`${scheme}://${location.host}/api/v1/ws`);
// Claimed before the await below, so two calls in the same moment cannot
// both open one.
this.#socket = 'opening';
let url;
try {
url = await socketURL();
} catch {
// No ticket — offline, or signed out. Try again the way a dropped
// socket does.
this.#socket = null;
setTimeout(() => this.connect(), this.#reconnectDelay);
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, 30000);
return;
}
const socket = new WebSocket(url);
this.#socket = socket;
socket.onopen = () => {
+7
View File
@@ -15,6 +15,7 @@
import { setBadge } from '$lib/badge.js';
import { open as openInChosenBrowser, stored as storedOpener } from '$lib/linkopen.js';
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
import { installNativeMedia } from '$lib/nativemedia.js';
let { children } = $props();
@@ -58,6 +59,12 @@
app.load();
});
// Pictures and downloads from the server, in the iOS app. A no-op in a
// browser. See nativemedia.js.
$effect(() => {
installNativeMedia();
});
/**
* Hvilken dør dette vindue er inde ad.
*
+11
View File
@@ -45,6 +45,17 @@ export default {
*/
version: {
name: process.env.VERDANDE_VERSION ?? 'dev'
},
/**
* Not in the iOS app. WKWebView does not run service workers for an app's
* own scheme, and the offline cache it would keep belongs to an interface
* served by the instance — the app carries its interface inside itself.
* `VERDANDE_TARGET=app` is set by the app's build; the browser build is
* unchanged.
*/
serviceWorker: {
register: process.env.VERDANDE_TARGET !== 'app'
}
}
};