Compare commits

...
2 Commits
Author SHA1 Message Date
kristianwindandClaude Opus 5.5 b9028e8f7a feat(app): fladen kan tale med en valgfri server, med sessionen som bearer
Tredje trin mod iOS-appen. I en browser deler fladen og API'et origin: hver
sti er relativ, og sessionen er en httpOnly-cookie. I appen ligger fladen i
appen selv på capacitor://localhost, og serveren er den, ejeren skrev ind.
Alt, der er forskelligt, bor i den nye `platform.js`, så browserens svar er
præcis, hvad det var før.

- `apiURL()` og `authorize()`: relative stier og cookie i en browser; den
  valgte server og `Authorization: Bearer` i appen, `credentials: 'omit'`.
- Login sender `client: app` og gemmer `session_token`; log ud fjerner det fra
  telefonen, også når serveren ikke svarede.
- Live-synk i appen beder om en billet og åbner socket'en på serveren med den.
- Første skærm i appen: "Hvilken Verdande vil du logge ind på?". Adressen
  prøves med /ping, før et adgangskodefelt vises, så en tastefejl, en router
  eller en server, der er nede, får sin egen sætning. Passkeys og opsætning af
  første konto vises ikke i appen; begge hører til serverens eget domæne.
- Billeder og downloads under /api/v1 hentes med tokenet og vises fra en blob,
  i appen og kun dér (`nativemedia.js`). Stien bevares i `data-src`, og
  `richtext.js` læser den først, så en note aldrig gemmer en blob-adresse.
- Service worker og Web Push er slået fra i appen: WKWebView har ingen af dem.

Serveren: `VERDANDE_APP_ORIGINS` regner flere origins som appens. Den findes
for E2E-projektet `app-origin`, der kører fladen fra app.localhost — samme
server, anden origin — med en Capacitor-stub og uden cookie. Prøven vælger
server, logger ind, ser et projekt fra "en anden enhed" dukke op over socket'en,
viser et billede fra en blob og logger ud.

Fire plantede fejl, hver fanget af prøven ved sit eget trin: ingen bearer,
socket uden billet, billeder ikke hentet, token efterladt ved log ud.

Dokumenterer også `VERDANDE_APP_SESSION_TTL` fra trin 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:16:33 +02:00
kristianwindandClaude Opus 5.5 a71af41d9e feat(app): CORS for appen, en billet til live-synk, og /ping siger hvad den er
Andet trin mod iOS-appen. Appen henter sin flade fra capacitor://localhost og
taler med den server, ejeren har skrevet ind, så hver anmodning er cross-origin.

- CORS på /api/v1 for appens origin og ingen andres. Ingen
  Allow-Credentials: appen bærer sin session som bearer, aldrig som cookie.
  Alle andre origins får ingen headere, præcis som før.
- POST /ws-ticket giver en engangsbillet, der lever 30 sekunder, og
  /ws?ticket=… åbner socket'en med den. En browser kan ikke sætte en header
  på et WebSocket-håndtryk, og tokenet selv i URL'en ville ligge i hver
  proxy-log i månedsvis. En socket med billet springer origin-tjekket over,
  fordi den ikke bærer nogen cookie, som en fremmed side kunne låne.
- Et håndtryk fra appens origin uden billet afvises. Prøven fandt hvorfor:
  biblioteket sammenligner kun værtsnavne, og capacitor://localhost har samme
  værtsnavn som en instans på http://localhost.
- /ping svarer med product, edition og api, så appen kan kende en Verdande
  fra en routers login-side, før nogen er logget ind. Ingen version: /version
  er bag et login med vilje.

