Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9028e8f7a | ||
|
|
a71af41d9e | ||
|
|
a8db761a4b | ||
|
|
4ef050ae04 | ||
|
|
95e02b567b | ||
|
|
93470259d2 |
@@ -251,6 +251,7 @@ to put one that the operator would ever see.
|
|||||||
| `VERDANDE_ADDR` | `:8080` | Listen address. |
|
| `VERDANDE_ADDR` | `:8080` | Listen address. |
|
||||||
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
||||||
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
|
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
|
||||||
|
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
|
||||||
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
|
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
|
||||||
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
|
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
|
||||||
| `VERDANDE_SMTP_HOST` | — | Blank disables outbound mail; invite links are shown in the app instead. |
|
| `VERDANDE_SMTP_HOST` | — | Blank disables outbound mail; invite links are shown in the app instead. |
|
||||||
|
|||||||
@@ -37,11 +37,14 @@ straight off the internet is keyed on the address it really came from.
|
|||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
|
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
|
||||||
|
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
|
||||||
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
|
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
|
||||||
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
|
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
|
||||||
|
|
||||||
Any Go duration: `720h`, `30m`, `45s`.
|
Any Go duration: `720h`, `30m`, `45s`.
|
||||||
|
|
||||||
|
`VERDANDE_APP_ORIGINS` (comma-separated) counts further origins as the iOS app's own, beside `capacitor://localhost`. It exists for the end-to-end suite, which runs the interface from a second host name to test it the way the app does; an instance has no reason to set it.
|
||||||
|
|
||||||
## Mail
|
## Mail
|
||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
|
|||||||
@@ -120,6 +120,19 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
email: { type: string, format: email }
|
email: { type: string, format: email }
|
||||||
password: { type: string }
|
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:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: |
|
description: |
|
||||||
@@ -132,7 +145,14 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
user: { $ref: "#/components/schemas/User" }
|
user: { $ref: "#/components/schemas/User" }
|
||||||
totp_required: { type: boolean }
|
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" }
|
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||||
|
"422": { $ref: "#/components/responses/Validation" }
|
||||||
"429": { $ref: "#/components/responses/RateLimited" }
|
"429": { $ref: "#/components/responses/RateLimited" }
|
||||||
|
|
||||||
/api/v1/auth/login/totp:
|
/api/v1/auth/login/totp:
|
||||||
@@ -4004,14 +4024,51 @@ paths:
|
|||||||
Events: `task.created`, `task.updated`, `task.completed`, `task.reopened`,
|
Events: `task.created`, `task.updated`, `task.completed`, `task.reopened`,
|
||||||
`task.moved`, `task.deleted`, `comment.created`, `notification`,
|
`task.moved`, `task.deleted`, `comment.created`, `notification`,
|
||||||
`reminder`.
|
`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:
|
responses:
|
||||||
"101": { description: Switching protocols. }
|
"101": { description: Switching protocols. }
|
||||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
"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:
|
/api/v1/ping:
|
||||||
get:
|
get:
|
||||||
tags: [integrations]
|
tags: [integrations]
|
||||||
summary: A liveness check that does not touch the database.
|
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: []
|
security: []
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -4022,6 +4079,9 @@ paths:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
status: { type: string, const: ok }
|
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 -----------------------------------------------------------------
|
# --- data -----------------------------------------------------------------
|
||||||
|
|
||||||
@@ -4439,6 +4499,10 @@ components:
|
|||||||
A personal API token, `vrd_…`. Carries its owner's permissions exactly —
|
A personal API token, `vrd_…`. Carries its owner's permissions exactly —
|
||||||
there is no separate service identity — and is refused on `/tokens`.
|
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:
|
responses:
|
||||||
BadRequest:
|
BadRequest:
|
||||||
description: The body could not be read.
|
description: The body could not be read.
|
||||||
@@ -4973,6 +5037,12 @@ components:
|
|||||||
it so anything the summary gets wrong is still visible.
|
it so anything the summary gets wrong is still visible.
|
||||||
example: Chrome på macOS
|
example: Chrome på macOS
|
||||||
user_agent: { type: string }
|
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 }
|
ip: { type: string }
|
||||||
created_at: { type: string, format: date-time }
|
created_at: { type: string, format: date-time }
|
||||||
last_seen_at: { type: string, format: date-time }
|
last_seen_at: { type: string, format: date-time }
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ type Config struct {
|
|||||||
DataDir string
|
DataDir string
|
||||||
|
|
||||||
SessionTTL time.Duration
|
SessionTTL 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
|
InviteTTL time.Duration
|
||||||
ResetTTL time.Duration
|
ResetTTL time.Duration
|
||||||
|
|
||||||
@@ -65,6 +69,12 @@ type Config struct {
|
|||||||
// the deal its operator made by self-hosting.
|
// the deal its operator made by self-hosting.
|
||||||
UpdateCheck bool
|
UpdateCheck bool
|
||||||
|
|
||||||
|
// AppOrigins are origins treated like the iOS app's own, capacitor://localhost,
|
||||||
|
// which is always included. Set by the end-to-end suite, which serves the
|
||||||
|
// interface from a second host name to test it the way the app runs it. An
|
||||||
|
// operator has no reason to set it.
|
||||||
|
AppOrigins []string
|
||||||
|
|
||||||
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
|
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
|
||||||
// from its own interface rather than from another browser tab.
|
// from its own interface rather than from another browser tab.
|
||||||
//
|
//
|
||||||
@@ -161,11 +171,19 @@ func Load() (*Config, error) {
|
|||||||
Edition: env("VERDANDE_EDITION", EditionFull),
|
Edition: env("VERDANDE_EDITION", EditionFull),
|
||||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||||
}
|
}
|
||||||
|
for _, o := range strings.Split(env("VERDANDE_APP_ORIGINS", ""), ",") {
|
||||||
|
if o = strings.TrimRight(strings.TrimSpace(o), "/"); o != "" {
|
||||||
|
c.AppOrigins = append(c.AppOrigins, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil {
|
if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil {
|
||||||
return nil, err
|
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 {
|
if c.InviteTTL, err = envDuration("VERDANDE_INVITE_TTL", 7*24*time.Hour); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ts *testServer) corsDo(t *testing.T, method, path, origin string, header map[string]string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(method, ts.URL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if origin != "" {
|
||||||
|
req.Header.Set("Origin", origin)
|
||||||
|
}
|
||||||
|
for k, v := range header {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSAnswersTheAppAndNobodyElse(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
preflight := map[string]string{
|
||||||
|
"Access-Control-Request-Method": "POST",
|
||||||
|
"Access-Control-Request-Headers": "authorization, content-type",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := ts.corsDo(t, "OPTIONS", "/api/v1/projects", "capacitor://localhost", preflight)
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("app preflight: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
||||||
|
t.Errorf("app preflight: Allow-Origin %q", got)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "Authorization") {
|
||||||
|
t.Errorf("app preflight: Allow-Headers %q lacks Authorization", got)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
|
||||||
|
t.Errorf("credentials allowed (%q): the app uses a bearer, never a cookie", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ordinary request from the app can be read.
|
||||||
|
resp = ts.corsDo(t, "GET", "/api/v1/ping", "capacitor://localhost", nil)
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
||||||
|
t.Errorf("app GET: Allow-Origin %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anybody else gets nothing, so the browser keeps them from reading.
|
||||||
|
for _, origin := range []string{"https://evil.example", "http://localhost", "capacitor://evil", "null"} {
|
||||||
|
resp = ts.corsDo(t, "OPTIONS", "/api/v1/projects", origin, preflight)
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Errorf("%s: preflight answered with Allow-Origin %q", origin, got)
|
||||||
|
}
|
||||||
|
resp = ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Errorf("%s: GET answered with Allow-Origin %q", origin, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The app asks this before anyone has signed in, to tell a Verdande from a
|
||||||
|
// router's login page.
|
||||||
|
func TestPingNamesTheProductAndEditionButNoVersion(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
anonymous := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||||
|
resp, body := anonymous.do(t, "GET", "/api/v1/ping", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("ping: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if body["product"] != "verdande" || body["edition"] != "full" || body["api"] != float64(1) {
|
||||||
|
t.Errorf("ping = %v", body)
|
||||||
|
}
|
||||||
|
if _, ok := body["version"]; ok {
|
||||||
|
t.Error("ping reveals the version to anybody")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialWS(ctx context.Context, url, origin string) (*websocket.Conn, *http.Response, error) {
|
||||||
|
h := http.Header{}
|
||||||
|
if origin != "" {
|
||||||
|
h.Set("Origin", origin)
|
||||||
|
}
|
||||||
|
return websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: h})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheAppOpensLiveSyncWithATicket(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
_, body := appLogin(t, ts)
|
||||||
|
token := body["session_token"].(string)
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Without a ticket or a cookie: refused.
|
||||||
|
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
|
||||||
|
t.Fatal("a socket opened with no credential")
|
||||||
|
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("no credential: %v", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.appDo(t, token, "POST", "/api/v1/ws-ticket", nil)
|
||||||
|
ticket, _ := body["ticket"].(string)
|
||||||
|
if resp.StatusCode != http.StatusOK || ticket == "" {
|
||||||
|
t.Fatalf("ws-ticket: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, _, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial with a ticket: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.CloseNow()
|
||||||
|
|
||||||
|
// And it is the right person's stream: their own new project arrives on it.
|
||||||
|
ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Live fra telefonen"})
|
||||||
|
_, msg, err := conn.Read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(msg), "Live fra telefonen") {
|
||||||
|
t.Errorf("first event was %s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spent: the same ticket does not open a second socket.
|
||||||
|
if _, resp, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost"); err == nil {
|
||||||
|
t.Error("a ticket opened two sockets")
|
||||||
|
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("reused ticket: %v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATicketExpires(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
_, body := appLogin(t, ts)
|
||||||
|
_, body = ts.appDo(t, body["session_token"].(string), "POST", "/api/v1/ws-ticket", nil)
|
||||||
|
ticket := body["ticket"].(string)
|
||||||
|
|
||||||
|
ts.api.wsTickets.mu.Lock()
|
||||||
|
v := ts.api.wsTickets.m[ticket]
|
||||||
|
v.expires = time.Now().Add(-time.Second)
|
||||||
|
ts.api.wsTickets.m[ticket] = v
|
||||||
|
ts.api.wsTickets.mu.Unlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws?ticket=" + ticket
|
||||||
|
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
|
||||||
|
t.Error("an expired ticket opened a socket")
|
||||||
|
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expired ticket: %v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cookie path is unchanged: a foreign page cannot open a socket that rides
|
||||||
|
// on the cookie, and the instance's own page can.
|
||||||
|
func TestACookieSocketStillChecksTheOrigin(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
var cookie string
|
||||||
|
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
|
||||||
|
if c.Name == sessionCookieInsecure {
|
||||||
|
cookie = c.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
dial := func(origin string) error {
|
||||||
|
h := http.Header{}
|
||||||
|
h.Set("Origin", origin)
|
||||||
|
h.Set("Cookie", sessionCookieInsecure+"="+cookie)
|
||||||
|
conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: h})
|
||||||
|
if err == nil {
|
||||||
|
conn.CloseNow()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := dial("https://evil.example"); err == nil {
|
||||||
|
t.Error("a foreign page opened a socket on the cookie")
|
||||||
|
}
|
||||||
|
if err := dial("capacitor://localhost"); err == nil {
|
||||||
|
t.Error("the app origin opened a socket on a cookie, without a ticket")
|
||||||
|
}
|
||||||
|
if err := dial("http://localhost"); err != nil {
|
||||||
|
t.Errorf("control: the instance's own origin: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The end-to-end suite runs the interface from a second host name and tells the
|
||||||
|
// server to count it as the app's. That origin is then the app's in every
|
||||||
|
// respect, and capacitor://localhost stays one too.
|
||||||
|
func TestConfiguredAppOriginsCountAsTheApp(t *testing.T) {
|
||||||
|
ts := newTestServerWith(t, func(c *config.Config) {
|
||||||
|
c.AppOrigins = []string{"http://app.localhost:8097"}
|
||||||
|
})
|
||||||
|
for _, origin := range []string{"http://app.localhost:8097", "capacitor://localhost"} {
|
||||||
|
resp := ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
||||||
|
t.Errorf("%s: Allow-Origin %q", origin, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not a prefix match: a neighbour on another port is nobody.
|
||||||
|
resp := ts.corsDo(t, "GET", "/api/v1/ping", "http://app.localhost:8098", nil)
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Errorf("a neighbouring origin was answered: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What the iOS app needs from a server that a browser gets for free by being on
|
||||||
|
// the same origin. The app loads its interface from capacitor://localhost and
|
||||||
|
// talks to whichever server its owner typed in, so every request it makes is
|
||||||
|
// cross-origin: it needs CORS to read the answers, a way to open the live-sync
|
||||||
|
// socket without a cookie, and a way to ask "is this a Verdande?" before anyone
|
||||||
|
// has signed in.
|
||||||
|
|
||||||
|
// appCORS answers the app's origin, and nobody else's.
|
||||||
|
//
|
||||||
|
// No Access-Control-Allow-Credentials: the app carries its session as a bearer
|
||||||
|
// token, never a cookie, so a response to a request with a cookie on it is not
|
||||||
|
// something this header ever needs to unlock. Every other origin gets no CORS
|
||||||
|
// headers at all, exactly as before — the browser then keeps a foreign page from
|
||||||
|
// reading anything, which is the default this must not loosen.
|
||||||
|
func (s *Server) appCORS(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if !s.isAppOrigin(origin) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h := w.Header()
|
||||||
|
h.Set("Access-Control-Allow-Origin", origin)
|
||||||
|
h.Add("Vary", "Origin")
|
||||||
|
// Read by the client: a rate limit's wait, and a download's file name.
|
||||||
|
h.Set("Access-Control-Expose-Headers", "Retry-After, Content-Disposition")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
|
||||||
|
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
|
||||||
|
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||||
|
h.Set("Access-Control-Max-Age", "600")
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the live-sync socket without a cookie -----------------------------------
|
||||||
|
|
||||||
|
// A browser cannot put a header on a WebSocket handshake, so the app's bearer
|
||||||
|
// token has no way onto it. Putting the token in the URL would work and would
|
||||||
|
// leave a credential that lasts months in every proxy log the URL passes. A
|
||||||
|
// ticket is the usual answer: asked for with the token, good for one connection,
|
||||||
|
// gone in thirty seconds.
|
||||||
|
|
||||||
|
const wsTicketTTL = 30 * time.Second
|
||||||
|
|
||||||
|
type wsTicket struct {
|
||||||
|
userID string
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type wsTickets struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]wsTicket
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWSTickets() *wsTickets { return &wsTickets{m: map[string]wsTicket{}} }
|
||||||
|
|
||||||
|
func (t *wsTickets) issue(userID string) (string, error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ticket := hex.EncodeToString(raw)
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
// Swept on the way in, so the map is bounded by thirty seconds of asking.
|
||||||
|
now := time.Now()
|
||||||
|
for k, v := range t.m {
|
||||||
|
if now.After(v.expires) {
|
||||||
|
delete(t.m, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.m[ticket] = wsTicket{userID: userID, expires: now.Add(wsTicketTTL)}
|
||||||
|
return ticket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// redeem spends a ticket. It is deleted before it is checked, so a second
|
||||||
|
// presentation finds nothing, whether the first one was in time or not.
|
||||||
|
func (t *wsTickets) redeem(ticket string) (string, bool) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
v, ok := t.m[ticket]
|
||||||
|
delete(t.m, ticket)
|
||||||
|
if !ok || time.Now().After(v.expires) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return v.userID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWSTicket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ticket, err := s.wsTickets.issue(userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, r, "issue websocket ticket", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ticket": ticket,
|
||||||
|
"expires_in": int(wsTicketTTL.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebSocketEntry opens the socket with a ticket, or falls through to the
|
||||||
|
// ordinary session check.
|
||||||
|
//
|
||||||
|
// A socket opened with a ticket skips the origin check, and that is the point
|
||||||
|
// rather than a gap: the origin check exists because a browser attaches the
|
||||||
|
// cookie to a handshake from any page. A ticket is attached by nobody — it has to
|
||||||
|
// be asked for with a credential and put in the URL on purpose — so there is no
|
||||||
|
// ambient authority for a foreign page to borrow.
|
||||||
|
func (s *Server) handleWebSocketEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ticket := r.URL.Query().Get("ticket")
|
||||||
|
if ticket == "" {
|
||||||
|
// The app never holds a cookie, so a handshake from its origin without a
|
||||||
|
// ticket is not the app. Refused here rather than left to the origin
|
||||||
|
// check, which compares host names only: capacitor://localhost and an
|
||||||
|
// instance served at http://localhost have the same one.
|
||||||
|
if s.isAppOrigin(r.Header.Get("Origin")) {
|
||||||
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "the app opens this socket with a ticket")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.requireAuth(http.HandlerFunc(s.handleWebSocket)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, ok := s.wsTickets.redeem(ticket)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that ticket is not valid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.db.UserByID(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, r, "load user", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(withUser(r.Context(), user), ctxTicket, true)
|
||||||
|
s.handleWebSocket(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- is this a Verdande? -----------------------------------------------------
|
||||||
|
|
||||||
|
// handlePing answers before anybody has signed in, which is when the app needs
|
||||||
|
// it: the person has typed an address and the app has to tell "that is a
|
||||||
|
// Verdande" from "that is a router's login page" before it shows a password
|
||||||
|
// field for it. The edition decides which half of the interface to show.
|
||||||
|
//
|
||||||
|
// No version. /version sits behind a login on purpose, and a public number is a
|
||||||
|
// list of the instances still running an old one.
|
||||||
|
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
"product": "verdande",
|
||||||
|
"edition": s.edition(),
|
||||||
|
"api": 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 {
|
type loginRequest struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
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 {
|
type loginResponse struct {
|
||||||
User *userJSON `json:"user,omitempty"`
|
User *userJSON `json:"user,omitempty"`
|
||||||
TOTPRequired bool `json:"totp_required"`
|
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) {
|
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 {
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
app, ok := appClient(w, req.Client, req.DeviceName)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
user, err := s.db.UserByEmail(r.Context(), req.Email)
|
user, err := s.db.UserByEmail(r.Context(), req.Email)
|
||||||
if err != nil || auth.VerifyPassword(user.PasswordHash, req.Password) != nil {
|
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
|
// 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.
|
// to fetch a phone, not long enough to be worth stealing.
|
||||||
ttl := s.cfg.SessionTTL
|
ttl := s.cfg.SessionTTL
|
||||||
|
if app {
|
||||||
|
ttl = s.cfg.AppSessionTTL
|
||||||
|
}
|
||||||
if user.TOTPEnabled {
|
if user.TOTPEnabled {
|
||||||
ttl = 10 * time.Minute
|
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 {
|
if err != nil {
|
||||||
s.internal(w, r, "create session", err)
|
s.internal(w, r, "create session", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setSessionCookie(w, token, ttl)
|
|
||||||
|
|
||||||
|
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 {
|
if user.TOTPEnabled {
|
||||||
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
|
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
|
return
|
||||||
}
|
}
|
||||||
|
resp.SessionToken = token
|
||||||
|
} else {
|
||||||
|
s.setSessionCookie(w, token, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.TOTPEnabled {
|
||||||
uj := toUserJSON(user)
|
uj := toUserJSON(user)
|
||||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
resp.User = &uj
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
type totpRequest struct {
|
type totpRequest struct {
|
||||||
@@ -305,7 +356,19 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.loginLimiter.reset(clientIP(r))
|
s.loginLimiter.reset(clientIP(r))
|
||||||
s.totpLimiter.reset(user.ID)
|
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)
|
s.internal(w, r, "promote session", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
|||||||
BaseURL: "http://localhost",
|
BaseURL: "http://localhost",
|
||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
SessionTTL: 24 * time.Hour,
|
SessionTTL: 24 * time.Hour,
|
||||||
|
AppSessionTTL: 7 * 24 * time.Hour,
|
||||||
InviteTTL: 7 * 24 * time.Hour,
|
InviteTTL: 7 * 24 * time.Hour,
|
||||||
ResetTTL: time.Hour,
|
ResetTTL: time.Hour,
|
||||||
Dev: true,
|
Dev: true,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type ctxKey int
|
|||||||
const (
|
const (
|
||||||
ctxUser ctxKey = iota
|
ctxUser ctxKey = iota
|
||||||
ctxSession
|
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
|
// 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
|
// permissions exactly — there is no separate service identity to reason about, and
|
||||||
// a script therefore cannot reach anything its owner could not.
|
// a script therefore cannot reach anything its owner could not.
|
||||||
// errNoCredentials means the request carried nothing to authenticate with, as
|
// 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
|
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())
|
cookie, err := r.Cookie(s.cookieName())
|
||||||
if err != nil || cookie.Value == "" {
|
if err != nil || cookie.Value == "" {
|
||||||
return nil, nil, errNoCredentials
|
return nil, nil, errNoCredentials
|
||||||
@@ -200,6 +218,14 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
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 s.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.
|
// Sec-Fetch-Site is set by the browser and cannot be forged by page script.
|
||||||
switch r.Header.Get("Sec-Fetch-Site") {
|
switch r.Header.Get("Sec-Fetch-Site") {
|
||||||
@@ -220,6 +246,28 @@ 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 (s *Server) isAppOrigin(origin string) bool {
|
||||||
|
if origin == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, o := range appOrigins {
|
||||||
|
if origin == o {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, o := range s.cfg.AppOrigins {
|
||||||
|
if origin == o {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) isOwnOrigin(origin string) bool {
|
func (s *Server) isOwnOrigin(origin string) bool {
|
||||||
if strings.EqualFold(origin, s.cfg.BaseURL) {
|
if strings.EqualFold(origin, s.cfg.BaseURL) {
|
||||||
return true
|
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) {
|
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
user := userFrom(r.Context())
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
ticketed, _ := r.Context().Value(ctxTicket).(bool)
|
||||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||||
// The socket is same-origin only. Without this, any page on the internet
|
// 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 —
|
// could open a socket carrying the user's cookie and watch their tasks —
|
||||||
// the same-origin policy does not apply to WebSocket handshakes.
|
// the same-origin policy does not apply to WebSocket handshakes.
|
||||||
OriginPatterns: s.allowedOrigins(),
|
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 {
|
if err != nil {
|
||||||
s.log.Debug("websocket handshake", "err", err)
|
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.
|
// instance should not be unreachable because one of them cannot be offered.
|
||||||
passkeys *auth.WebAuthn
|
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
|
// Password guessing is the attack this application is actually exposed to, so
|
||||||
// the endpoints that check a secret are limited separately and more tightly
|
// the endpoints that check a secret are limited separately and more tightly
|
||||||
// than the rest of the API.
|
// 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
|
// 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.
|
// generous for a person and hopeless for a search through a million.
|
||||||
totpLimiter: newLimiter(10, time.Hour),
|
totpLimiter: newLimiter(10, time.Hour),
|
||||||
|
wsTickets: newWSTickets(),
|
||||||
}
|
}
|
||||||
// Before the CSP, deliberately: the policy has to describe the bytes that go
|
// Before the CSP, deliberately: the policy has to describe the bytes that go
|
||||||
// out, and in the notes edition those are the renamed ones.
|
// 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) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
// Before the CSRF lock, so a preflight is answered and never reaches it.
|
||||||
|
r.Use(s.appCORS)
|
||||||
r.Use(s.requireCSRF)
|
r.Use(s.requireCSRF)
|
||||||
|
|
||||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/ping", s.handlePing)
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
||||||
})
|
// 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
|
// 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
|
// 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()
|
full := !s.cfg.NotesOnly()
|
||||||
r.Use(s.requireAuth)
|
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
|
// The MCP endpoint. Reachable with a personal API token, so a connector
|
||||||
// sees exactly what its owner sees.
|
// sees exactly what its owner sees.
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ type sessionJSON struct {
|
|||||||
// when the summary is not enough.
|
// when the summary is not enough.
|
||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
UserAgent string `json:"user_agent"`
|
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"`
|
IP string `json:"ip"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
LastSeenAt string `json:"last_seen_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))
|
out := make([]sessionJSON, 0, len(sessions))
|
||||||
for _, sess := range 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{
|
out = append(out, sessionJSON{
|
||||||
ID: sess.ID,
|
ID: sess.ID,
|
||||||
Current: current != nil && sess.ID == current.ID,
|
Current: current != nil && sess.ID == current.ID,
|
||||||
Device: describeUserAgent(sess.UA),
|
Device: device,
|
||||||
UserAgent: sess.UA,
|
UserAgent: sess.UA,
|
||||||
|
Client: sess.Client,
|
||||||
IP: sess.IP,
|
IP: sess.IP,
|
||||||
CreatedAt: sess.CreatedAt.Format(time.RFC3339),
|
CreatedAt: sess.CreatedAt.Format(time.RFC3339),
|
||||||
LastSeenAt: sess.LastSeenAt.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;
|
||||||
+66
-27
@@ -15,11 +15,38 @@ type Session struct {
|
|||||||
UA string
|
UA string
|
||||||
IP string
|
IP string
|
||||||
PendingTOTP bool
|
PendingTOTP bool
|
||||||
|
// 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
|
CreatedAt time.Time
|
||||||
LastSeenAt time.Time
|
LastSeenAt time.Time
|
||||||
ExpiresAt 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.
|
// CreateSession issues a session and returns the value to put in the cookie.
|
||||||
//
|
//
|
||||||
// Only the hash is stored. A session cookie is a bearer credential — whoever holds
|
// Only the hash is stored. A session cookie is a bearer credential — whoever holds
|
||||||
@@ -40,6 +67,7 @@ func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time
|
|||||||
UA: truncate(ua, 500),
|
UA: truncate(ua, 500),
|
||||||
IP: truncate(ip, 100),
|
IP: truncate(ip, 100),
|
||||||
PendingTOTP: pendingTOTP,
|
PendingTOTP: pendingTOTP,
|
||||||
|
Client: "web",
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
LastSeenAt: now,
|
LastSeenAt: now,
|
||||||
ExpiresAt: now.Add(ttl),
|
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) {
|
func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User, error) {
|
||||||
id := auth.HashToken(token)
|
id := auth.HashToken(token)
|
||||||
|
|
||||||
var s Session
|
s, err := scanSession(db.QueryRowContext(ctx,
|
||||||
var pending int
|
`SELECT `+sessionColumns+` FROM sessions WHERE id = ?`, id))
|
||||||
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)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil, ErrNotFound
|
return nil, nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -77,11 +100,6 @@ func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User
|
|||||||
return nil, nil, err
|
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) {
|
if time.Now().After(s.ExpiresAt) {
|
||||||
_, _ = db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, s.ID)
|
_, _ = db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, s.ID)
|
||||||
return nil, nil, ErrNotFound
|
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
|
// 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
|
// 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.
|
// 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 {
|
if time.Since(s.LastSeenAt) > time.Minute {
|
||||||
|
now := time.Now()
|
||||||
|
if s.IdleTTL > 0 && !s.PendingTOTP {
|
||||||
|
s.ExpiresAt = now.Add(s.IdleTTL).UTC()
|
||||||
_, _ = db.ExecContext(ctx,
|
_, _ = db.ExecContext(ctx,
|
||||||
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), s.ID)
|
`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
|
// 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
|
// its clock restarts, so the full lifetime is counted from finishing the login and
|
||||||
// not from typing the password.
|
// 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,
|
res, err := db.ExecContext(ctx,
|
||||||
`UPDATE sessions SET pending_totp = 0, expires_at = ? WHERE id = ? AND pending_totp = 1`,
|
`UPDATE sessions SET pending_totp = 0, expires_at = ?, idle_ttl = ? WHERE id = ? AND pending_totp = 1`,
|
||||||
time.Now().Add(ttl).Unix(), sessionID)
|
time.Now().Add(ttl).Unix(), int64(idle/time.Second), sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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) {
|
func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error) {
|
||||||
rows, err := db.QueryContext(ctx,
|
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)
|
FROM sessions WHERE user_id = ? AND pending_totp = 0 ORDER BY last_seen_at DESC`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -156,17 +201,11 @@ func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error
|
|||||||
|
|
||||||
var out []Session
|
var out []Session
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s Session
|
s, err := scanSession(rows)
|
||||||
var pending int
|
if err != nil {
|
||||||
var created, lastSeen, expires int64
|
|
||||||
if err := rows.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.PendingTOTP = pending == 1
|
out = append(out, *s)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { USER } from './user.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interface the way the iOS app runs it: from a different origin than the
|
||||||
|
* server, with no cookie, its session as a bearer token.
|
||||||
|
*
|
||||||
|
* The real app is served from capacitor://localhost, which a test browser cannot
|
||||||
|
* open. `app.localhost` stands in for it — Chromium sends every *.localhost to
|
||||||
|
* the loopback address, so it is this same server, but to the browser it is a
|
||||||
|
* different origin, and that difference is the whole thing under test. The server
|
||||||
|
* is told to treat it as the app's origin (VERDANDE_APP_ORIGINS in the config),
|
||||||
|
* and a stub of Capacitor's bridge makes the interface believe it is in the app.
|
||||||
|
*
|
||||||
|
* bypassCSP because the server's policy is written for its own pages
|
||||||
|
* (`connect-src 'self'`); the app's pages are inside the app and never carry it.
|
||||||
|
* Without this the browser would block exactly the cross-origin calls the test
|
||||||
|
* exists to make, and the failure would read as the app not working.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PORT = 8097;
|
||||||
|
const SERVER = `http://localhost:${PORT}`;
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
baseURL: `http://app.localhost:${PORT}`,
|
||||||
|
bypassCSP: true,
|
||||||
|
storageState: { cookies: [], origins: [] }
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.Capacitor = { isNativePlatform: () => true };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The app's session, read the way the app reads it. */
|
||||||
|
const storedToken = (page) =>
|
||||||
|
page.evaluate(() => localStorage.getItem('verdande.app.session') ?? '');
|
||||||
|
|
||||||
|
test('appen vælger sin server, logger ind med et token og følger med live', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// First the address — and an address with nothing behind it is said to be so,
|
||||||
|
// rather than leading to a sign-in form that cannot succeed.
|
||||||
|
await expect(page.getByText('Hvilken Verdande vil du logge ind på?')).toBeVisible();
|
||||||
|
await page.getByLabel(/Serveradresse/).fill('http://localhost:1');
|
||||||
|
await page.getByRole('button', { name: 'Fortsæt' }).click();
|
||||||
|
await expect(page.getByText(/Kunne ikke nå localhost:1/)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(/Serveradresse/).fill(SERVER);
|
||||||
|
await page.getByRole('button', { name: 'Fortsæt' }).click();
|
||||||
|
|
||||||
|
// The sign-in form, saying where it signs in to.
|
||||||
|
await expect(page.getByText(`localhost:${PORT}`)).toBeVisible();
|
||||||
|
await page.getByLabel(/E-mail/).fill(USER.email);
|
||||||
|
await page.getByLabel(/Adgangskode/).fill(USER.password);
|
||||||
|
await page.getByRole('button', { name: 'Log ind', exact: true }).click();
|
||||||
|
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||||
|
|
||||||
|
// Signed in by token, and by nothing else: no session cookie on either origin.
|
||||||
|
const token = await storedToken(page);
|
||||||
|
expect(token).not.toBe('');
|
||||||
|
const cookies = await page.context().cookies();
|
||||||
|
expect(cookies.filter((c) => c.name.includes('verdande_session'))).toEqual([]);
|
||||||
|
|
||||||
|
// Live sync over a ticketed socket: a project made behind the interface's back
|
||||||
|
// — straight to the API, as another device would — shows up in the sidebar
|
||||||
|
// without a reload. Nothing but the socket can have put it there.
|
||||||
|
const name = `Fra en anden enhed ${Date.now()}`;
|
||||||
|
const status = await page.evaluate(
|
||||||
|
async ({ server, token, name }) => {
|
||||||
|
const res = await fetch(`${server}/api/v1/projects`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
return res.status;
|
||||||
|
},
|
||||||
|
{ server: SERVER, token, name }
|
||||||
|
);
|
||||||
|
expect(status).toBe(201);
|
||||||
|
await expect(page.getByRole('navigation', { name: 'Hovedmenu' }).getByText(name)).toBeVisible();
|
||||||
|
|
||||||
|
// A picture from the server, which an <img> cannot fetch with a token. It is
|
||||||
|
// shown from a blob, and the path it came from is kept for the note to save.
|
||||||
|
const path = await page.evaluate(
|
||||||
|
async ({ server, token }) => {
|
||||||
|
const auth = { Authorization: `Bearer ${token}` };
|
||||||
|
const note = await fetch(`${server}/api/v1/notes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...auth, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ body: '# Billede fra appen' })
|
||||||
|
}).then((r) => r.json());
|
||||||
|
// A one-pixel PNG.
|
||||||
|
const png = Uint8Array.from(
|
||||||
|
atob(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=='
|
||||||
|
),
|
||||||
|
(c) => c.charCodeAt(0)
|
||||||
|
);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png');
|
||||||
|
const saved = await fetch(`${server}/api/v1/notes/${note.id}/attachments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: auth,
|
||||||
|
body: form
|
||||||
|
}).then((r) => r.json());
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.id = 'appbillede';
|
||||||
|
img.setAttribute('src', saved.url);
|
||||||
|
document.body.append(img);
|
||||||
|
return saved.url;
|
||||||
|
},
|
||||||
|
{ server: SERVER, token }
|
||||||
|
);
|
||||||
|
const img = page.locator('#appbillede');
|
||||||
|
await expect(img).toHaveAttribute('data-src', path);
|
||||||
|
await expect(img).toHaveAttribute('src', /^blob:/);
|
||||||
|
await expect.poll(() => img.evaluate((el) => el.naturalWidth)).toBe(1);
|
||||||
|
|
||||||
|
// Signing out takes the token off the phone, and keeps the server.
|
||||||
|
await page.evaluate(() => document.getElementById('appbillede')?.remove());
|
||||||
|
// Two taps: the first asks, the second signs out.
|
||||||
|
await page.getByRole('button', { name: /Log ud/ }).click();
|
||||||
|
await page.getByRole('button', { name: /Tryk igen for at logge ud/ }).click();
|
||||||
|
await expect(page.getByText(`localhost:${PORT}`)).toBeVisible();
|
||||||
|
expect(await storedToken(page)).toBe('');
|
||||||
|
|
||||||
|
// And the token is dead on the server too, not just forgotten here.
|
||||||
|
const after = await page.evaluate(
|
||||||
|
async ({ server, token }) =>
|
||||||
|
(await fetch(`${server}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` } }))
|
||||||
|
.status,
|
||||||
|
{ server: SERVER, token }
|
||||||
|
);
|
||||||
|
expect(after).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs in the way the app does, outside the page, and hands the page the server
|
||||||
|
* and the session as the app would have stored them.
|
||||||
|
*/
|
||||||
|
async function signedInApp(page) {
|
||||||
|
const origin = { Origin: `http://app.localhost:${PORT}` };
|
||||||
|
const res = await fetch(`${SERVER}/api/v1/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...origin, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: USER.email,
|
||||||
|
password: USER.password,
|
||||||
|
client: 'app',
|
||||||
|
device_name: 'Prøve-iPhone'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const { session_token: token } = await res.json();
|
||||||
|
expect(token).toBeTruthy();
|
||||||
|
await page.addInitScript(
|
||||||
|
({ server, token }) => {
|
||||||
|
localStorage.setItem('verdande.app.server', server);
|
||||||
|
localStorage.setItem('verdande.app.session', token);
|
||||||
|
},
|
||||||
|
{ server: SERVER, token }
|
||||||
|
);
|
||||||
|
const call = async (method, path, body) => {
|
||||||
|
const init = { method, headers: { ...origin, Authorization: `Bearer ${token}` } };
|
||||||
|
if (body instanceof FormData) init.body = body;
|
||||||
|
else if (body !== undefined) {
|
||||||
|
init.headers['Content-Type'] = 'application/json';
|
||||||
|
init.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const r = await fetch(`${SERVER}/api/v1${path}`, init);
|
||||||
|
return r.status === 204 ? null : r.json();
|
||||||
|
};
|
||||||
|
return call;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The picture in a note is shown from a blob made on the phone. Editing the note
|
||||||
|
* turns the page back into markdown, and what it must write is the path the
|
||||||
|
* picture came from — a blob address saved into a note is a picture that is gone
|
||||||
|
* the next time anybody opens it, on any device.
|
||||||
|
*/
|
||||||
|
test('en note med et billede gemmer stien, ikke blob-adressen, når den redigeres i appen', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const call = await signedInApp(page);
|
||||||
|
|
||||||
|
const note = await call('POST', '/notes', { body: '# Billednote fra appen' });
|
||||||
|
const png = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg==',
|
||||||
|
'base64'
|
||||||
|
);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png');
|
||||||
|
const saved = await call('POST', `/notes/${note.id}/attachments`, form);
|
||||||
|
await call('PATCH', `/notes/${note.id}`, {
|
||||||
|
body: `# Billednote fra appen\n\n\n\nTekst under billedet`
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/noter?note=' + note.id);
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
const img = ed.locator('img');
|
||||||
|
await expect(img).toHaveAttribute('src', /^blob:/);
|
||||||
|
await expect(img).toHaveAttribute('data-src', saved.url);
|
||||||
|
|
||||||
|
// Touch the note so it saves, and wait for the save itself.
|
||||||
|
const patched = page.waitForResponse(
|
||||||
|
(r) => r.request().method() === 'PATCH' && /\/notes\/[^/]+$/.test(new URL(r.url()).pathname) && r.ok()
|
||||||
|
);
|
||||||
|
await ed.getByText('Tekst under billedet').click();
|
||||||
|
await page.keyboard.press('End');
|
||||||
|
await page.keyboard.type(' og mere');
|
||||||
|
await patched;
|
||||||
|
|
||||||
|
const after = await call('GET', `/notes/${note.id}`);
|
||||||
|
expect(after.body).toContain('og mere');
|
||||||
|
expect(after.body).toContain(`](${saved.url})`);
|
||||||
|
expect(after.body).not.toContain('blob:');
|
||||||
|
});
|
||||||
@@ -5440,6 +5440,84 @@ test('et indsat billede kan åbnes stort og kopieres', async ({ page, context })
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
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.
|
* En opgaves tekst kan læses færdig, også på en telefon.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ export default defineConfig({
|
|||||||
// Alt undtagen noter-udgaven, som har sin egen server på sin egen port.
|
// Alt undtagen noter-udgaven, som har sin egen server på sin egen port.
|
||||||
// Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor
|
// Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor
|
||||||
// den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker.
|
// den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker.
|
||||||
testIgnore: /edition\.spec\.js/,
|
// Og app-prøven, der kører fladen fra en anden origin med sin egen login.
|
||||||
|
testIgnore: /(edition|app)\.spec\.js/,
|
||||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
|
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
|
||||||
dependencies: ['setup']
|
dependencies: ['setup']
|
||||||
},
|
},
|
||||||
@@ -120,6 +121,19 @@ export default defineConfig({
|
|||||||
use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` }
|
use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fladen, som iOS-appen kører den: fra en anden origin end serveren, uden
|
||||||
|
* cookie, med sessionen som bearer. Se app.spec.js. Afhænger af `setup`,
|
||||||
|
* fordi den logger ind på kontoen, opsætningen lavede — men låner ikke dens
|
||||||
|
* cookie, for det er netop det, appen ikke har.
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
name: 'app-origin',
|
||||||
|
testMatch: /app\.spec\.js/,
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
dependencies: ['setup']
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'webkit-forms',
|
name: 'webkit-forms',
|
||||||
grep: /@forms/,
|
grep: /@forms/,
|
||||||
@@ -164,7 +178,10 @@ export default defineConfig({
|
|||||||
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
||||||
VERDANDE_DATA_DIR: dataDir,
|
VERDANDE_DATA_DIR: dataDir,
|
||||||
// Off: an update check would reach out to GitHub from a test run.
|
// Off: an update check would reach out to GitHub from a test run.
|
||||||
VERDANDE_UPDATE_CHECK: 'false'
|
VERDANDE_UPDATE_CHECK: 'false',
|
||||||
|
// Den origin, app-origin-projektet kører fladen fra, regnet som appens
|
||||||
|
// egen — som capacitor://localhost er det i den rigtige app.
|
||||||
|
VERDANDE_APP_ORIGINS: `http://app.localhost:${PORT}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+33
-6
@@ -24,6 +24,7 @@ export class ApiError extends Error {
|
|||||||
|
|
||||||
import { t } from './i18n.svelte.js';
|
import { t } from './i18n.svelte.js';
|
||||||
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
|
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
|
||||||
|
import { apiURL, appSession, authorize, deviceName, isNativeApp } from './platform.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long a request keeps retrying a network failure before giving up.
|
* How long a request keeps retrying a network failure before giving up.
|
||||||
@@ -152,19 +153,21 @@ async function request(method, path, body, options = {}) {
|
|||||||
const init = {
|
const init = {
|
||||||
method,
|
method,
|
||||||
headers: {},
|
headers: {},
|
||||||
// The session cookie is httpOnly and same-origin; nothing here handles a token.
|
|
||||||
credentials: 'same-origin',
|
|
||||||
signal: options.signal
|
signal: options.signal
|
||||||
};
|
};
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
init.headers['Content-Type'] = 'application/json';
|
init.headers['Content-Type'] = 'application/json';
|
||||||
init.body = JSON.stringify(body);
|
init.body = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
|
// In a browser: the httpOnly session cookie, same-origin, and nothing here
|
||||||
|
// handles a token. In the iOS app: its session token as a bearer. See
|
||||||
|
// platform.js.
|
||||||
|
authorize(init);
|
||||||
|
|
||||||
// Retries a network failure for a few seconds so a short drop does not lose the
|
// Retries a network failure for a few seconds so a short drop does not lose the
|
||||||
// call; `withRetry` turns an exhausted window into the same `offline` error the
|
// call; `withRetry` turns an exhausted window into the same `offline` error the
|
||||||
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
|
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
|
||||||
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
|
const res = await withRetry(() => fetch(apiURL(path), init), options.noRetry);
|
||||||
|
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
|
|
||||||
@@ -205,7 +208,7 @@ async function upload(path, file, field = 'file') {
|
|||||||
form.append(field, file);
|
form.append(field, file);
|
||||||
|
|
||||||
const res = await withRetry(() =>
|
const res = await withRetry(() =>
|
||||||
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
|
fetch(apiURL(path), authorize({ method: 'POST', body: form }))
|
||||||
);
|
);
|
||||||
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
@@ -232,9 +235,30 @@ export const api = {
|
|||||||
// --- auth
|
// --- auth
|
||||||
setupState: () => get('/auth/setup'),
|
setupState: () => get('/auth/setup'),
|
||||||
setup: (data) => post('/auth/setup', data),
|
setup: (data) => post('/auth/setup', data),
|
||||||
login: (email, password) => post('/auth/login', { email, password }),
|
login: async (email, password) => {
|
||||||
|
if (!isNativeApp()) return post('/auth/login', { email, password });
|
||||||
|
// The app gets its session in the answer instead of a cookie, and keeps
|
||||||
|
// it — also while a code is still owed, because the second step is sent
|
||||||
|
// with it.
|
||||||
|
const result = await post('/auth/login', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
client: 'app',
|
||||||
|
device_name: deviceName()
|
||||||
|
});
|
||||||
|
if (result?.session_token) appSession.setToken(result.session_token);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
loginTOTP: (code) => post('/auth/login/totp', { code }),
|
loginTOTP: (code) => post('/auth/login/totp', { code }),
|
||||||
logout: () => post('/auth/logout'),
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
return await post('/auth/logout');
|
||||||
|
} finally {
|
||||||
|
// Gone from the phone whether or not the server heard: a sign-out that
|
||||||
|
// left the token behind because the network was down is not one.
|
||||||
|
if (isNativeApp()) appSession.signOut();
|
||||||
|
}
|
||||||
|
},
|
||||||
me: () => get('/auth/me'),
|
me: () => get('/auth/me'),
|
||||||
updateProfile: (data) => patch('/auth/me', data),
|
updateProfile: (data) => patch('/auth/me', data),
|
||||||
// Its own route rather than a field on the profile: that one backs a form with
|
// Its own route rather than a field on the profile: that one backs a form with
|
||||||
@@ -421,6 +445,9 @@ export const api = {
|
|||||||
post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }),
|
post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }),
|
||||||
deleteToken: (id) => del(`/tokens/${id}`),
|
deleteToken: (id) => del(`/tokens/${id}`),
|
||||||
|
|
||||||
|
// --- live sync, for the app (see stores.svelte.js)
|
||||||
|
wsTicket: () => post('/ws-ticket'),
|
||||||
|
|
||||||
// --- notifications and push
|
// --- notifications and push
|
||||||
listNotifications: () => get('/notifications'),
|
listNotifications: () => get('/notifications'),
|
||||||
markNotificationsRead: (id) => post(id ? `/notifications/${id}/read` : '/notifications/read'),
|
markNotificationsRead: (id) => post(id ? `/notifications/${id}/read` : '/notifications/read'),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
* unforgivable. When the replacement (the EditContext API) is everywhere, this
|
* unforgivable. When the replacement (the EditContext API) is everywhere, this
|
||||||
* is the file to change.
|
* is the file to change.
|
||||||
*/
|
*/
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
|
import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
|
||||||
import { highlight, guessLanguage } from '$lib/highlight.js';
|
import { highlight, guessLanguage } from '$lib/highlight.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
@@ -756,6 +757,8 @@
|
|||||||
if (image) {
|
if (image) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
|
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
|
||||||
|
zoomed = false;
|
||||||
|
natural = null;
|
||||||
copied = false;
|
copied = false;
|
||||||
copyError = '';
|
copyError = '';
|
||||||
return;
|
return;
|
||||||
@@ -769,6 +772,49 @@
|
|||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let copyError = $state('');
|
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() {
|
function closeImage() {
|
||||||
showing = null;
|
showing = null;
|
||||||
// Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke
|
// 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. -->
|
af netop det ark, det skal lægge sig over. -->
|
||||||
{#if showing}
|
{#if showing}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
<!-- 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 -->
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||||
<div class="shot" onclick={(e) => e.stopPropagation()}>
|
<div class="tools" 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>
|
<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>
|
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
|
||||||
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
|
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
|
||||||
</div>
|
|
||||||
{#if copyError}<p class="bad">{copyError}</p>{/if}
|
{#if copyError}<p class="bad">{copyError}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1318,43 +1384,77 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Over alt andet og midt på skærmen. Baggrunden er mørk nok til at billedet
|
/* 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 {
|
.lightbox {
|
||||||
|
--pad: var(--s4);
|
||||||
|
--bar: 4rem;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
background: rgb(0 0 0 / 0.8);
|
background: rgb(0 0 0 / 0.85);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
padding: var(--pad) var(--pad) calc(var(--bar) + var(--pad));
|
||||||
justify-content: center;
|
overflow: auto;
|
||||||
padding: var(--s4);
|
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 {
|
.shot {
|
||||||
display: flex;
|
margin: auto;
|
||||||
flex-direction: column;
|
flex: none;
|
||||||
align-items: center;
|
max-width: calc(100vw - 2 * var(--pad));
|
||||||
gap: var(--s3);
|
max-height: calc(100dvh - 2 * var(--pad) - var(--bar));
|
||||||
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;
|
|
||||||
border-radius: var(--radius);
|
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 {
|
.tools {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: var(--s3);
|
||||||
|
transform: translateX(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--s2);
|
gap: var(--s2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100vw - 2 * var(--s3));
|
||||||
}
|
}
|
||||||
|
|
||||||
.tools button,
|
.tools button,
|
||||||
@@ -1374,6 +1474,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bad {
|
.bad {
|
||||||
|
flex-basis: 100%;
|
||||||
|
text-align: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
|
|||||||
@@ -21,9 +21,56 @@
|
|||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
import { available, signIn } from '$lib/passkey.js';
|
import { available, signIn } from '$lib/passkey.js';
|
||||||
|
import { appSession, isNativeApp, normaliseServer } from '$lib/platform.js';
|
||||||
|
|
||||||
// loading | login | setup | totp | forgot | sent | invite | reset | done
|
// loading | server | login | setup | totp | forgot | sent | invite | reset | done
|
||||||
let mode = $state('loading');
|
let mode = $state('loading');
|
||||||
|
|
||||||
|
// --- which server (the iOS app only) ------------------------------------------
|
||||||
|
|
||||||
|
const native = isNativeApp();
|
||||||
|
let server = $state(native ? appSession.server() : '');
|
||||||
|
let serverHost = $derived(server ? server.replace(/^https?:\/\//, '') : '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app has no server of its own. Before a password field is shown for an
|
||||||
|
* address somebody typed, the address has to be a Verdande: asked with /ping,
|
||||||
|
* which answers without a login and names the product. A router's login page,
|
||||||
|
* a typo that lands on somebody's blog, a server that is down — each gets its
|
||||||
|
* own sentence here instead of a sign-in form that cannot succeed.
|
||||||
|
*/
|
||||||
|
async function chooseServer() {
|
||||||
|
const url = normaliseServer(server);
|
||||||
|
if (!url) {
|
||||||
|
error = t('app.serverInvalid');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let answer;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/api/v1/ping`, { credentials: 'omit' });
|
||||||
|
answer = res.ok ? await res.json() : null;
|
||||||
|
} catch {
|
||||||
|
error = t('app.serverUnreachable', { host: url.replace(/^https?:\/\//, '') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (answer?.product !== 'verdande') {
|
||||||
|
error = t('app.notVerdande');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appSession.setServer(url);
|
||||||
|
server = url;
|
||||||
|
await enterServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks the chosen server what it wants: sign-in, or — in a browser — setup. */
|
||||||
|
async function enterServer() {
|
||||||
|
try {
|
||||||
|
const state = await api.setupState();
|
||||||
|
mode = state.needs_setup ? 'setup' : 'login';
|
||||||
|
} catch {
|
||||||
|
mode = 'login';
|
||||||
|
}
|
||||||
|
}
|
||||||
let email = $state('');
|
let email = $state('');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
@@ -37,6 +84,9 @@
|
|||||||
let passkeyReady = $state(false);
|
let passkeyReady = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// Not in the app: a passkey is bound to the server's own domain, and the
|
||||||
|
// app's pages are not on it. Offered there it would fail every time.
|
||||||
|
if (native) return;
|
||||||
available().then((ok) => (passkeyReady = ok));
|
available().then((ok) => (passkeyReady = ok));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,10 +144,11 @@
|
|||||||
mode = linkMode;
|
mode = linkMode;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api
|
if (native && !appSession.server()) {
|
||||||
.setupState()
|
mode = 'server';
|
||||||
.then((state) => (mode = state.needs_setup ? 'setup' : 'login'))
|
return;
|
||||||
.catch(() => (mode = 'login'));
|
}
|
||||||
|
enterServer();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function run(fn) {
|
async function run(fn) {
|
||||||
@@ -120,6 +171,10 @@
|
|||||||
|
|
||||||
run(async () => {
|
run(async () => {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
|
case 'server':
|
||||||
|
await chooseServer();
|
||||||
|
break;
|
||||||
|
|
||||||
case 'setup':
|
case 'setup':
|
||||||
await api.setup({
|
await api.setup({
|
||||||
email,
|
email,
|
||||||
@@ -190,6 +245,34 @@
|
|||||||
|
|
||||||
{#if mode === 'loading'}
|
{#if mode === 'loading'}
|
||||||
<p class="lede"> </p>
|
<p class="lede"> </p>
|
||||||
|
{:else if mode === 'server'}
|
||||||
|
<p class="lede">{t('app.serverPrompt')}</p>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
{t('app.server')}
|
||||||
|
<input
|
||||||
|
bind:value={server}
|
||||||
|
use:focusOnMount
|
||||||
|
type="url"
|
||||||
|
inputmode="url"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="verdande.example.dk"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span class="hint">{t('app.serverHint')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" disabled={busy}>{t('app.connect')}</button>
|
||||||
|
{:else if mode === 'setup' && native}
|
||||||
|
<!-- The first account starts a browser session, and the app has no use
|
||||||
|
for a cookie. An instance this new is being set up by whoever runs
|
||||||
|
it, at a desk; they do it there, and the app signs in after. -->
|
||||||
|
<p class="lede">{t('app.setupInBrowser', { host: serverHost })}</p>
|
||||||
|
<button type="button" class="link" onclick={() => (mode = 'server')}>
|
||||||
|
{t('app.changeServer')}
|
||||||
|
</button>
|
||||||
{:else if mode === 'setup'}
|
{:else if mode === 'setup'}
|
||||||
<p class="lede">{t('auth.firstAccount')}</p>
|
<p class="lede">{t('auth.firstAccount')}</p>
|
||||||
|
|
||||||
@@ -303,6 +386,15 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<p class="lede">{t('auth.signInPrompt')}</p>
|
<p class="lede">{t('auth.signInPrompt')}</p>
|
||||||
|
|
||||||
|
{#if native}
|
||||||
|
<p class="server-line">
|
||||||
|
<span class="mono">{serverHost}</span>
|
||||||
|
<button type="button" class="link" onclick={() => (mode = 'server')}>
|
||||||
|
{t('app.changeServer')}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
{t('auth.email')}
|
{t('auth.email')}
|
||||||
<input bind:value={email} type="email" autocomplete="username" required />
|
<input bind:value={email} type="email" autocomplete="username" required />
|
||||||
@@ -463,6 +555,23 @@
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Which server the app is signing in to, and the way back to choosing. */
|
||||||
|
.server-line {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s2);
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-line .mono {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--s3);
|
padding: var(--s3);
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ export const da = {
|
|||||||
'notes.imageCopy': 'Kopiér billedet',
|
'notes.imageCopy': 'Kopiér billedet',
|
||||||
'notes.imageCopied': 'Kopieret',
|
'notes.imageCopied': 'Kopieret',
|
||||||
'notes.imageOpen': 'Åbn i nyt faneblad',
|
'notes.imageOpen': 'Åbn i nyt faneblad',
|
||||||
|
'notes.imageZoomIn': 'Fuld størrelse',
|
||||||
|
'notes.imageZoomOut': 'Tilpas skærmen',
|
||||||
'notes.strike': 'Gennemstreget',
|
'notes.strike': 'Gennemstreget',
|
||||||
'notes.code': 'Kode',
|
'notes.code': 'Kode',
|
||||||
'notes.showSource': 'Vis kilden (Markdown)',
|
'notes.showSource': 'Vis kilden (Markdown)',
|
||||||
@@ -734,6 +736,15 @@ export const da = {
|
|||||||
'auth.savePassword': 'Gem adgangskoden',
|
'auth.savePassword': 'Gem adgangskoden',
|
||||||
'auth.passwordChanged': 'Adgangskoden er skiftet. Log ind med den nye.',
|
'auth.passwordChanged': 'Adgangskoden er skiftet. Log ind med den nye.',
|
||||||
'auth.resetSent': 'Hvis adressen har en konto, er der et link på vej. Tjek også spamfilteret.',
|
'auth.resetSent': 'Hvis adressen har en konto, er der et link på vej. Tjek også spamfilteret.',
|
||||||
|
'app.serverPrompt': 'Hvilken Verdande vil du logge ind på?',
|
||||||
|
'app.server': 'Serveradresse',
|
||||||
|
'app.serverHint': 'Den adresse, du åbner Verdande på i en browser.',
|
||||||
|
'app.connect': 'Fortsæt',
|
||||||
|
'app.serverInvalid': 'Det ligner ikke en adresse.',
|
||||||
|
'app.serverUnreachable': 'Kunne ikke nå {host}. Tjek adressen og din forbindelse.',
|
||||||
|
'app.notVerdande': 'Der svarer noget på den adresse, men det er ikke en Verdande.',
|
||||||
|
'app.changeServer': 'Skift server',
|
||||||
|
'app.setupInBrowser': '{host} har ingen konti endnu. Opret den første i en browser, og log så ind her.',
|
||||||
'auth.signInPrompt': 'Log ind for at fortsætte.',
|
'auth.signInPrompt': 'Log ind for at fortsætte.',
|
||||||
'auth.signIn': 'Log ind',
|
'auth.signIn': 'Log ind',
|
||||||
'auth.forgot': 'Glemt adgangskode?',
|
'auth.forgot': 'Glemt adgangskode?',
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ export const en = {
|
|||||||
'notes.imageCopy': 'Copy the image',
|
'notes.imageCopy': 'Copy the image',
|
||||||
'notes.imageCopied': 'Copied',
|
'notes.imageCopied': 'Copied',
|
||||||
'notes.imageOpen': 'Open in a new tab',
|
'notes.imageOpen': 'Open in a new tab',
|
||||||
|
'notes.imageZoomIn': 'Full size',
|
||||||
|
'notes.imageZoomOut': 'Fit the screen',
|
||||||
'notes.strike': 'Strikethrough',
|
'notes.strike': 'Strikethrough',
|
||||||
'notes.code': 'Code',
|
'notes.code': 'Code',
|
||||||
'notes.showSource': 'Show the source (Markdown)',
|
'notes.showSource': 'Show the source (Markdown)',
|
||||||
@@ -728,6 +730,15 @@ export const en = {
|
|||||||
'auth.passwordChanged': 'The password has been changed. Sign in with the new one.',
|
'auth.passwordChanged': 'The password has been changed. Sign in with the new one.',
|
||||||
'auth.resetSent':
|
'auth.resetSent':
|
||||||
'If that address has an account, a link is on its way. Check the spam filter too.',
|
'If that address has an account, a link is on its way. Check the spam filter too.',
|
||||||
|
'app.serverPrompt': 'Which Verdande do you want to sign in to?',
|
||||||
|
'app.server': 'Server address',
|
||||||
|
'app.serverHint': 'The address you open Verdande at in a browser.',
|
||||||
|
'app.connect': 'Continue',
|
||||||
|
'app.serverInvalid': 'That does not look like an address.',
|
||||||
|
'app.serverUnreachable': 'Could not reach {host}. Check the address and your connection.',
|
||||||
|
'app.notVerdande': 'Something answers at that address, but it is not a Verdande.',
|
||||||
|
'app.changeServer': 'Change server',
|
||||||
|
'app.setupInBrowser': '{host} has no accounts yet. Create the first one in a browser, then sign in here.',
|
||||||
'auth.signInPrompt': 'Sign in to continue.',
|
'auth.signInPrompt': 'Sign in to continue.',
|
||||||
'auth.signIn': 'Sign in',
|
'auth.signIn': 'Sign in',
|
||||||
'auth.forgot': 'Forgotten your password?',
|
'auth.forgot': 'Forgotten your password?',
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Pictures and files from the server, inside the iOS app.
|
||||||
|
*
|
||||||
|
* Attachments are addressed as `/api/v1/attachments/<id>` — in a note's text, in
|
||||||
|
* a task's file list, in the export links — and in a browser that is the whole
|
||||||
|
* story: the path is on the page's own origin and the cookie goes with it. In the
|
||||||
|
* app neither holds. The path would resolve against capacitor://localhost, and an
|
||||||
|
* `<img>` or a link cannot carry the bearer token even when pointed at the right
|
||||||
|
* server.
|
||||||
|
*
|
||||||
|
* So in the app, and only there, this watches the page: an image whose source is
|
||||||
|
* an API path is fetched with the token and shown from a blob, and a tap on a
|
||||||
|
* link to an API path is answered the same way. Done once for the whole document
|
||||||
|
* rather than in each component, because the paths come from many places — a
|
||||||
|
* note's own markdown among them — and a component that forgot would show a
|
||||||
|
* broken picture only in the app, where nobody runs the tests.
|
||||||
|
*
|
||||||
|
* The original path is kept in `data-src`. The note editor turns the page back
|
||||||
|
* into markdown, and a blob address saved into a note is a picture that is gone
|
||||||
|
* the next time anybody opens it; `richtext.js` reads `data-src` first.
|
||||||
|
*/
|
||||||
|
import { apiURL, authorize, isNativeApp } from './platform.js';
|
||||||
|
|
||||||
|
const API = '/api/v1/';
|
||||||
|
const blobs = new Map();
|
||||||
|
|
||||||
|
async function blobFor(path) {
|
||||||
|
if (!blobs.has(path)) {
|
||||||
|
const promise = fetch(apiURL(path.slice(API.length - 1)), authorize({ method: 'GET' })).then(
|
||||||
|
async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
return URL.createObjectURL(await res.blob());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// A failure is not cached: the next sight of the picture tries again.
|
||||||
|
promise.catch(() => blobs.delete(path));
|
||||||
|
blobs.set(path, promise);
|
||||||
|
}
|
||||||
|
return blobs.get(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adopt(img) {
|
||||||
|
const path = img.getAttribute('src');
|
||||||
|
// Our own blob address coming back through the observer is not an API path,
|
||||||
|
// so it stops here; a new API path on an image already adopted is taken up.
|
||||||
|
if (!path?.startsWith(API) || img.dataset.src === path) return;
|
||||||
|
img.dataset.src = path;
|
||||||
|
// Nothing until the picture is here, rather than a broken-image glyph.
|
||||||
|
img.removeAttribute('src');
|
||||||
|
blobFor(path)
|
||||||
|
.then((url) => {
|
||||||
|
if (img.dataset.src === path) img.src = url;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scan(root) {
|
||||||
|
if (root.nodeType !== 1) return;
|
||||||
|
if (root.tagName === 'IMG') adopt(root);
|
||||||
|
for (const img of root.querySelectorAll?.('img[src^="/api/v1/"]') ?? []) adopt(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLink(event) {
|
||||||
|
const a = event.target.closest?.('a[href]');
|
||||||
|
const path = a?.getAttribute('href');
|
||||||
|
if (!path?.startsWith(API)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
window.open(await blobFor(path), '_blank');
|
||||||
|
} catch {
|
||||||
|
// Nothing to open; the request's own failure is what there is to say,
|
||||||
|
// and there is no screen here to say it on.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called once by the layout. A no-op in a browser. */
|
||||||
|
export function installNativeMedia() {
|
||||||
|
if (!isNativeApp() || typeof document === 'undefined') return;
|
||||||
|
scan(document.body);
|
||||||
|
new MutationObserver((records) => {
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.type === 'attributes') adopt(record.target);
|
||||||
|
else for (const node of record.addedNodes) scan(node);
|
||||||
|
}
|
||||||
|
}).observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['src']
|
||||||
|
});
|
||||||
|
document.addEventListener('click', openLink, true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Where the interface is running, and what that changes about reaching the server.
|
||||||
|
*
|
||||||
|
* In a browser the interface and the API share an origin: every path is relative
|
||||||
|
* and the session is an httpOnly cookie no script can see. In the iOS app the
|
||||||
|
* interface is bundled into the app and served from capacitor://localhost, and
|
||||||
|
* the server is whichever one its owner typed in. So the app needs three things
|
||||||
|
* a browser never does: the server's address, its own session token, and that
|
||||||
|
* token on every request instead of a cookie.
|
||||||
|
*
|
||||||
|
* Everything that differs between the two lives here, so `api.js` and the socket
|
||||||
|
* ask one question — `apiURL(path)`, `authorize(init)` — and the browser's answer
|
||||||
|
* is exactly what it was before this file existed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** True inside the iOS app. Capacitor puts its bridge on `window` before any of
|
||||||
|
* our code runs. */
|
||||||
|
export function isNativeApp() {
|
||||||
|
return !!globalThis.Capacitor?.isNativePlatform?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's stored settings: which server, and the session on it.
|
||||||
|
*
|
||||||
|
* localStorage for now, behind this one object, so the Keychain plugin can take
|
||||||
|
* its place without anything else changing. The token is a session — whoever
|
||||||
|
* holds it is signed in — and the Keychain is where a phone keeps those; until it
|
||||||
|
* is wired in, this is a WKWebView's own storage, readable only by the app.
|
||||||
|
*/
|
||||||
|
const SERVER_KEY = 'verdande.app.server';
|
||||||
|
const TOKEN_KEY = 'verdande.app.session';
|
||||||
|
|
||||||
|
function read(key) {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key) ?? '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(key, value) {
|
||||||
|
try {
|
||||||
|
if (value) localStorage.setItem(key, value);
|
||||||
|
else localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// Storage refused (private mode, quota). The app then asks again next
|
||||||
|
// launch, which is the right failure: a token that seemed saved and was
|
||||||
|
// not would look like being signed out for no reason.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appSession = {
|
||||||
|
server: () => read(SERVER_KEY),
|
||||||
|
setServer: (url) => write(SERVER_KEY, url),
|
||||||
|
token: () => read(TOKEN_KEY),
|
||||||
|
setToken: (token) => write(TOKEN_KEY, token),
|
||||||
|
/** Signed out: the token goes, the server stays — signing in again is to the
|
||||||
|
* same place nearly every time. */
|
||||||
|
signOut: () => write(TOKEN_KEY, '')
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The address of an API path: relative in a browser, on the chosen server in
|
||||||
|
* the app. */
|
||||||
|
export function apiURL(path) {
|
||||||
|
const base = isNativeApp() ? appSession.server() : '';
|
||||||
|
return `${base}/api/v1${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills in how a request proves who is asking.
|
||||||
|
*
|
||||||
|
* A browser sends its cookie, same-origin only. The app sends no cookie at all —
|
||||||
|
* `omit`, so nothing the WebView picked up from somewhere rides along — and its
|
||||||
|
* token as a bearer.
|
||||||
|
*/
|
||||||
|
export function authorize(init) {
|
||||||
|
if (!isNativeApp()) {
|
||||||
|
init.credentials = 'same-origin';
|
||||||
|
return init;
|
||||||
|
}
|
||||||
|
init.credentials = 'omit';
|
||||||
|
const token = appSession.token();
|
||||||
|
if (token) {
|
||||||
|
init.headers = { ...(init.headers ?? {}), Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
return init;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the app calls itself in the list of signed-in devices. */
|
||||||
|
export function deviceName() {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
if (/iPad/.test(ua)) return 'iPad';
|
||||||
|
if (/iPhone/.test(ua)) return 'iPhone';
|
||||||
|
return 'Verdande-appen';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalises what somebody typed as their server's address.
|
||||||
|
*
|
||||||
|
* "verdande.example.dk" becomes "https://verdande.example.dk"; a trailing slash
|
||||||
|
* and a pasted "/api/v1" are dropped. Plain http is kept when typed on purpose,
|
||||||
|
* because a self-hoster on their own network may have no certificate — iOS will
|
||||||
|
* refuse it unless the app allows it, and that refusal should be the one they see.
|
||||||
|
*/
|
||||||
|
export function normaliseServer(input) {
|
||||||
|
let s = (input ?? '').trim();
|
||||||
|
if (!s) return '';
|
||||||
|
if (!/^https?:\/\//i.test(s)) s = `https://${s}`;
|
||||||
|
try {
|
||||||
|
const u = new URL(s);
|
||||||
|
return `${u.protocol}//${u.host}`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,16 @@
|
|||||||
|
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { t } from './i18n.svelte.js';
|
import { t } from './i18n.svelte.js';
|
||||||
|
import { isNativeApp } from './platform.js';
|
||||||
|
|
||||||
/** Whether this browser can do it at all. Safari before 16.4 cannot, and neither
|
/** Whether this browser can do it at all. Safari before 16.4 cannot, and neither
|
||||||
* can any browser on a page served over plain HTTP from a non-localhost host. */
|
* can any browser on a page served over plain HTTP from a non-localhost host. */
|
||||||
export function supported() {
|
export function supported() {
|
||||||
return (
|
return (
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
|
// The iOS app gets its notifications from APNs, not Web Push: a WKWebView
|
||||||
|
// has no push service, and a subscription made there would go nowhere.
|
||||||
|
!isNativeApp() &&
|
||||||
'serviceWorker' in navigator &&
|
'serviceWorker' in navigator &&
|
||||||
'PushManager' in window &&
|
'PushManager' in window &&
|
||||||
'Notification' in window
|
'Notification' in window
|
||||||
|
|||||||
@@ -267,7 +267,10 @@ function inlineToMarkdown(node) {
|
|||||||
// — og den første gemning ville tage hvert billede ud af noten uden at
|
// — og den første gemning ville tage hvert billede ud af noten uden at
|
||||||
// nogen havde rørt det.
|
// nogen havde rørt det.
|
||||||
case 'IMG':
|
case 'IMG':
|
||||||
return ` ?? ''})`;
|
// data-src first: in the iOS app the shown source is a blob made on this
|
||||||
|
// device, and the path it was made from is what the note keeps. See
|
||||||
|
// nativemedia.js.
|
||||||
|
return ` || ''})`;
|
||||||
case 'STRONG':
|
case 'STRONG':
|
||||||
case 'B':
|
case 'B':
|
||||||
return inner ? `**${inner}**` : '';
|
return inner ? `**${inner}**` : '';
|
||||||
|
|||||||
@@ -13,6 +13,23 @@
|
|||||||
|
|
||||||
import { api, ApiError, humanMessage } from './api.js';
|
import { api, ApiError, humanMessage } from './api.js';
|
||||||
import { i18n, t } from './i18n.svelte.js';
|
import { i18n, t } from './i18n.svelte.js';
|
||||||
|
import { appSession, isNativeApp } from './platform.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the live-sync socket is. A browser opens it on its own origin and the
|
||||||
|
* cookie goes with the handshake. The app cannot put its token on a handshake,
|
||||||
|
* so it asks for a one-use ticket first and opens the socket on its server with
|
||||||
|
* that.
|
||||||
|
*/
|
||||||
|
async function socketURL() {
|
||||||
|
if (!isNativeApp()) {
|
||||||
|
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
return `${scheme}://${location.host}/api/v1/ws`;
|
||||||
|
}
|
||||||
|
const { ticket } = await api.wsTicket();
|
||||||
|
const base = appSession.server().replace(/^http/, 'ws');
|
||||||
|
return `${base}/api/v1/ws?ticket=${encodeURIComponent(ticket)}`;
|
||||||
|
}
|
||||||
|
|
||||||
class AppState {
|
class AppState {
|
||||||
user = $state(null);
|
user = $state(null);
|
||||||
@@ -229,6 +246,14 @@ class AppState {
|
|||||||
async load() {
|
async load() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
|
// The app before it knows which server it belongs to: nothing to ask, and
|
||||||
|
// the sign-in screen is where the address is asked for.
|
||||||
|
if (isNativeApp() && !appSession.server()) {
|
||||||
|
this.user = null;
|
||||||
|
this.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Døren, hvis dette vindue allerede er gået gennem den. Genindlæser nogen på
|
// Døren, hvis dette vindue allerede er gået gennem den. Genindlæser nogen på
|
||||||
// /noter inde i urd-appen, kommer de ikke forbi /urd igen, og uden den her
|
// /noter inde i urd-appen, kommer de ikke forbi /urd igen, og uden den her
|
||||||
// linje ville ansigtet falde af ved hvert F5.
|
// linje ville ansigtet falde af ved hvert F5.
|
||||||
@@ -281,10 +306,23 @@ class AppState {
|
|||||||
|
|
||||||
// --- realtime ---------------------------------------------------------------
|
// --- realtime ---------------------------------------------------------------
|
||||||
|
|
||||||
connect() {
|
async connect() {
|
||||||
if (this.#socket) return;
|
if (this.#socket) return;
|
||||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
// Claimed before the await below, so two calls in the same moment cannot
|
||||||
const socket = new WebSocket(`${scheme}://${location.host}/api/v1/ws`);
|
// both open one.
|
||||||
|
this.#socket = 'opening';
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = await socketURL();
|
||||||
|
} catch {
|
||||||
|
// No ticket — offline, or signed out. Try again the way a dropped
|
||||||
|
// socket does.
|
||||||
|
this.#socket = null;
|
||||||
|
setTimeout(() => this.connect(), this.#reconnectDelay);
|
||||||
|
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, 30000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const socket = new WebSocket(url);
|
||||||
this.#socket = socket;
|
this.#socket = socket;
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { setBadge } from '$lib/badge.js';
|
import { setBadge } from '$lib/badge.js';
|
||||||
import { open as openInChosenBrowser, stored as storedOpener } from '$lib/linkopen.js';
|
import { open as openInChosenBrowser, stored as storedOpener } from '$lib/linkopen.js';
|
||||||
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
|
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
|
||||||
|
import { installNativeMedia } from '$lib/nativemedia.js';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -58,6 +59,12 @@
|
|||||||
app.load();
|
app.load();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pictures and downloads from the server, in the iOS app. A no-op in a
|
||||||
|
// browser. See nativemedia.js.
|
||||||
|
$effect(() => {
|
||||||
|
installNativeMedia();
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hvilken dør dette vindue er inde ad.
|
* Hvilken dør dette vindue er inde ad.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -45,6 +45,17 @@ export default {
|
|||||||
*/
|
*/
|
||||||
version: {
|
version: {
|
||||||
name: process.env.VERDANDE_VERSION ?? 'dev'
|
name: process.env.VERDANDE_VERSION ?? 'dev'
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not in the iOS app. WKWebView does not run service workers for an app's
|
||||||
|
* own scheme, and the offline cache it would keep belongs to an interface
|
||||||
|
* served by the instance — the app carries its interface inside itself.
|
||||||
|
* `VERDANDE_TARGET=app` is set by the app's build; the browser build is
|
||||||
|
* unchanged.
|
||||||
|
*/
|
||||||
|
serviceWorker: {
|
||||||
|
register: process.env.VERDANDE_TARGET !== 'app'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user