Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9028e8f7a | ||
|
|
a71af41d9e |
@@ -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 |
|
||||||
|
|||||||
@@ -4024,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":
|
||||||
@@ -4042,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 -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -69,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.
|
||||||
//
|
//
|
||||||
@@ -165,6 +171,11 @@ 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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -220,7 +222,7 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
|||||||
// this Origin — the browser writes the header, not the page — and an app
|
// 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
|
// login sets no cookie, so there is no session here for a forged request
|
||||||
// to ride on.
|
// to ride on.
|
||||||
if isAppOrigin(r.Header.Get("Origin")) {
|
if s.isAppOrigin(r.Header.Get("Origin")) {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -249,12 +251,20 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
|||||||
// makes to a server arrives from here, whichever server it is.
|
// makes to a server arrives from here, whichever server it is.
|
||||||
var appOrigins = []string{"capacitor://localhost"}
|
var appOrigins = []string{"capacitor://localhost"}
|
||||||
|
|
||||||
func isAppOrigin(origin string) bool {
|
func (s *Server) isAppOrigin(origin string) bool {
|
||||||
|
if origin == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
for _, o := range appOrigins {
|
for _, o := range appOrigins {
|
||||||
if origin == o {
|
if origin == o {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, o := range s.cfg.AppOrigins {
|
||||||
|
if origin == o {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:');
|
||||||
|
});
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -736,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?',
|
||||||
|
|||||||
@@ -730,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