Fem plantede fejl, hver fanget af sin egen prøve: CORS til enhver origin,
genbrugelig billet, billet uden udløb, app-origin på en cookie, og
Allow-Credentials.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:03:01 +02:00
22 changed files with 1154 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 |
+40
View File
@@ -4024,14 +4024,51 @@ paths:
Events: `task.created`, `task.updated`, `task.completed`, `task.reopened`,
`task.moved`, `task.deleted`, `comment.created`, `notification`,
`reminder`.
A browser opens it with its session cookie and from the instance's own
origin. The iOS app cannot put a header on a handshake, so it asks
`/ws-ticket` first and opens `/ws?ticket=…`; a handshake from the app's
origin without one is refused.
parameters:
- name: ticket
in: query
required: false
schema: { type: string }
description: From `/ws-ticket`. Good for one connection, within 30 seconds.
responses:
"101": { description: Switching protocols. }
"401": { $ref: "#/components/responses/Unauthorized" }
/api/v1/ws-ticket:
post:
tags: [integrations]
summary: A one-use ticket for opening the live-sync socket without a cookie.
description: |
For the iOS app, whose session is a bearer token that a WebSocket
handshake cannot carry. Putting the token itself in the URL would leave a
credential lasting months in every log the URL passes through; the ticket
lasts thirty seconds and opens one connection.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
ticket: { type: string }
expires_in: { type: integer, description: Seconds., example: 30 }
"401": { $ref: "#/components/responses/Unauthorized" }
/api/v1/ping:
get:
tags: [integrations]
summary: A liveness check that does not touch the database.
description: |
Also how the iOS app tells a Verdande from anything else at an address
somebody typed in, before anyone has signed in, and which half of the
interface to show. It carries no version: `/version` is behind a login on
purpose, and a public number is a list of the instances running an old one.
security: []
responses:
"200":
@@ -4042,6 +4079,9 @@ paths:
type: object
properties:
status: { type: string, const: ok }
product: { type: string, const: verdande }
edition: { type: string, enum: [full, notes] }
api: { type: integer, const: 1, description: Raised when /api/v1 changes in a way an older app cannot follow. }
# --- data -----------------------------------------------------------------
+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 {
+224
View File
@@ -0,0 +1,224 @@
package httpapi
import (
"context"
"net/http"
"strings"
"testing"
"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 {
t.Helper()
req, err := http.NewRequest(method, ts.URL+path, nil)
if err != nil {
t.Fatal(err)
}
if origin != "" {
req.Header.Set("Origin", origin)
}
for k, v := range header {
req.Header.Set(k, v)
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp
}
func TestCORSAnswersTheAppAndNobodyElse(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
preflight := map[string]string{
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization, content-type",
}
resp := ts.corsDo(t, "OPTIONS", "/api/v1/projects", "capacitor://localhost", preflight)
if resp.StatusCode != http.StatusNoContent {
t.Errorf("app preflight: status %d", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
t.Errorf("app preflight: Allow-Origin %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "Authorization") {
t.Errorf("app preflight: Allow-Headers %q lacks Authorization", got)
}
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
t.Errorf("credentials allowed (%q): the app uses a bearer, never a cookie", got)
}
// An ordinary request from the app can be read.
resp = ts.corsDo(t, "GET", "/api/v1/ping", "capacitor://localhost", nil)
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
t.Errorf("app GET: Allow-Origin %q", got)
}
// Anybody else gets nothing, so the browser keeps them from reading.
for _, origin := range []string{"https://evil.example", "http://localhost", "capacitor://evil", "null"} {
resp = ts.corsDo(t, "OPTIONS", "/api/v1/projects", origin, preflight)
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("%s: preflight answered with Allow-Origin %q", origin, got)
}
resp = ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("%s: GET answered with Allow-Origin %q", origin, got)
}
}
}
// The app asks this before anyone has signed in, to tell a Verdande from a
// router's login page.
func TestPingNamesTheProductAndEditionButNoVersion(t *testing.T) {
ts := newTestServer(t)
anonymous := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
resp, body := anonymous.do(t, "GET", "/api/v1/ping", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("ping: %d", resp.StatusCode)
}
if body["product"] != "verdande" || body["edition"] != "full" || body["api"] != float64(1) {
t.Errorf("ping = %v", body)
}
if _, ok := body["version"]; ok {
t.Error("ping reveals the version to anybody")
}
}
func dialWS(ctx context.Context, url, origin string) (*websocket.Conn, *http.Response, error) {
h := http.Header{}
if origin != "" {
h.Set("Origin", origin)
}
return websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: h})
}
func TestTheAppOpensLiveSyncWithATicket(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := appLogin(t, ts)
token := body["session_token"].(string)
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Without a ticket or a cookie: refused.
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
t.Fatal("a socket opened with no credential")
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
t.Errorf("no credential: %v", resp)
}
resp, body := ts.appDo(t, token, "POST", "/api/v1/ws-ticket", nil)
ticket, _ := body["ticket"].(string)
if resp.StatusCode != http.StatusOK || ticket == "" {
t.Fatalf("ws-ticket: %d %v", resp.StatusCode, body)
}
conn, _, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost")
if err != nil {
t.Fatalf("dial with a ticket: %v", err)
}
defer conn.CloseNow()
// And it is the right person's stream: their own new project arrives on it.
ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Live fra telefonen"})
_, msg, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read: %v", err)
}
if !strings.Contains(string(msg), "Live fra telefonen") {
t.Errorf("first event was %s", msg)
}
// Spent: the same ticket does not open a second socket.
if _, resp, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost"); err == nil {
t.Error("a ticket opened two sockets")
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
t.Errorf("reused ticket: %v", resp)
}
}
func TestATicketExpires(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := appLogin(t, ts)
_, body = ts.appDo(t, body["session_token"].(string), "POST", "/api/v1/ws-ticket", nil)
ticket := body["ticket"].(string)
ts.api.wsTickets.mu.Lock()
v := ts.api.wsTickets.m[ticket]
v.expires = time.Now().Add(-time.Second)
ts.api.wsTickets.m[ticket] = v
ts.api.wsTickets.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws?ticket=" + ticket
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
t.Error("an expired ticket opened a socket")
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expired ticket: %v", resp)
}
}
// The cookie path is unchanged: a foreign page cannot open a socket that rides
// on the cookie, and the instance's own page can.
func TestACookieSocketStillChecksTheOrigin(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var cookie string
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
if c.Name == sessionCookieInsecure {
cookie = c.Value
}
}
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dial := func(origin string) error {
h := http.Header{}
h.Set("Origin", origin)
h.Set("Cookie", sessionCookieInsecure+"="+cookie)
conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: h})
if err == nil {
conn.CloseNow()
}
return err
}
if err := dial("https://evil.example"); err == nil {
t.Error("a foreign page opened a socket on the cookie")
}
if err := dial("capacitor://localhost"); err == nil {
t.Error("the app origin opened a socket on a cookie, without a ticket")
}
if err := dial("http://localhost"); err != nil {
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)
}
}
+169
View File
@@ -0,0 +1,169 @@
package httpapi
import (
"context"
"crypto/rand"
"encoding/hex"
"net/http"
"sync"
"time"
)
// What the iOS app needs from a server that a browser gets for free by being on
// the same origin. The app loads its interface from capacitor://localhost and
// talks to whichever server its owner typed in, so every request it makes is
// cross-origin: it needs CORS to read the answers, a way to open the live-sync
// socket without a cookie, and a way to ask "is this a Verdande?" before anyone
// has signed in.
// appCORS answers the app's origin, and nobody else's.
//
// No Access-Control-Allow-Credentials: the app carries its session as a bearer
// token, never a cookie, so a response to a request with a cookie on it is not
// 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 (s *Server) appCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if !s.isAppOrigin(origin) {
next.ServeHTTP(w, r)
return
}
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Add("Vary", "Origin")
// Read by the client: a rate limit's wait, and a download's file name.
h.Set("Access-Control-Expose-Headers", "Retry-After, Content-Disposition")
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
h.Set("Access-Control-Max-Age", "600")
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// --- the live-sync socket without a cookie -----------------------------------
// A browser cannot put a header on a WebSocket handshake, so the app's bearer
// token has no way onto it. Putting the token in the URL would work and would
// leave a credential that lasts months in every proxy log the URL passes. A
// ticket is the usual answer: asked for with the token, good for one connection,
// gone in thirty seconds.
const wsTicketTTL = 30 * time.Second
type wsTicket struct {
userID string
expires time.Time
}
type wsTickets struct {
mu sync.Mutex
m map[string]wsTicket
}
func newWSTickets() *wsTickets { return &wsTickets{m: map[string]wsTicket{}} }
func (t *wsTickets) issue(userID string) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
ticket := hex.EncodeToString(raw)
t.mu.Lock()
defer t.mu.Unlock()
// Swept on the way in, so the map is bounded by thirty seconds of asking.
now := time.Now()
for k, v := range t.m {
if now.After(v.expires) {
delete(t.m, k)
}
}
t.m[ticket] = wsTicket{userID: userID, expires: now.Add(wsTicketTTL)}
return ticket, nil
}
// redeem spends a ticket. It is deleted before it is checked, so a second
// presentation finds nothing, whether the first one was in time or not.
func (t *wsTickets) redeem(ticket string) (string, bool) {
t.mu.Lock()
defer t.mu.Unlock()
v, ok := t.m[ticket]
delete(t.m, ticket)
if !ok || time.Now().After(v.expires) {
return "", false
}
return v.userID, true
}
func (s *Server) handleWSTicket(w http.ResponseWriter, r *http.Request) {
ticket, err := s.wsTickets.issue(userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "issue websocket ticket", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ticket": ticket,
"expires_in": int(wsTicketTTL.Seconds()),
})
}
// handleWebSocketEntry opens the socket with a ticket, or falls through to the
// ordinary session check.
//
// A socket opened with a ticket skips the origin check, and that is the point
// rather than a gap: the origin check exists because a browser attaches the
// cookie to a handshake from any page. A ticket is attached by nobody — it has to
// be asked for with a credential and put in the URL on purpose — so there is no
// ambient authority for a foreign page to borrow.
func (s *Server) handleWebSocketEntry(w http.ResponseWriter, r *http.Request) {
ticket := r.URL.Query().Get("ticket")
if ticket == "" {
// The app never holds a cookie, so a handshake from its origin without a
// 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 s.isAppOrigin(r.Header.Get("Origin")) {
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "the app opens this socket with a ticket")
return
}
s.requireAuth(http.HandlerFunc(s.handleWebSocket)).ServeHTTP(w, r)
return
}
userID, ok := s.wsTickets.redeem(ticket)
if !ok {
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that ticket is not valid")
return
}
user, err := s.db.UserByID(r.Context(), userID)
if err != nil {
s.storeError(w, r, "load user", err)
return
}
ctx := context.WithValue(withUser(r.Context(), user), ctxTicket, true)
s.handleWebSocket(w, r.WithContext(ctx))
}
// --- is this a Verdande? -----------------------------------------------------
// handlePing answers before anybody has signed in, which is when the app needs
// it: the person has typed an address and the app has to tell "that is a
// Verdande" from "that is a router's login page" before it shows a password
// field for it. The edition decides which half of the interface to show.
//
// No version. /version sits behind a login on purpose, and a public number is a
// list of the instances still running an old one.
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"product": "verdande",
"edition": s.edition(),
"api": 1,
})
}
+12 -2
View File
@@ -18,6 +18,8 @@ type ctxKey int
const (
ctxUser ctxKey = iota
ctxSession
// ctxTicket marks a socket opened with a one-use ticket rather than a cookie.
ctxTicket
)
// sessionCookie is prefixed with __Host- when served over HTTPS. That prefix is
@@ -220,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
}
@@ -249,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
}
+4
View File
@@ -45,11 +45,15 @@ func (s *Server) publishToOwner(r *http.Request, event string, payload any) {
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
ticketed, _ := r.Context().Value(ctxTicket).(bool)
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// The socket is same-origin only. Without this, any page on the internet
// could open a socket carrying the user's cookie and watch their tasks —
// the same-origin policy does not apply to WebSocket handshakes.
OriginPatterns: s.allowedOrigins(),
// Except one opened with a ticket, which carries no cookie for a foreign
// page to borrow: see handleWebSocketEntry.
InsecureSkipVerify: ticketed,
})
if err != nil {
s.log.Debug("websocket handshake", "err", err)
+13 -4
View File
@@ -60,6 +60,10 @@ type Server struct {
// instance should not be unreachable because one of them cannot be offered.
passkeys *auth.WebAuthn
// One-use tickets for opening the live-sync socket without a cookie — see
// app_handlers.go.
wsTickets *wsTickets
// Password guessing is the attack this application is actually exposed to, so
// the endpoints that check a secret are limited separately and more tightly
// than the rest of the API.
@@ -115,6 +119,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// their own code gets it right on the first or second try; ten in an hour is
// generous for a person and hopeless for a search through a million.
totpLimiter: newLimiter(10, time.Hour),
wsTickets: newWSTickets(),
}
// Before the CSP, deliberately: the policy has to describe the bytes that go
// out, and in the notes edition those are the renamed ones.
@@ -273,11 +278,15 @@ 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(s.appCORS)
r.Use(s.requireCSRF)
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
r.Get("/ping", s.handlePing)
// The live-sync socket checks its own credential: a ticket, or the
// session as everywhere else. See handleWebSocketEntry.
r.Get("/ws", s.handleWebSocketEntry)
// The beacon's receiving end, and the number it produces. Both without a
// session, because the caller is a stranger's installation — that is the
@@ -378,7 +387,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
full := !s.cfg.NotesOnly()
r.Use(s.requireAuth)
r.Get("/ws", s.handleWebSocket)
r.Post("/ws-ticket", s.handleWSTicket)
// The MCP endpoint. Reachable with a personal API token, so a connector
// sees exactly what its owner sees.
+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'
}
}
};