Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a71af41d9e | ||
|
|
a8db761a4b | ||
|
|
4ef050ae04 | ||
|
|
95e02b567b | ||
|
|
93470259d2 |
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user