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_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
||||
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
|
||||
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
|
||||
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
|
||||
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
|
||||
| `VERDANDE_SMTP_HOST` | — | Blank disables outbound mail; invite links are shown in the app instead. |
|
||||
|
||||
@@ -37,11 +37,14 @@ straight off the internet is keyed on the address it really came from.
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
|
||||
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
|
||||
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
|
||||
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
|
||||
|
||||
Any Go duration: `720h`, `30m`, `45s`.
|
||||
|
||||
`VERDANDE_APP_ORIGINS` (comma-separated) counts further origins as the iOS app's own, beside `capacitor://localhost`. It exists for the end-to-end suite, which runs the interface from a second host name to test it the way the app does; an instance has no reason to set it.
|
||||
|
||||
## Mail
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|
||||
@@ -4024,14 +4024,51 @@ paths:
|
||||
Events: `task.created`, `task.updated`, `task.completed`, `task.reopened`,
|
||||
`task.moved`, `task.deleted`, `comment.created`, `notification`,
|
||||
`reminder`.
|
||||
|
||||
A browser opens it with its session cookie and from the instance's own
|
||||
origin. The iOS app cannot put a header on a handshake, so it asks
|
||||
`/ws-ticket` first and opens `/ws?ticket=…`; a handshake from the app's
|
||||
origin without one is refused.
|
||||
parameters:
|
||||
- name: ticket
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: string }
|
||||
description: From `/ws-ticket`. Good for one connection, within 30 seconds.
|
||||
responses:
|
||||
"101": { description: Switching protocols. }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
/api/v1/ws-ticket:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: A one-use ticket for opening the live-sync socket without a cookie.
|
||||
description: |
|
||||
For the iOS app, whose session is a bearer token that a WebSocket
|
||||
handshake cannot carry. Putting the token itself in the URL would leave a
|
||||
credential lasting months in every log the URL passes through; the ticket
|
||||
lasts thirty seconds and opens one connection.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ticket: { type: string }
|
||||
expires_in: { type: integer, description: Seconds., example: 30 }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
/api/v1/ping:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: A liveness check that does not touch the database.
|
||||
description: |
|
||||
Also how the iOS app tells a Verdande from anything else at an address
|
||||
somebody typed in, before anyone has signed in, and which half of the
|
||||
interface to show. It carries no version: `/version` is behind a login on
|
||||
purpose, and a public number is a list of the instances running an old one.
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
@@ -4042,6 +4079,9 @@ paths:
|
||||
type: object
|
||||
properties:
|
||||
status: { type: string, const: ok }
|
||||
product: { type: string, const: verdande }
|
||||
edition: { type: string, enum: [full, notes] }
|
||||
api: { type: integer, const: 1, description: Raised when /api/v1 changes in a way an older app cannot follow. }
|
||||
|
||||
# --- data -----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -69,6 +69,12 @@ type Config struct {
|
||||
// the deal its operator made by self-hosting.
|
||||
UpdateCheck bool
|
||||
|
||||
// AppOrigins are origins treated like the iOS app's own, capacitor://localhost,
|
||||
// which is always included. Set by the end-to-end suite, which serves the
|
||||
// interface from a second host name to test it the way the app runs it. An
|
||||
// operator has no reason to set it.
|
||||
AppOrigins []string
|
||||
|
||||
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
|
||||
// from its own interface rather than from another browser tab.
|
||||
//
|
||||
@@ -165,6 +171,11 @@ func Load() (*Config, error) {
|
||||
Edition: env("VERDANDE_EDITION", EditionFull),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
for _, o := range strings.Split(env("VERDANDE_APP_ORIGINS", ""), ",") {
|
||||
if o = strings.TrimRight(strings.TrimSpace(o), "/"); o != "" {
|
||||
c.AppOrigins = append(c.AppOrigins, o)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil {
|
||||
|
||||
@@ -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 (
|
||||
ctxUser ctxKey = iota
|
||||
ctxSession
|
||||
// ctxTicket marks a socket opened with a one-use ticket rather than a cookie.
|
||||
ctxTicket
|
||||
)
|
||||
|
||||
// sessionCookie is prefixed with __Host- when served over HTTPS. That prefix is
|
||||
@@ -220,7 +222,7 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
||||
// this Origin — the browser writes the header, not the page — and an app
|
||||
// login sets no cookie, so there is no session here for a forged request
|
||||
// to ride on.
|
||||
if isAppOrigin(r.Header.Get("Origin")) {
|
||||
if s.isAppOrigin(r.Header.Get("Origin")) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -249,12 +251,20 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
||||
// makes to a server arrives from here, whichever server it is.
|
||||
var appOrigins = []string{"capacitor://localhost"}
|
||||
|
||||
func isAppOrigin(origin string) bool {
|
||||
func (s *Server) isAppOrigin(origin string) bool {
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
for _, o := range appOrigins {
|
||||
if origin == o {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, o := range s.cfg.AppOrigins {
|
||||
if origin == o {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -45,11 +45,15 @@ func (s *Server) publishToOwner(r *http.Request, event string, payload any) {
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
ticketed, _ := r.Context().Value(ctxTicket).(bool)
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
// The socket is same-origin only. Without this, any page on the internet
|
||||
// could open a socket carrying the user's cookie and watch their tasks —
|
||||
// the same-origin policy does not apply to WebSocket handshakes.
|
||||
OriginPatterns: s.allowedOrigins(),
|
||||
// Except one opened with a ticket, which carries no cookie for a foreign
|
||||
// page to borrow: see handleWebSocketEntry.
|
||||
InsecureSkipVerify: ticketed,
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Debug("websocket handshake", "err", err)
|
||||
|
||||
@@ -60,6 +60,10 @@ type Server struct {
|
||||
// instance should not be unreachable because one of them cannot be offered.
|
||||
passkeys *auth.WebAuthn
|
||||
|
||||
// One-use tickets for opening the live-sync socket without a cookie — see
|
||||
// app_handlers.go.
|
||||
wsTickets *wsTickets
|
||||
|
||||
// Password guessing is the attack this application is actually exposed to, so
|
||||
// the endpoints that check a secret are limited separately and more tightly
|
||||
// than the rest of the API.
|
||||
@@ -115,6 +119,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// their own code gets it right on the first or second try; ten in an hour is
|
||||
// generous for a person and hopeless for a search through a million.
|
||||
totpLimiter: newLimiter(10, time.Hour),
|
||||
wsTickets: newWSTickets(),
|
||||
}
|
||||
// Before the CSP, deliberately: the policy has to describe the bytes that go
|
||||
// out, and in the notes edition those are the renamed ones.
|
||||
@@ -273,11 +278,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
}
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// Before the CSRF lock, so a preflight is answered and never reaches it.
|
||||
r.Use(s.appCORS)
|
||||
r.Use(s.requireCSRF)
|
||||
|
||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
r.Get("/ping", s.handlePing)
|
||||
|
||||
// The live-sync socket checks its own credential: a ticket, or the
|
||||
// session as everywhere else. See handleWebSocketEntry.
|
||||
r.Get("/ws", s.handleWebSocketEntry)
|
||||
|
||||
// The beacon's receiving end, and the number it produces. Both without a
|
||||
// session, because the caller is a stranger's installation — that is the
|
||||
@@ -378,7 +387,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
full := !s.cfg.NotesOnly()
|
||||
r.Use(s.requireAuth)
|
||||
|
||||
r.Get("/ws", s.handleWebSocket)
|
||||
r.Post("/ws-ticket", s.handleWSTicket)
|
||||
|
||||
// The MCP endpoint. Reachable with a personal API token, so a connector
|
||||
// sees exactly what its owner sees.
|
||||
|
||||
@@ -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.
|
||||
// Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor
|
||||
// den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker.
|
||||
testIgnore: /edition\.spec\.js/,
|
||||
// Og app-prøven, der kører fladen fra en anden origin med sin egen login.
|
||||
testIgnore: /(edition|app)\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
@@ -120,6 +121,19 @@ export default defineConfig({
|
||||
use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` }
|
||||
},
|
||||
|
||||
/**
|
||||
* Fladen, som iOS-appen kører den: fra en anden origin end serveren, uden
|
||||
* cookie, med sessionen som bearer. Se app.spec.js. Afhænger af `setup`,
|
||||
* fordi den logger ind på kontoen, opsætningen lavede — men låner ikke dens
|
||||
* cookie, for det er netop det, appen ikke har.
|
||||
*/
|
||||
{
|
||||
name: 'app-origin',
|
||||
testMatch: /app\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit-forms',
|
||||
grep: /@forms/,
|
||||
@@ -164,7 +178,10 @@ export default defineConfig({
|
||||
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
||||
VERDANDE_DATA_DIR: dataDir,
|
||||
// Off: an update check would reach out to GitHub from a test run.
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
VERDANDE_UPDATE_CHECK: 'false',
|
||||
// Den origin, app-origin-projektet kører fladen fra, regnet som appens
|
||||
// egen — som capacitor://localhost er det i den rigtige app.
|
||||
VERDANDE_APP_ORIGINS: `http://app.localhost:${PORT}`
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+33
-6
@@ -24,6 +24,7 @@ export class ApiError extends Error {
|
||||
|
||||
import { t } from './i18n.svelte.js';
|
||||
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
|
||||
import { apiURL, appSession, authorize, deviceName, isNativeApp } from './platform.js';
|
||||
|
||||
/**
|
||||
* How long a request keeps retrying a network failure before giving up.
|
||||
@@ -152,19 +153,21 @@ async function request(method, path, body, options = {}) {
|
||||
const init = {
|
||||
method,
|
||||
headers: {},
|
||||
// The session cookie is httpOnly and same-origin; nothing here handles a token.
|
||||
credentials: 'same-origin',
|
||||
signal: options.signal
|
||||
};
|
||||
if (body !== undefined) {
|
||||
init.headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
// In a browser: the httpOnly session cookie, same-origin, and nothing here
|
||||
// handles a token. In the iOS app: its session token as a bearer. See
|
||||
// platform.js.
|
||||
authorize(init);
|
||||
|
||||
// Retries a network failure for a few seconds so a short drop does not lose the
|
||||
// call; `withRetry` turns an exhausted window into the same `offline` error the
|
||||
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
|
||||
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
|
||||
const res = await withRetry(() => fetch(apiURL(path), init), options.noRetry);
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
@@ -205,7 +208,7 @@ async function upload(path, file, field = 'file') {
|
||||
form.append(field, file);
|
||||
|
||||
const res = await withRetry(() =>
|
||||
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
|
||||
fetch(apiURL(path), authorize({ method: 'POST', body: form }))
|
||||
);
|
||||
|
||||
const text = await res.text();
|
||||
@@ -232,9 +235,30 @@ export const api = {
|
||||
// --- auth
|
||||
setupState: () => get('/auth/setup'),
|
||||
setup: (data) => post('/auth/setup', data),
|
||||
login: (email, password) => post('/auth/login', { email, password }),
|
||||
login: async (email, password) => {
|
||||
if (!isNativeApp()) return post('/auth/login', { email, password });
|
||||
// The app gets its session in the answer instead of a cookie, and keeps
|
||||
// it — also while a code is still owed, because the second step is sent
|
||||
// with it.
|
||||
const result = await post('/auth/login', {
|
||||
email,
|
||||
password,
|
||||
client: 'app',
|
||||
device_name: deviceName()
|
||||
});
|
||||
if (result?.session_token) appSession.setToken(result.session_token);
|
||||
return result;
|
||||
},
|
||||
loginTOTP: (code) => post('/auth/login/totp', { code }),
|
||||
logout: () => post('/auth/logout'),
|
||||
logout: async () => {
|
||||
try {
|
||||
return await post('/auth/logout');
|
||||
} finally {
|
||||
// Gone from the phone whether or not the server heard: a sign-out that
|
||||
// left the token behind because the network was down is not one.
|
||||
if (isNativeApp()) appSession.signOut();
|
||||
}
|
||||
},
|
||||
me: () => get('/auth/me'),
|
||||
updateProfile: (data) => patch('/auth/me', data),
|
||||
// Its own route rather than a field on the profile: that one backs a form with
|
||||
@@ -421,6 +445,9 @@ export const api = {
|
||||
post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }),
|
||||
deleteToken: (id) => del(`/tokens/${id}`),
|
||||
|
||||
// --- live sync, for the app (see stores.svelte.js)
|
||||
wsTicket: () => post('/ws-ticket'),
|
||||
|
||||
// --- notifications and push
|
||||
listNotifications: () => get('/notifications'),
|
||||
markNotificationsRead: (id) => post(id ? `/notifications/${id}/read` : '/notifications/read'),
|
||||
|
||||
@@ -21,9 +21,56 @@
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { available, signIn } from '$lib/passkey.js';
|
||||
import { appSession, isNativeApp, normaliseServer } from '$lib/platform.js';
|
||||
|
||||
// loading | login | setup | totp | forgot | sent | invite | reset | done
|
||||
// loading | server | login | setup | totp | forgot | sent | invite | reset | done
|
||||
let mode = $state('loading');
|
||||
|
||||
// --- which server (the iOS app only) ------------------------------------------
|
||||
|
||||
const native = isNativeApp();
|
||||
let server = $state(native ? appSession.server() : '');
|
||||
let serverHost = $derived(server ? server.replace(/^https?:\/\//, '') : '');
|
||||
|
||||
/**
|
||||
* The app has no server of its own. Before a password field is shown for an
|
||||
* address somebody typed, the address has to be a Verdande: asked with /ping,
|
||||
* which answers without a login and names the product. A router's login page,
|
||||
* a typo that lands on somebody's blog, a server that is down — each gets its
|
||||
* own sentence here instead of a sign-in form that cannot succeed.
|
||||
*/
|
||||
async function chooseServer() {
|
||||
const url = normaliseServer(server);
|
||||
if (!url) {
|
||||
error = t('app.serverInvalid');
|
||||
return;
|
||||
}
|
||||
let answer;
|
||||
try {
|
||||
const res = await fetch(`${url}/api/v1/ping`, { credentials: 'omit' });
|
||||
answer = res.ok ? await res.json() : null;
|
||||
} catch {
|
||||
error = t('app.serverUnreachable', { host: url.replace(/^https?:\/\//, '') });
|
||||
return;
|
||||
}
|
||||
if (answer?.product !== 'verdande') {
|
||||
error = t('app.notVerdande');
|
||||
return;
|
||||
}
|
||||
appSession.setServer(url);
|
||||
server = url;
|
||||
await enterServer();
|
||||
}
|
||||
|
||||
/** Asks the chosen server what it wants: sign-in, or — in a browser — setup. */
|
||||
async function enterServer() {
|
||||
try {
|
||||
const state = await api.setupState();
|
||||
mode = state.needs_setup ? 'setup' : 'login';
|
||||
} catch {
|
||||
mode = 'login';
|
||||
}
|
||||
}
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let name = $state('');
|
||||
@@ -37,6 +84,9 @@
|
||||
let passkeyReady = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// Not in the app: a passkey is bound to the server's own domain, and the
|
||||
// app's pages are not on it. Offered there it would fail every time.
|
||||
if (native) return;
|
||||
available().then((ok) => (passkeyReady = ok));
|
||||
});
|
||||
|
||||
@@ -94,10 +144,11 @@
|
||||
mode = linkMode;
|
||||
return;
|
||||
}
|
||||
api
|
||||
.setupState()
|
||||
.then((state) => (mode = state.needs_setup ? 'setup' : 'login'))
|
||||
.catch(() => (mode = 'login'));
|
||||
if (native && !appSession.server()) {
|
||||
mode = 'server';
|
||||
return;
|
||||
}
|
||||
enterServer();
|
||||
});
|
||||
|
||||
async function run(fn) {
|
||||
@@ -120,6 +171,10 @@
|
||||
|
||||
run(async () => {
|
||||
switch (mode) {
|
||||
case 'server':
|
||||
await chooseServer();
|
||||
break;
|
||||
|
||||
case 'setup':
|
||||
await api.setup({
|
||||
email,
|
||||
@@ -190,6 +245,34 @@
|
||||
|
||||
{#if mode === 'loading'}
|
||||
<p class="lede"> </p>
|
||||
{:else if mode === 'server'}
|
||||
<p class="lede">{t('app.serverPrompt')}</p>
|
||||
|
||||
<label>
|
||||
{t('app.server')}
|
||||
<input
|
||||
bind:value={server}
|
||||
use:focusOnMount
|
||||
type="url"
|
||||
inputmode="url"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
placeholder="verdande.example.dk"
|
||||
required
|
||||
/>
|
||||
<span class="hint">{t('app.serverHint')}</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>{t('app.connect')}</button>
|
||||
{:else if mode === 'setup' && native}
|
||||
<!-- The first account starts a browser session, and the app has no use
|
||||
for a cookie. An instance this new is being set up by whoever runs
|
||||
it, at a desk; they do it there, and the app signs in after. -->
|
||||
<p class="lede">{t('app.setupInBrowser', { host: serverHost })}</p>
|
||||
<button type="button" class="link" onclick={() => (mode = 'server')}>
|
||||
{t('app.changeServer')}
|
||||
</button>
|
||||
{:else if mode === 'setup'}
|
||||
<p class="lede">{t('auth.firstAccount')}</p>
|
||||
|
||||
@@ -303,6 +386,15 @@
|
||||
{:else}
|
||||
<p class="lede">{t('auth.signInPrompt')}</p>
|
||||
|
||||
{#if native}
|
||||
<p class="server-line">
|
||||
<span class="mono">{serverHost}</span>
|
||||
<button type="button" class="link" onclick={() => (mode = 'server')}>
|
||||
{t('app.changeServer')}
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<label>
|
||||
{t('auth.email')}
|
||||
<input bind:value={email} type="email" autocomplete="username" required />
|
||||
@@ -463,6 +555,23 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Which server the app is signing in to, and the way back to choosing. */
|
||||
.server-line {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.server-line .mono {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
|
||||
@@ -736,6 +736,15 @@ export const da = {
|
||||
'auth.savePassword': 'Gem adgangskoden',
|
||||
'auth.passwordChanged': 'Adgangskoden er skiftet. Log ind med den nye.',
|
||||
'auth.resetSent': 'Hvis adressen har en konto, er der et link på vej. Tjek også spamfilteret.',
|
||||
'app.serverPrompt': 'Hvilken Verdande vil du logge ind på?',
|
||||
'app.server': 'Serveradresse',
|
||||
'app.serverHint': 'Den adresse, du åbner Verdande på i en browser.',
|
||||
'app.connect': 'Fortsæt',
|
||||
'app.serverInvalid': 'Det ligner ikke en adresse.',
|
||||
'app.serverUnreachable': 'Kunne ikke nå {host}. Tjek adressen og din forbindelse.',
|
||||
'app.notVerdande': 'Der svarer noget på den adresse, men det er ikke en Verdande.',
|
||||
'app.changeServer': 'Skift server',
|
||||
'app.setupInBrowser': '{host} har ingen konti endnu. Opret den første i en browser, og log så ind her.',
|
||||
'auth.signInPrompt': 'Log ind for at fortsætte.',
|
||||
'auth.signIn': 'Log ind',
|
||||
'auth.forgot': 'Glemt adgangskode?',
|
||||
|
||||
@@ -730,6 +730,15 @@ export const en = {
|
||||
'auth.passwordChanged': 'The password has been changed. Sign in with the new one.',
|
||||
'auth.resetSent':
|
||||
'If that address has an account, a link is on its way. Check the spam filter too.',
|
||||
'app.serverPrompt': 'Which Verdande do you want to sign in to?',
|
||||
'app.server': 'Server address',
|
||||
'app.serverHint': 'The address you open Verdande at in a browser.',
|
||||
'app.connect': 'Continue',
|
||||
'app.serverInvalid': 'That does not look like an address.',
|
||||
'app.serverUnreachable': 'Could not reach {host}. Check the address and your connection.',
|
||||
'app.notVerdande': 'Something answers at that address, but it is not a Verdande.',
|
||||
'app.changeServer': 'Change server',
|
||||
'app.setupInBrowser': '{host} has no accounts yet. Create the first one in a browser, then sign in here.',
|
||||
'auth.signInPrompt': 'Sign in to continue.',
|
||||
'auth.signIn': 'Sign in',
|
||||
'auth.forgot': 'Forgotten your password?',
|
||||
|
||||
@@ -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 { t } from './i18n.svelte.js';
|
||||
import { isNativeApp } from './platform.js';
|
||||
|
||||
/** Whether this browser can do it at all. Safari before 16.4 cannot, and neither
|
||||
* can any browser on a page served over plain HTTP from a non-localhost host. */
|
||||
export function supported() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
// The iOS app gets its notifications from APNs, not Web Push: a WKWebView
|
||||
// has no push service, and a subscription made there would go nowhere.
|
||||
!isNativeApp() &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
|
||||
@@ -267,7 +267,10 @@ function inlineToMarkdown(node) {
|
||||
// — og den første gemning ville tage hvert billede ud af noten uden at
|
||||
// nogen havde rørt det.
|
||||
case 'IMG':
|
||||
return ` ?? ''})`;
|
||||
// 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 'B':
|
||||
return inner ? `**${inner}**` : '';
|
||||
|
||||
@@ -13,6 +13,23 @@
|
||||
|
||||
import { api, ApiError, humanMessage } from './api.js';
|
||||
import { i18n, t } from './i18n.svelte.js';
|
||||
import { appSession, isNativeApp } from './platform.js';
|
||||
|
||||
/**
|
||||
* Where the live-sync socket is. A browser opens it on its own origin and the
|
||||
* cookie goes with the handshake. The app cannot put its token on a handshake,
|
||||
* so it asks for a one-use ticket first and opens the socket on its server with
|
||||
* that.
|
||||
*/
|
||||
async function socketURL() {
|
||||
if (!isNativeApp()) {
|
||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
return `${scheme}://${location.host}/api/v1/ws`;
|
||||
}
|
||||
const { ticket } = await api.wsTicket();
|
||||
const base = appSession.server().replace(/^http/, 'ws');
|
||||
return `${base}/api/v1/ws?ticket=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
|
||||
class AppState {
|
||||
user = $state(null);
|
||||
@@ -229,6 +246,14 @@ class AppState {
|
||||
async load() {
|
||||
this.loading = true;
|
||||
|
||||
// The app before it knows which server it belongs to: nothing to ask, and
|
||||
// the sign-in screen is where the address is asked for.
|
||||
if (isNativeApp() && !appSession.server()) {
|
||||
this.user = null;
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Døren, hvis dette vindue allerede er gået gennem den. Genindlæser nogen på
|
||||
// /noter inde i urd-appen, kommer de ikke forbi /urd igen, og uden den her
|
||||
// linje ville ansigtet falde af ved hvert F5.
|
||||
@@ -281,10 +306,23 @@ class AppState {
|
||||
|
||||
// --- realtime ---------------------------------------------------------------
|
||||
|
||||
connect() {
|
||||
async connect() {
|
||||
if (this.#socket) return;
|
||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const socket = new WebSocket(`${scheme}://${location.host}/api/v1/ws`);
|
||||
// Claimed before the await below, so two calls in the same moment cannot
|
||||
// both open one.
|
||||
this.#socket = 'opening';
|
||||
let url;
|
||||
try {
|
||||
url = await socketURL();
|
||||
} catch {
|
||||
// No ticket — offline, or signed out. Try again the way a dropped
|
||||
// socket does.
|
||||
this.#socket = null;
|
||||
setTimeout(() => this.connect(), this.#reconnectDelay);
|
||||
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, 30000);
|
||||
return;
|
||||
}
|
||||
const socket = new WebSocket(url);
|
||||
this.#socket = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { setBadge } from '$lib/badge.js';
|
||||
import { open as openInChosenBrowser, stored as storedOpener } from '$lib/linkopen.js';
|
||||
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
|
||||
import { installNativeMedia } from '$lib/nativemedia.js';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -58,6 +59,12 @@
|
||||
app.load();
|
||||
});
|
||||
|
||||
// Pictures and downloads from the server, in the iOS app. A no-op in a
|
||||
// browser. See nativemedia.js.
|
||||
$effect(() => {
|
||||
installNativeMedia();
|
||||
});
|
||||
|
||||
/**
|
||||
* Hvilken dør dette vindue er inde ad.
|
||||
*
|
||||
|
||||
@@ -45,6 +45,17 @@ export default {
|
||||
*/
|
||||
version: {
|
||||
name: process.env.VERDANDE_VERSION ?? 'dev'
|
||||
},
|
||||
|
||||
/**
|
||||
* Not in the iOS app. WKWebView does not run service workers for an app's
|
||||
* own scheme, and the offline cache it would keep belongs to an interface
|
||||
* served by the instance — the app carries its interface inside itself.
|
||||
* `VERDANDE_TARGET=app` is set by the app's build; the browser build is
|
||||
* unchanged.
|
||||
*/
|
||||
serviceWorker: {
|
||||
register: process.env.VERDANDE_TARGET !== 'app'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user