Compare commits

...
5 Commits
Author SHA1 Message Date
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 <noreply@anthropic.com>
2026-10-23 14:03:01 +02:00
kristianwindandClaude Opus 5.5 a8db761a4b fix(docs): et kolon i en beskrivelse gjorde specifikationen ulæselig
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:59:55 +02:00
kristianwindandClaude Opus 5.5 4ef050ae04 feat(login): appen kan logge ind og bære sin session som bearer
Første trin mod en iOS-app. Appen pakker den eksisterende flade ind og henter
den fra sig selv (capacitor://localhost), så den deler aldrig origin med
serveren og får aldrig cookien tilbage. Den logger derfor ind som en browser,
med adgangskode og evt. kode, men får sin session i svaret i stedet for i en
cookie og sender den som `Authorization: Bearer`.

Det er med vilje en SESSION og ikke et API-token: den står i listen over
enheder under det navn, appen giver sig selv, kan logges ud derfra, og den når
2FA og nøgler, som et token med vilje ikke gør.

- `POST /auth/login` tager `client: "app"` og `device_name`. Svaret får
  `session_token` og ingen cookie, også mens `totp_required` er sand.
- En app-session glider: hver brug flytter udløbet frem, op til
  `VERDANDE_APP_SESSION_TTL` (180 dage) ubrugt. En browser beholder sin faste
  levetid. En halvfærdig login glider ikke; den beholder sine ti minutter.
- En bearer, der ikke er et API-token, slås op som session, men kun hvis den
  blev udstedt til appen. En browsers cookie-værdi som header må ikke blive en
  anden vej forbi SameSite og CSRF-låsen.
- CSRF-låsen genkender appens origin. Det var prøven, der fandt det: selve
  login-kaldet har intet token endnu og blev afvist som cross-site. En
  hjemmeside kan ikke skrive `Origin`-headeren, og en app-login sætter ingen
  cookie, så der er ingen session for en forfalsket anmodning at ride på.

Passkeys er udenfor i første omgang: RP-ID er bundet til domænet og kræver
apple-app-site-association på hver instans.

Migration 0037: sessions.client, device_name, idle_ttl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:55:46 +02:00
Kristian Wind 95e02b567b Merge pull request #104 from kristianwind/billedvisning-stoerre
CI / OpenAPI is valid (push) Successful in 18s
CI / End-to-end (push) Failing after 4m58s
CI / Go (push) Failing after 5m30s
CI / Docker image builds (push) Successful in 2m39s
fix(noter): et åbnet billede fylder skærmen og kan ses i fuld størrelse
2026-10-18 23:46:13 +02:00
kristianwindandClaude Opus 5.5 93470259d2 fix(noter): et åbnet billede fylder skærmen og kan ses i fuld størrelse
Billedvisningen havde kun max-width/max-height, så den kunne gøre et billede
mindre og aldrig større. Målt i den rigtige app: et 600×400-billede stod 600 px
i noten og 600 i visningen, og et højt telefon-skærmbillede stod 701 px bredt i
noten og 357 i visningen. På en telefon var der ingen forskel overhovedet.

Nu fylder billedet skærmen (også når det skal forstørres), og et klik på
billedet — eller knappen "Fuld størrelse" — viser det i egne pixels, aldrig
smallere end skærmen, med rulning hen til det sted, man klikkede. Knapperne
ligger fast nederst, så de kan nås, uanset hvor man har rullet hen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:37:10 +02:00
17 changed files with 1170 additions and 85 deletions
+70
View File
@@ -120,6 +120,19 @@ paths:
properties:
email: { type: string, format: email }
password: { type: string }
client:
type: string
enum: [web, app]
description: |
`app` for the iOS app. Its session comes back as
`session_token` instead of a cookie and slides — every use
moves its end forward, up to `VERDANDE_APP_SESSION_TTL`
(180 days) unused. Left out, it is a browser login.
device_name:
type: string
maxLength: 100
description: "Required with `client: app`; shown in the list of sessions."
example: Kristians iPhone
responses:
"200":
description: |
@@ -132,7 +145,14 @@ paths:
properties:
user: { $ref: "#/components/schemas/User" }
totp_required: { type: boolean }
session_token:
type: string
description: |
An app login only, also while `totp_required` — the app
sends it to `/auth/login/totp` too. Sent from then on as
`Authorization: Bearer`. A browser never receives it.
"401": { $ref: "#/components/responses/Unauthorized" }
"422": { $ref: "#/components/responses/Validation" }
"429": { $ref: "#/components/responses/RateLimited" }
/api/v1/auth/login/totp:
@@ -4004,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":
@@ -4022,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 -----------------------------------------------------------------
@@ -4439,6 +4499,10 @@ components:
A personal API token, `vrd_…`. Carries its owner's permissions exactly —
there is no separate service identity — and is refused on `/tokens`.
Or the iOS app's session, the `session_token` from an app login. That
one is a session and reaches everything a browser session does. A
browser's own session is never accepted here.
responses:
BadRequest:
description: The body could not be read.
@@ -4973,6 +5037,12 @@ components:
it so anything the summary gets wrong is still visible.
example: Chrome på macOS
user_agent: { type: string }
client:
type: string
enum: [web, app]
description: |
`app` for the iOS app, whose `device` is the name it gave itself and
whose `expires_at` moves forward each time it is used.
ip: { type: string }
created_at: { type: string, format: date-time }
last_seen_at: { type: string, format: date-time }
+9 -2
View File
@@ -24,8 +24,12 @@ type Config struct {
DataDir string
SessionTTL time.Duration
InviteTTL time.Duration
ResetTTL time.Duration
// AppSessionTTL is how long a session from the iOS app may sit unused before
// it ends. It slides: every use moves the end forward, so a phone in daily use
// stays signed in and one left in a drawer does not.
AppSessionTTL time.Duration
InviteTTL time.Duration
ResetTTL time.Duration
SMTP SMTP
@@ -166,6 +170,9 @@ func Load() (*Config, error) {
if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil {
return nil, err
}
if c.AppSessionTTL, err = envDuration("VERDANDE_APP_SESSION_TTL", 180*24*time.Hour); err != nil {
return nil, err
}
if c.InviteTTL, err = envDuration("VERDANDE_INVITE_TTL", 7*24*time.Hour); err != nil {
return nil, err
}
+202
View File
@@ -0,0 +1,202 @@
package httpapi
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/coder/websocket"
)
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)
}
}
+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 appCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if !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 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,
})
}
+269
View File
@@ -0,0 +1,269 @@
package httpapi
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"testing"
"time"
)
// The iOS app loads its interface from itself — capacitor://localhost — so it
// never shares an origin with the server and never gets a cookie back. It signs
// in like a browser does and carries the same kind of session, as a bearer token.
// appDo is a request the way the app makes it: no cookie jar, the token in a
// header, and the browser engine reporting the request as cross-site, because to
// WKWebView it is.
func (ts *testServer) appDo(t *testing.T, token, method, path string, body any) (*http.Response, map[string]any) {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, ts.URL+path, reader)
if err != nil {
t.Fatal(err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Origin", "capacitor://localhost")
req.Header.Set("Sec-Fetch-Site", "cross-site")
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
t.Cleanup(func() { resp.Body.Close() })
var decoded map[string]any
raw, _ := io.ReadAll(resp.Body)
if len(raw) > 0 {
_ = json.Unmarshal(raw, &decoded)
}
return resp, decoded
}
func appLogin(t *testing.T, ts *testServer) (*http.Response, map[string]any) {
t.Helper()
return ts.appDo(t, "", "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
"client": "app", "device_name": "Kristians iPhone",
})
}
func TestAppLoginReturnsATokenAndNoCookie(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := appLogin(t, ts)
if resp.StatusCode != http.StatusOK {
t.Fatalf("app login: %d %v", resp.StatusCode, body)
}
if len(resp.Cookies()) != 0 {
t.Errorf("an app login set a cookie: %v", resp.Cookies())
}
token, _ := body["session_token"].(string)
if token == "" {
t.Fatalf("no session_token in %v", body)
}
if body["user"] == nil {
t.Error("no user in the response")
}
// The token is the whole credential, from a foreign origin, reads and writes.
resp, me := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusOK || me["email"] != "kristian@example.dk" {
t.Fatalf("me with the app token: %d %v", resp.StatusCode, me)
}
resp, body = ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Fra telefonen"})
if resp.StatusCode != http.StatusCreated {
t.Errorf("a write from the app was refused: %d %v", resp.StatusCode, body)
}
// It is a session, not an API token: it reaches what a token may not, and it
// is in the list of devices under the name the app gave itself.
resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/sessions", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("sessions from the app: %d %v", resp.StatusCode, body)
}
var found bool
for _, raw := range body["sessions"].([]any) {
s := raw.(map[string]any)
if s["current"] == true {
found = true
if s["device"] != "Kristians iPhone" || s["client"] != "app" {
t.Errorf("the app's session reads %v / %v", s["device"], s["client"])
}
}
}
if !found {
t.Error("the app's own session is not marked current")
}
// And signing out ends it.
if resp, _ = ts.appDo(t, token, "POST", "/api/v1/auth/logout", nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("logout: %d", resp.StatusCode)
}
if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized {
t.Errorf("the token still works after logout: %d", resp.StatusCode)
}
}
// A browser's session never leaves its httpOnly cookie. Its value replayed as a
// bearer must not work: that would turn the header into a second way past the
// SameSite and CSRF locks the cookie sits behind.
func TestABrowserSessionIsNotABearer(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
}
}
if cookie == "" {
t.Fatal("bootstrap left no session cookie")
}
if resp, _ := ts.appDo(t, cookie, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a browser session worked as a bearer: %d", resp.StatusCode)
}
// Control: the same value is still a perfectly good cookie.
if resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
t.Errorf("control: the cookie itself: %d", resp.StatusCode)
}
}
func TestAppLoginWithASecondFactor(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{"password": "et langt kodeord"})
secret, _ := body["secret"].(string)
if resp, body := ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)}); resp.StatusCode != http.StatusOK {
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
}
resp, body := appLogin(t, ts)
token, _ := body["session_token"].(string)
if resp.StatusCode != http.StatusOK || body["totp_required"] != true || token == "" {
t.Fatalf("app login should hand over a token and ask for a code: %d %v", resp.StatusCode, body)
}
if body["user"] != nil {
t.Error("the user came back before the code was given")
}
// Half a login reaches nothing, as in a browser, and does not slide yet.
resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusUnauthorized || body["code"] != CodeTOTPRequired {
t.Errorf("a pending app session reached /me: %d %v", resp.StatusCode, body)
}
session, _, err := ts.db.SessionByToken(t.Context(), token)
if err != nil {
t.Fatal(err)
}
if session.IdleTTL != 0 || time.Until(session.ExpiresAt) > 11*time.Minute {
t.Errorf("a pending app session lives %v and slides by %v", time.Until(session.ExpiresAt), session.IdleTTL)
}
resp, body = ts.appDo(t, token, "POST", "/api/v1/auth/login/totp", map[string]string{"code": codeFor(t, secret)})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp from the app: %d %v", resp.StatusCode, body)
}
if len(resp.Cookies()) != 0 {
t.Errorf("finishing an app login set a cookie: %v", resp.Cookies())
}
if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
t.Errorf("me after the code: %d", resp.StatusCode)
}
session, _, _ = ts.db.SessionByToken(t.Context(), token)
if session.IdleTTL != 7*24*time.Hour {
t.Errorf("a finished app session slides by %v, want the app TTL", session.IdleTTL)
}
}
// A phone in daily use must not sign itself out; one left in a drawer must. The
// end moves forward each time the session is used, and a browser's does not.
func TestAnAppSessionSlides(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := appLogin(t, ts)
token := body["session_token"].(string)
// Pretend it was last used a while ago and is a day from ending.
soon := time.Now().Add(24 * time.Hour).Unix()
if _, err := ts.db.ExecContext(t.Context(),
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'app'`,
time.Now().Add(-time.Hour).Unix(), soon); err != nil {
t.Fatal(err)
}
if resp, _ := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
t.Fatalf("me: %d", resp.StatusCode)
}
var expires int64
if err := ts.db.QueryRowContext(t.Context(),
`SELECT expires_at FROM sessions WHERE client = 'app'`).Scan(&expires); err != nil {
t.Fatal(err)
}
if left := time.Until(time.Unix(expires, 0)); left < 6*24*time.Hour {
t.Errorf("using the app session left %v on it, want about the app TTL", left)
}
// Control: a browser session used the same way keeps its fixed end.
if _, err := ts.db.ExecContext(t.Context(),
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'web'`,
time.Now().Add(-time.Hour).Unix(), soon); err != nil {
t.Fatal(err)
}
ts.do(t, "GET", "/api/v1/auth/me", nil)
if err := ts.db.QueryRowContext(t.Context(),
`SELECT expires_at FROM sessions WHERE client = 'web'`).Scan(&expires); err != nil {
t.Fatal(err)
}
if expires != soon {
t.Errorf("a browser session's end moved from %d to %d", soon, expires)
}
}
func TestAppLoginRefusesWhatItDoesNotKnow(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, tc := range []struct {
name string
body map[string]string
field string
}{
{"unknown client", map[string]string{"client": "android", "device_name": "x"}, "client"},
{"app without a name", map[string]string{"client": "app", "device_name": " "}, "device_name"},
} {
tc.body["email"] = "kristian@example.dk"
tc.body["password"] = "et langt kodeord"
resp, body := ts.appDo(t, "", "POST", "/api/v1/auth/login", tc.body)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("%s: status %d, want 422", tc.name, resp.StatusCode)
continue
}
if fields, _ := body["fields"].(map[string]any); fields[tc.field] == nil {
t.Errorf("%s: no error on %q in %v", tc.name, tc.field, body)
}
}
}
func mustURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return u
}
+71 -8
View File
@@ -159,11 +159,40 @@ func (s *Server) handleSetDefaultReminder(w http.ResponseWriter, r *http.Request
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
// Client is "app" when the iOS app signs in; empty is a browser. DeviceName is
// what the app calls itself, shown in the list of signed-in devices.
Client string `json:"client"`
DeviceName string `json:"device_name"`
}
type loginResponse struct {
User *userJSON `json:"user,omitempty"`
TOTPRequired bool `json:"totp_required"`
// SessionToken is set for an app login only, and instead of the cookie: the
// app loads its interface from itself, so it never shares an origin with the
// server and a cookie would not come back. It is sent from then on as
// `Authorization: Bearer`. A browser never sees it — its session stays in an
// httpOnly cookie no script can read.
SessionToken string `json:"session_token,omitempty"`
}
// appClient reports whether a login asked for an app session, and refuses a value
// it does not know rather than quietly giving it a browser session: a client that
// asked for something else and got a cookie would look signed in until its next
// request, and then not be.
func appClient(w http.ResponseWriter, client, deviceName string) (app, ok bool) {
switch client {
case "", "web":
return false, true
case "app":
if strings.TrimSpace(deviceName) == "" {
writeFieldErrors(w, map[string]string{"device_name": "an app names the device it signs in from"})
return false, false
}
return true, true
}
writeFieldErrors(w, map[string]string{"client": `must be "app" or left out`})
return false, false
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
@@ -171,6 +200,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if err := decodeJSON(w, r, &req); err != nil {
return
}
app, ok := appClient(w, req.Client, req.DeviceName)
if !ok {
return
}
user, err := s.db.UserByEmail(r.Context(), req.Email)
if err != nil || auth.VerifyPassword(user.PasswordHash, req.Password) != nil {
@@ -215,22 +248,40 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// A session that still needs a code gets a short life of its own: long enough
// to fetch a phone, not long enough to be worth stealing.
ttl := s.cfg.SessionTTL
if app {
ttl = s.cfg.AppSessionTTL
}
if user.TOTPEnabled {
ttl = 10 * time.Minute
}
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
token, session, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
if err != nil {
s.internal(w, r, "create session", err)
return
}
s.setSessionCookie(w, token, ttl)
if user.TOTPEnabled {
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
return
resp := loginResponse{TOTPRequired: user.TOTPEnabled}
if app {
// Sliding only once the login is finished; a pending one keeps its ten
// minutes and handleLoginTOTP turns it on.
idle := s.cfg.AppSessionTTL
if user.TOTPEnabled {
idle = 0
}
if err := s.db.MarkAppSession(r.Context(), session.ID, req.DeviceName, idle); err != nil {
s.internal(w, r, "mark app session", err)
return
}
resp.SessionToken = token
} else {
s.setSessionCookie(w, token, ttl)
}
uj := toUserJSON(user)
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
if !user.TOTPEnabled {
uj := toUserJSON(user)
resp.User = &uj
}
writeJSON(w, http.StatusOK, resp)
}
type totpRequest struct {
@@ -305,7 +356,19 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
s.loginLimiter.reset(clientIP(r))
s.totpLimiter.reset(user.ID)
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
// An app's session finishes as a sliding one; it already holds its token and
// gets no cookie.
if session.Client == "app" {
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.AppSessionTTL, s.cfg.AppSessionTTL); err != nil {
s.internal(w, r, "promote session", err)
return
}
uj := toUserJSON(user)
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
return
}
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL, 0); err != nil {
s.internal(w, r, "promote session", err)
return
}
+7 -6
View File
@@ -51,12 +51,13 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
cfg := &config.Config{
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
// party id, and every browser makes the same exception for localhost.
BaseURL: "http://localhost",
DataDir: t.TempDir(),
SessionTTL: 24 * time.Hour,
InviteTTL: 7 * 24 * time.Hour,
ResetTTL: time.Hour,
Dev: true,
BaseURL: "http://localhost",
DataDir: t.TempDir(),
SessionTTL: 24 * time.Hour,
AppSessionTTL: 7 * 24 * time.Hour,
InviteTTL: 7 * 24 * time.Hour,
ResetTTL: time.Hour,
Dev: true,
}
if configure != nil {
configure(cfg)
+42 -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
@@ -126,9 +128,10 @@ func (s *Server) requireAdmin(next http.Handler) http.Handler {
})
}
// authenticate resolves a request to a user, by session cookie or by API token.
// authenticate resolves a request to a user, by session cookie, by API token, or by
// an app's session sent as a bearer token.
//
// Both are accepted everywhere. An API token belongs to a person and carries their
// All are accepted everywhere. An API token belongs to a person and carries their
// permissions exactly — there is no separate service identity to reason about, and
// a script therefore cannot reach anything its owner could not.
// errNoCredentials means the request carried nothing to authenticate with, as
@@ -145,6 +148,21 @@ func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, err
return user, nil, nil
}
// Any other bearer is an app session (see loginResponse.SessionToken). It is
// accepted only for a session that was issued to the app: a browser's session
// never leaves its httpOnly cookie, and a cookie value found somewhere and
// replayed as a header must not become a second way to use it.
if token := bearerToken(r); token != "" {
session, user, err := s.db.SessionByToken(r.Context(), token)
if err != nil {
return nil, nil, err
}
if session.Client != "app" {
return nil, nil, store.ErrNotFound
}
return user, session, nil
}
cookie, err := r.Cookie(s.cookieName())
if err != nil || cookie.Value == "" {
return nil, nil, errNoCredentials
@@ -200,6 +218,14 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
// The iOS app, before it has a token: its sign-in. A web page cannot send
// 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")) {
next.ServeHTTP(w, r)
return
}
// Sec-Fetch-Site is set by the browser and cannot be forged by page script.
switch r.Header.Get("Sec-Fetch-Site") {
@@ -220,6 +246,20 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
})
}
// appOrigins are where the iOS app's interface is served from. Capacitor loads
// the bundled build on this scheme inside WKWebView, so every request the app
// makes to a server arrives from here, whichever server it is.
var appOrigins = []string{"capacitor://localhost"}
func isAppOrigin(origin string) bool {
for _, o := range appOrigins {
if origin == o {
return true
}
}
return false
}
func (s *Server) isOwnOrigin(origin string) bool {
if strings.EqualFold(origin, s.cfg.BaseURL) {
return true
+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(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.
+12 -3
View File
@@ -23,8 +23,11 @@ type sessionJSON struct {
// Device is a summary; UserAgent is what it was summarised from. Both, because
// the summary is what you read and the original is what settles the question
// when the summary is not enough.
Device string `json:"device"`
UserAgent string `json:"user_agent"`
Device string `json:"device"`
UserAgent string `json:"user_agent"`
// Client is "web" or "app". An app session slides — its expires_at moves
// forward each time it is used — where a browser's is fixed.
Client string `json:"client"`
IP string `json:"ip"`
CreatedAt string `json:"created_at"`
LastSeenAt string `json:"last_seen_at"`
@@ -47,11 +50,17 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
out := make([]sessionJSON, 0, len(sessions))
for _, sess := range sessions {
// The app names itself; a browser is described from its user agent.
device := sess.DeviceName
if device == "" {
device = describeUserAgent(sess.UA)
}
out = append(out, sessionJSON{
ID: sess.ID,
Current: current != nil && sess.ID == current.ID,
Device: describeUserAgent(sess.UA),
Device: device,
UserAgent: sess.UA,
Client: sess.Client,
IP: sess.IP,
CreatedAt: sess.CreatedAt.Format(time.RFC3339),
LastSeenAt: sess.LastSeenAt.Format(time.RFC3339),
@@ -0,0 +1,19 @@
-- Sessioner fra en app.
--
-- En app logger ind som en browser gør, med adgangskode og evt. kode, og får en
-- almindelig session. Forskellen er transporten: tokenet kommer i svaret og sendes
-- som `Authorization: Bearer`, fordi en app, der henter sit UI fra sig selv, ikke
-- deler origin med serveren og derfor aldrig får cookien tilbage. At det er en
-- SESSION og ikke et API-token er hele pointen: den står i listen over enheder,
-- kan logges ud derfra, og den når indstillingerne for 2FA og nøgler, som et
-- token med vilje ikke gør.
--
-- device_name er det navn, appen giver sig selv ("Kristians iPhone"), så listen
-- siger noget, en person genkender. idle_ttl er sekunder: større end 0 betyder, at
-- sessionen glider — hver gang den bruges, flyttes udløbet frem. En browser får
-- 0 og sin faste levetid som før; en telefon, man har i lommen hver dag, må ikke
-- logge sig selv ud hver 30. dag, men en telefon, der ligger i en skuffe et halvt
-- år, skal.
ALTER TABLE sessions ADD COLUMN client TEXT NOT NULL DEFAULT 'web';
ALTER TABLE sessions ADD COLUMN device_name TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN idle_ttl INTEGER NOT NULL DEFAULT 0;
+70 -31
View File
@@ -15,9 +15,36 @@ type Session struct {
UA string
IP string
PendingTOTP bool
CreatedAt time.Time
LastSeenAt time.Time
ExpiresAt time.Time
// Client is "web" for a browser and "app" for the iOS app, which carries its
// session as a bearer token rather than a cookie. DeviceName is what the app
// calls itself; a browser has none and is described from its user agent.
Client string
DeviceName string
// IdleTTL above zero makes the session slide: each use moves ExpiresAt to
// IdleTTL from now. Zero is the fixed lifetime a browser session has always had.
IdleTTL time.Duration
CreatedAt time.Time
LastSeenAt time.Time
ExpiresAt time.Time
}
const sessionColumns = `id, user_id, ua, ip, pending_totp, client, device_name, idle_ttl,
created_at, last_seen_at, expires_at`
func scanSession(row interface{ Scan(...any) error }) (*Session, error) {
var s Session
var pending int
var idle, created, lastSeen, expires int64
if err := row.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &s.Client, &s.DeviceName, &idle,
&created, &lastSeen, &expires); err != nil {
return nil, err
}
s.PendingTOTP = pending == 1
s.IdleTTL = time.Duration(idle) * time.Second
s.CreatedAt = time.Unix(created, 0).UTC()
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
s.ExpiresAt = time.Unix(expires, 0).UTC()
return &s, nil
}
// CreateSession issues a session and returns the value to put in the cookie.
@@ -40,6 +67,7 @@ func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time
UA: truncate(ua, 500),
IP: truncate(ip, 100),
PendingTOTP: pendingTOTP,
Client: "web",
CreatedAt: now,
LastSeenAt: now,
ExpiresAt: now.Add(ttl),
@@ -63,13 +91,8 @@ func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time
func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User, error) {
id := auth.HashToken(token)
var s Session
var pending int
var created, lastSeen, expires int64
err := db.QueryRowContext(ctx,
`SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at
FROM sessions WHERE id = ?`, id).
Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires)
s, err := scanSession(db.QueryRowContext(ctx,
`SELECT `+sessionColumns+` FROM sessions WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, ErrNotFound
}
@@ -77,11 +100,6 @@ func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User
return nil, nil, err
}
s.PendingTOTP = pending == 1
s.CreatedAt = time.Unix(created, 0).UTC()
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
s.ExpiresAt = time.Unix(expires, 0).UTC()
if time.Now().After(s.ExpiresAt) {
_, _ = db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, s.ID)
return nil, nil, ErrNotFound
@@ -95,20 +113,47 @@ func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User
// last_seen_at drives the session list in settings ("this device, 2 minutes
// ago"). It is written at most once a minute: every request would be a write
// per request, which on SQLite means a write lock per request.
//
// A sliding session moves its end in the same write, and only then: its
// expiry is therefore accurate to the minute, which is far finer than the
// months it is measured in.
if time.Since(s.LastSeenAt) > time.Minute {
_, _ = db.ExecContext(ctx,
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), s.ID)
now := time.Now()
if s.IdleTTL > 0 && !s.PendingTOTP {
s.ExpiresAt = now.Add(s.IdleTTL).UTC()
_, _ = db.ExecContext(ctx,
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`,
now.Unix(), s.ExpiresAt.Unix(), s.ID)
} else {
_, _ = db.ExecContext(ctx,
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`, now.Unix(), s.ID)
}
}
return &s, user, nil
return s, user, nil
}
// MarkAppSession records that a session belongs to the iOS app, what the device
// calls itself, and how long it may sit unused (see Session.IdleTTL).
//
// A login still waiting on its code is marked with idle 0, so it keeps its ten
// minutes; PromoteSession turns sliding on once the code is in.
func (db *DB) MarkAppSession(ctx context.Context, sessionID, deviceName string, idle time.Duration) error {
_, err := db.ExecContext(ctx,
`UPDATE sessions SET client = 'app', device_name = ?, idle_ttl = ? WHERE id = ?`,
truncate(deviceName, 100), int64(idle/time.Second), sessionID)
return err
}
// PromoteSession completes a two-step login: the session stops being pending and
// its clock restarts, so the full lifetime is counted from finishing the login and
// not from typing the password.
func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl time.Duration) error {
//
// idle above zero makes the finished session slide (see Session.IdleTTL); a
// browser passes zero.
func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl, idle time.Duration) error {
res, err := db.ExecContext(ctx,
`UPDATE sessions SET pending_totp = 0, expires_at = ? WHERE id = ? AND pending_totp = 1`,
time.Now().Add(ttl).Unix(), sessionID)
`UPDATE sessions SET pending_totp = 0, expires_at = ?, idle_ttl = ? WHERE id = ? AND pending_totp = 1`,
time.Now().Add(ttl).Unix(), int64(idle/time.Second), sessionID)
if err != nil {
return err
}
@@ -147,7 +192,7 @@ func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error {
func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at
`SELECT `+sessionColumns+`
FROM sessions WHERE user_id = ? AND pending_totp = 0 ORDER BY last_seen_at DESC`, userID)
if err != nil {
return nil, err
@@ -156,17 +201,11 @@ func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error
var out []Session
for rows.Next() {
var s Session
var pending int
var created, lastSeen, expires int64
if err := rows.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires); err != nil {
s, err := scanSession(rows)
if err != nil {
return nil, err
}
s.PendingTOTP = pending == 1
s.CreatedAt = time.Unix(created, 0).UTC()
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
s.ExpiresAt = time.Unix(expires, 0).UTC()
out = append(out, s)
out = append(out, *s)
}
return out, rows.Err()
}
+78
View File
@@ -5440,6 +5440,84 @@ test('et indsat billede kan åbnes stort og kopieres', async ({ page, context })
expect(trouble).toEqual([]);
});
/**
* Et billede bliver større af at blive åbnet — ikke mindre.
*
* Rapporteret som "billederne er meget små, når man klikker på dem". Ruden havde
* kun `max-width` og `max-height`, så den kunne gøre et billede mindre og aldrig
* større. Målt i den rigtige app: et lille billede (600×400) stod 600 pixels bredt
* i noten og 600 i ruden, og et højt skærmbillede fra en telefon stod 701 bredt i
* noten og 357 i ruden. At klikke for at se billedet bedre gav et dårligere billede.
*
* Målt på kasserne, med tal: "billedet er synligt" var sandt hele tiden.
*/
test('et åbnet billede fylder skærmen og kan ses i fuld størrelse', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByLabel('Notens tekst');
await body.fill('Billednote med mål');
const paste = (w, h) =>
body.evaluate(async (el, [w, h]) => {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const g = c.getContext('2d');
g.fillStyle = '#c33';
g.fillRect(0, 0, w, h);
const blob = await new Promise((r) => c.toBlob(r, 'image/png'));
const data = new DataTransfer();
data.items.add(new File([blob], 'skud.png', { type: 'image/png' }));
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
}, [w, h]);
const viewport = page.viewportSize();
const shot = page.getByRole('dialog', { name: 'Billede' });
const big = shot.locator('img');
// Et lille billede: ruden forstørrer det, til det fylder skærmen i den ene led.
await paste(600, 400);
const small = page.locator('[aria-label="Notens tekst"] img').last();
await expect(small).toHaveJSProperty('complete', true);
const inNote = await small.boundingBox();
await small.click();
await expect(big).toHaveClass(/sized/);
const fit = await big.boundingBox();
expect(fit.width).toBeGreaterThan(inNote.width * 1.5);
expect(fit.width).toBeLessThanOrEqual(viewport.width);
expect(fit.y + fit.height).toBeLessThanOrEqual(viewport.height);
// Fuld størrelse er aldrig mindre end det, man kom fra.
await shot.getByRole('button', { name: 'Fuld størrelse' }).click();
const full = await big.boundingBox();
expect(full.width).toBeGreaterThanOrEqual(fit.width);
await shot.getByRole('button', { name: 'Tilpas skærmen' }).click();
await page.keyboard.press('Escape');
await expect(shot).toHaveCount(0);
// Et højt skærmbillede fra en telefon: i fuld størrelse er det mindst lige så
// bredt som i noten, og ruden ruller, så hele billedet kan nås.
await paste(1170, 2532);
const tall = page.locator('[aria-label="Notens tekst"] img').last();
await expect(tall).toHaveJSProperty('complete', true);
const tallInNote = await tall.boundingBox();
await tall.click();
await expect(big).toHaveClass(/sized/);
await big.click();
const tallFull = await big.boundingBox();
expect(tallFull.width).toBeGreaterThanOrEqual(tallInNote.width);
expect(tallFull.width).toBeGreaterThanOrEqual(1170);
const scroll = await shot.evaluate((d) => d.scrollHeight - d.clientHeight);
expect(scroll).toBeGreaterThan(1000);
// Et klik på billedet igen bringer det tilbage til skærmens mål.
await big.click();
const back = await big.boundingBox();
expect(back.y + back.height).toBeLessThanOrEqual(viewport.height);
expect(trouble).toEqual([]);
});
/**
* En opgaves tekst kan læses færdig, også på en telefon.
*
+131 -29
View File
@@ -13,6 +13,7 @@
* unforgivable. When the replacement (the EditContext API) is everywhere, this
* is the file to change.
*/
import { tick } from 'svelte';
import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
import { highlight, guessLanguage } from '$lib/highlight.js';
import { t } from '$lib/i18n.svelte.js';
@@ -756,6 +757,8 @@
if (image) {
event.preventDefault();
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
zoomed = false;
natural = null;
copied = false;
copyError = '';
return;
@@ -769,6 +772,49 @@
let copied = $state(false);
let copyError = $state('');
// Ruden viste billedet med `max-width`/`max-height` og intet andet, så det kunne
// blive mindre, men aldrig større. Det var mindre end i noten, det kom fra: et
// skærmbillede fra en telefon på en skærm i bredformat stod 701 pixels bredt i
// arket og 357 i ruden, og et lille billede blev slet ikke forstørret. Nu fylder
// det skærmen — også når det skal forstørres for at gøre det — og et klik på
// billedet viser det i sin egen størrelse, til at rulle rundt i.
let zoomed = $state(false);
/** @type {{ w: number, h: number } | null} */
let natural = $state(null);
/** @type {HTMLDivElement | null} */
let lightbox = $state(null);
/** @param {Event & { currentTarget: HTMLImageElement }} event */
function measured(event) {
const img = event.currentTarget;
natural = { w: img.naturalWidth, h: img.naturalHeight };
}
/**
* Skift mellem "hele skærmen" og "fuld størrelse".
*
* Fra et klik på billedet rulles der hen til det sted, man klikkede, så man
* ikke lander i øverste venstre hjørne og skal lede efter det, man ville se.
*
* @param {MouseEvent} [event]
*/
async function toggleZoom(event) {
const img = lightbox?.querySelector('.shot');
let fx = 0.5;
let fy = 0.5;
if (event?.clientX !== undefined && event.detail > 0 && img) {
const r = img.getBoundingClientRect();
fx = (event.clientX - r.left) / r.width;
fy = (event.clientY - r.top) / r.height;
}
zoomed = !zoomed;
await tick();
if (!zoomed || !img || !lightbox) return;
const r = img.getBoundingClientRect();
lightbox.scrollLeft += r.left + r.width * fx - lightbox.clientWidth / 2;
lightbox.scrollTop += r.top + r.height * fy - lightbox.clientHeight / 2;
}
function closeImage() {
showing = null;
// Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke
@@ -1293,15 +1339,35 @@
af netop det ark, det skal lægge sig over. -->
{#if showing}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="lightbox" onclick={closeImage} role="dialog" aria-modal="true" aria-label={t('notes.imageTitle')}>
<div
class="lightbox"
class:zoomed
bind:this={lightbox}
onclick={closeImage}
role="dialog"
aria-modal="true"
aria-label={t('notes.imageTitle')}
style:--w={natural?.w}
style:--h={natural?.h}
>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<img
class="shot"
class:sized={natural}
src={showing.src}
alt={showing.alt}
onload={measured}
onclick={(e) => {
e.stopPropagation();
toggleZoom(e);
}}
/>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="shot" onclick={(e) => e.stopPropagation()}>
<img src={showing.src} alt={showing.alt} />
<div class="tools">
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
</div>
<div class="tools" onclick={(e) => e.stopPropagation()}>
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
<button onclick={toggleZoom}>{zoomed ? t('notes.imageZoomOut') : t('notes.imageZoomIn')}</button>
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
{#if copyError}<p class="bad">{copyError}</p>{/if}
</div>
</div>
@@ -1318,43 +1384,77 @@
<style>
/* Over alt andet og midt på skærmen. Baggrunden er mørk nok til at billedet
står alene — det er hele grunden til at åbne det. */
står alene — det er hele grunden til at åbne det.
Centreret med `margin: auto` på billedet og ikke med `justify-content`: i
fuld størrelse er billedet større end ruden, og et flex-element, der er
centreret og for stort, stikker lige meget ud til begge sider — den venstre og
den øverste del kan man så ikke rulle hen til. */
.lightbox {
--pad: var(--s4);
--bar: 4rem;
position: fixed;
inset: 0;
z-index: 100;
background: rgb(0 0 0 / 0.8);
background: rgb(0 0 0 / 0.85);
display: flex;
align-items: center;
justify-content: center;
padding: var(--s4);
padding: var(--pad) var(--pad) calc(var(--bar) + var(--pad));
overflow: auto;
overscroll-behavior: contain;
}
/* På en telefon bryder de fire knapper over i to rækker. */
@media (max-width: 600px) {
.lightbox {
--pad: var(--s2);
--bar: 6.5rem;
}
}
.shot {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s3);
max-width: 100%;
max-height: 100%;
}
/* `min-height: 0` hører med: uden den nægter et flex-element at blive lavere
end sit indhold, og et højt billede vokser ud over skærmen i stedet for at
passe sig ind i den. */
.shot img {
min-height: 0;
max-width: 100%;
max-height: calc(100vh - 8rem);
object-fit: contain;
margin: auto;
flex: none;
max-width: calc(100vw - 2 * var(--pad));
max-height: calc(100dvh - 2 * var(--pad) - var(--bar));
border-radius: var(--radius);
cursor: zoom-in;
}
/* Så snart billedets egne mål kendes, fylder det skærmen: den største bredde,
der både er inden for skærmens bredde og — ved billedets form — inden for dens
højde. Det forstørrer et lille billede, hvad `max-width` alene aldrig gjorde. */
.shot.sized {
max-width: none;
max-height: none;
width: min(
calc(100vw - 2 * var(--pad)),
calc((100dvh - 2 * var(--pad) - var(--bar)) * var(--w) / var(--h))
);
height: auto;
}
/* Fuld størrelse: billedets egne pixels, og ruden ruller. Aldrig smallere end
skærmen — et lille billede ville ellers blive MINDRE af at blive forstørret, og
et højt skærmbillede fra en telefon skal kunne læses i hele skærmens bredde. */
.zoomed .shot.sized {
width: max(calc(var(--w) * 1px), calc(100vw - 2 * var(--pad)));
cursor: zoom-out;
}
/* Knapperne ligger fast nederst, så de er der, uanset hvor man har rullet hen
i et billede i fuld størrelse. */
.tools {
position: fixed;
left: 50%;
bottom: var(--s3);
transform: translateX(-50%);
display: flex;
gap: var(--s2);
align-items: center;
justify-content: center;
flex-wrap: wrap;
width: max-content;
max-width: calc(100vw - 2 * var(--s3));
}
.tools button,
@@ -1374,6 +1474,8 @@
}
.bad {
flex-basis: 100%;
text-align: center;
margin: 0;
font-size: var(--text-sm);
color: var(--danger);
+2
View File
@@ -177,6 +177,8 @@ export const da = {
'notes.imageCopy': 'Kopiér billedet',
'notes.imageCopied': 'Kopieret',
'notes.imageOpen': 'Åbn i nyt faneblad',
'notes.imageZoomIn': 'Fuld størrelse',
'notes.imageZoomOut': 'Tilpas skærmen',
'notes.strike': 'Gennemstreget',
'notes.code': 'Kode',
'notes.showSource': 'Vis kilden (Markdown)',
+2
View File
@@ -174,6 +174,8 @@ export const en = {
'notes.imageCopy': 'Copy the image',
'notes.imageCopied': 'Copied',
'notes.imageOpen': 'Open in a new tab',
'notes.imageZoomIn': 'Full size',
'notes.imageZoomOut': 'Fit the screen',
'notes.strike': 'Strikethrough',
'notes.code': 'Code',
'notes.showSource': 'Show the source (Markdown)',