Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9028e8f7a | ||
|
|
a71af41d9e | ||
|
|
a8db761a4b | ||
|
|
4ef050ae04 | ||
|
|
95e02b567b | ||
|
|
93470259d2 | ||
|
|
6a5228aed8 | ||
|
|
9155e799b1 | ||
|
|
5764271579 | ||
|
|
8a6329bfc9 | ||
|
|
fb8abcaa74 | ||
|
|
956e57f234 | ||
|
|
661e47ebdf | ||
|
|
6178cbbdd8 | ||
|
|
43bd405e36 | ||
|
|
b73f55ad0b | ||
|
|
475ecbe7ef | ||
|
|
159029675c | ||
|
|
3768cf946e | ||
|
|
60795126a5 | ||
|
|
664614b924 |
@@ -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 |
|
||||
|
||||
@@ -120,6 +120,19 @@ paths:
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
password: { type: string }
|
||||
client:
|
||||
type: string
|
||||
enum: [web, app]
|
||||
description: |
|
||||
`app` for the iOS app. Its session comes back as
|
||||
`session_token` instead of a cookie and slides — every use
|
||||
moves its end forward, up to `VERDANDE_APP_SESSION_TTL`
|
||||
(180 days) unused. Left out, it is a browser login.
|
||||
device_name:
|
||||
type: string
|
||||
maxLength: 100
|
||||
description: "Required with `client: app`; shown in the list of sessions."
|
||||
example: Kristians iPhone
|
||||
responses:
|
||||
"200":
|
||||
description: |
|
||||
@@ -132,7 +145,14 @@ paths:
|
||||
properties:
|
||||
user: { $ref: "#/components/schemas/User" }
|
||||
totp_required: { type: boolean }
|
||||
session_token:
|
||||
type: string
|
||||
description: |
|
||||
An app login only, also while `totp_required` — the app
|
||||
sends it to `/auth/login/totp` too. Sent from then on as
|
||||
`Authorization: Bearer`. A browser never receives it.
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
"429": { $ref: "#/components/responses/RateLimited" }
|
||||
|
||||
/api/v1/auth/login/totp:
|
||||
@@ -273,6 +293,49 @@ paths:
|
||||
"204": { description: Saved. }
|
||||
"400": { $ref: "#/components/responses/BadRequest" }
|
||||
|
||||
/api/v1/auth/default-reminder:
|
||||
put:
|
||||
tags: [auth]
|
||||
summary: The account's standing alarm on tasks that have a clock.
|
||||
description: |
|
||||
Reminders have always had to be set one at a time: a task written as
|
||||
"i morgen kl 9" got a time and no alarm, because nothing creates a reminder
|
||||
by itself. That is the right default for a task with no clock — there is no
|
||||
moment to ring at — and an errand you walk every time for a task with one.
|
||||
|
||||
`minutes` is relative to the due moment and **negative for before it**, the
|
||||
same convention `reminders.offset_min` uses because that is the column the
|
||||
value ends up in: the store adds it to the due time. `0` is exactly at the
|
||||
due moment. `null` turns the default off, which is where every account
|
||||
starts — switching it on for everybody in a migration would have meant
|
||||
alarms nobody asked for, on tasks already lying there.
|
||||
|
||||
A positive number is refused. An alarm after the thing it is warning about
|
||||
is not a reminder, and accepting one here would make the single mistake this
|
||||
value can make — a sign the wrong way round — look like a choice.
|
||||
|
||||
The default never stacks on an alarm the task already has, and a task can
|
||||
opt out as it is written with "ingen alarm" in the quick-add line.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
minutes:
|
||||
type: [integer, "null"]
|
||||
maximum: 0
|
||||
minimum: -10080
|
||||
description: |
|
||||
Minutes relative to the due moment, negative for before it.
|
||||
Null is off.
|
||||
example: -10
|
||||
responses:
|
||||
"204": { description: Saved. }
|
||||
"400": { $ref: "#/components/responses/BadRequest" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/auth/nav-order:
|
||||
put:
|
||||
tags: [auth]
|
||||
@@ -3961,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":
|
||||
@@ -3979,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 -----------------------------------------------------------------
|
||||
|
||||
@@ -4396,6 +4499,10 @@ components:
|
||||
A personal API token, `vrd_…`. Carries its owner's permissions exactly —
|
||||
there is no separate service identity — and is refused on `/tokens`.
|
||||
|
||||
Or the iOS app's session, the `session_token` from an app login. That
|
||||
one is a session and reaches everything a browser session does. A
|
||||
browser's own session is never accepted here.
|
||||
|
||||
responses:
|
||||
BadRequest:
|
||||
description: The body could not be read.
|
||||
@@ -4801,6 +4908,17 @@ components:
|
||||
never null: a client that has to check for both an absent array and an
|
||||
empty one will one day check for only one of them.
|
||||
example: ["labels"]
|
||||
default_reminder_min:
|
||||
type: [integer, "null"]
|
||||
description: |
|
||||
The account's standing alarm on tasks that have a clock, as minutes
|
||||
relative to the due moment and negative for before it. Null is off,
|
||||
which is where every account starts.
|
||||
|
||||
A type union rather than a number with a sentinel, because zero is a
|
||||
real setting here — an alarm exactly at the due moment — so "off" and
|
||||
"at the time" have to stay different answers.
|
||||
example: -10
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
RecoveryCodes:
|
||||
@@ -4919,6 +5037,12 @@ components:
|
||||
it so anything the summary gets wrong is still visible.
|
||||
example: Chrome på macOS
|
||||
user_agent: { type: string }
|
||||
client:
|
||||
type: string
|
||||
enum: [web, app]
|
||||
description: |
|
||||
`app` for the iOS app, whose `device` is the name it gave itself and
|
||||
whose `expires_at` moves forward each time it is used.
|
||||
ip: { type: string }
|
||||
created_at: { type: string, format: date-time }
|
||||
last_seen_at: { type: string, format: date-time }
|
||||
@@ -4991,6 +5115,21 @@ components:
|
||||
task is still created, in the Inbox — a thought is not thrown away
|
||||
over a typo — but the caller is told, because the alternative is
|
||||
that the name vanishes from the title and nothing says why.
|
||||
unknown_section:
|
||||
type: string
|
||||
description: |
|
||||
The same, for a `/section` that no section of the project the task
|
||||
landed in answers to. The task is created without a section.
|
||||
reminder_not_set:
|
||||
type: string
|
||||
description: |
|
||||
Present when the line asked to be reminded and the task has
|
||||
nothing to count back from — "mind mig en time før" on a task with
|
||||
a day but no clock. The offset form of a reminder fires relative to
|
||||
the due moment, so without one the row would sit in the table and
|
||||
never go off. The task is created; the alarm is not, and this says
|
||||
so. An alarm that silently never rings is the one way a reminder
|
||||
must not fail, because nothing afterwards reveals it.
|
||||
|
||||
ParsedTask:
|
||||
type: object
|
||||
@@ -5002,6 +5141,28 @@ components:
|
||||
due_date: { type: string, format: date }
|
||||
due_datetime: { type: string, format: date-time }
|
||||
recurrence: { type: string }
|
||||
reminder_offset_min:
|
||||
type: integer
|
||||
description: |
|
||||
When to be told, as minutes relative to the due moment: negative for
|
||||
before it, zero for at it. Absent when the line asked for no alarm.
|
||||
Relative rather than absolute, so moving the task moves its alarm with
|
||||
it. Mind the sign — the store adds this to the due time, so "an hour
|
||||
before" is -60.
|
||||
no_reminder:
|
||||
type: boolean
|
||||
description: |
|
||||
The line said it wants no alarm on this one — "ingen alarm". It exists
|
||||
because of the account-wide default: with alarms on for everything that
|
||||
has a clock, there has to be a way to say "not this one" in the same
|
||||
breath as writing it, rather than creating the task, opening it and
|
||||
deleting the alarm just made.
|
||||
reminder_at:
|
||||
type: string
|
||||
description: |
|
||||
An absolute alarm, `YYYY-MM-DDTHH:MM`, set when the line gave the
|
||||
reminder a clock of its own. It takes the day the task is due: "mind
|
||||
mig kl 8" about something due Friday means Friday at eight.
|
||||
labels:
|
||||
type: array
|
||||
items: { type: string }
|
||||
|
||||
@@ -24,8 +24,12 @@ type Config struct {
|
||||
DataDir string
|
||||
|
||||
SessionTTL time.Duration
|
||||
InviteTTL time.Duration
|
||||
ResetTTL time.Duration
|
||||
// AppSessionTTL is how long a session from the iOS app may sit unused before
|
||||
// it ends. It slides: every use moves the end forward, so a phone in daily use
|
||||
// stays signed in and one left in a drawer does not.
|
||||
AppSessionTTL time.Duration
|
||||
InviteTTL time.Duration
|
||||
ResetTTL time.Duration
|
||||
|
||||
SMTP SMTP
|
||||
|
||||
@@ -65,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.
|
||||
//
|
||||
@@ -161,11 +171,19 @@ 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 {
|
||||
return nil, err
|
||||
}
|
||||
if c.AppSessionTTL, err = envDuration("VERDANDE_APP_SESSION_TTL", 180*24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.InviteTTL, err = envDuration("VERDANDE_INVITE_TTL", 7*24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
func (ts *testServer) corsDo(t *testing.T, method, path, origin string, header map[string]string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, ts.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if origin != "" {
|
||||
req.Header.Set("Origin", origin)
|
||||
}
|
||||
for k, v := range header {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestCORSAnswersTheAppAndNobodyElse(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
preflight := map[string]string{
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "authorization, content-type",
|
||||
}
|
||||
|
||||
resp := ts.corsDo(t, "OPTIONS", "/api/v1/projects", "capacitor://localhost", preflight)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("app preflight: status %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
||||
t.Errorf("app preflight: Allow-Origin %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "Authorization") {
|
||||
t.Errorf("app preflight: Allow-Headers %q lacks Authorization", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
|
||||
t.Errorf("credentials allowed (%q): the app uses a bearer, never a cookie", got)
|
||||
}
|
||||
|
||||
// An ordinary request from the app can be read.
|
||||
resp = ts.corsDo(t, "GET", "/api/v1/ping", "capacitor://localhost", nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
||||
t.Errorf("app GET: Allow-Origin %q", got)
|
||||
}
|
||||
|
||||
// Anybody else gets nothing, so the browser keeps them from reading.
|
||||
for _, origin := range []string{"https://evil.example", "http://localhost", "capacitor://evil", "null"} {
|
||||
resp = ts.corsDo(t, "OPTIONS", "/api/v1/projects", origin, preflight)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("%s: preflight answered with Allow-Origin %q", origin, got)
|
||||
}
|
||||
resp = ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("%s: GET answered with Allow-Origin %q", origin, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The app asks this before anyone has signed in, to tell a Verdande from a
|
||||
// router's login page.
|
||||
func TestPingNamesTheProductAndEditionButNoVersion(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
anonymous := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||
resp, body := anonymous.do(t, "GET", "/api/v1/ping", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ping: %d", resp.StatusCode)
|
||||
}
|
||||
if body["product"] != "verdande" || body["edition"] != "full" || body["api"] != float64(1) {
|
||||
t.Errorf("ping = %v", body)
|
||||
}
|
||||
if _, ok := body["version"]; ok {
|
||||
t.Error("ping reveals the version to anybody")
|
||||
}
|
||||
}
|
||||
|
||||
func dialWS(ctx context.Context, url, origin string) (*websocket.Conn, *http.Response, error) {
|
||||
h := http.Header{}
|
||||
if origin != "" {
|
||||
h.Set("Origin", origin)
|
||||
}
|
||||
return websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: h})
|
||||
}
|
||||
|
||||
func TestTheAppOpensLiveSyncWithATicket(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := appLogin(t, ts)
|
||||
token := body["session_token"].(string)
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Without a ticket or a cookie: refused.
|
||||
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
|
||||
t.Fatal("a socket opened with no credential")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("no credential: %v", resp)
|
||||
}
|
||||
|
||||
resp, body := ts.appDo(t, token, "POST", "/api/v1/ws-ticket", nil)
|
||||
ticket, _ := body["ticket"].(string)
|
||||
if resp.StatusCode != http.StatusOK || ticket == "" {
|
||||
t.Fatalf("ws-ticket: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
conn, _, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("dial with a ticket: %v", err)
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
// And it is the right person's stream: their own new project arrives on it.
|
||||
ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Live fra telefonen"})
|
||||
_, msg, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(msg), "Live fra telefonen") {
|
||||
t.Errorf("first event was %s", msg)
|
||||
}
|
||||
|
||||
// Spent: the same ticket does not open a second socket.
|
||||
if _, resp, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost"); err == nil {
|
||||
t.Error("a ticket opened two sockets")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("reused ticket: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATicketExpires(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := appLogin(t, ts)
|
||||
_, body = ts.appDo(t, body["session_token"].(string), "POST", "/api/v1/ws-ticket", nil)
|
||||
ticket := body["ticket"].(string)
|
||||
|
||||
ts.api.wsTickets.mu.Lock()
|
||||
v := ts.api.wsTickets.m[ticket]
|
||||
v.expires = time.Now().Add(-time.Second)
|
||||
ts.api.wsTickets.m[ticket] = v
|
||||
ts.api.wsTickets.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws?ticket=" + ticket
|
||||
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
|
||||
t.Error("an expired ticket opened a socket")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expired ticket: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie path is unchanged: a foreign page cannot open a socket that rides
|
||||
// on the cookie, and the instance's own page can.
|
||||
func TestACookieSocketStillChecksTheOrigin(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
var cookie string
|
||||
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
|
||||
if c.Name == sessionCookieInsecure {
|
||||
cookie = c.Value
|
||||
}
|
||||
}
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dial := func(origin string) error {
|
||||
h := http.Header{}
|
||||
h.Set("Origin", origin)
|
||||
h.Set("Cookie", sessionCookieInsecure+"="+cookie)
|
||||
conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: h})
|
||||
if err == nil {
|
||||
conn.CloseNow()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := dial("https://evil.example"); err == nil {
|
||||
t.Error("a foreign page opened a socket on the cookie")
|
||||
}
|
||||
if err := dial("capacitor://localhost"); err == nil {
|
||||
t.Error("the app origin opened a socket on a cookie, without a ticket")
|
||||
}
|
||||
if err := dial("http://localhost"); err != nil {
|
||||
t.Errorf("control: the instance's own origin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The end-to-end suite runs the interface from a second host name and tells the
|
||||
// server to count it as the app's. That origin is then the app's in every
|
||||
// respect, and capacitor://localhost stays one too.
|
||||
func TestConfiguredAppOriginsCountAsTheApp(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(c *config.Config) {
|
||||
c.AppOrigins = []string{"http://app.localhost:8097"}
|
||||
})
|
||||
for _, origin := range []string{"http://app.localhost:8097", "capacitor://localhost"} {
|
||||
resp := ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
||||
t.Errorf("%s: Allow-Origin %q", origin, got)
|
||||
}
|
||||
}
|
||||
// Not a prefix match: a neighbour on another port is nobody.
|
||||
resp := ts.corsDo(t, "GET", "/api/v1/ping", "http://app.localhost:8098", nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("a neighbouring origin was answered: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the iOS app needs from a server that a browser gets for free by being on
|
||||
// the same origin. The app loads its interface from capacitor://localhost and
|
||||
// talks to whichever server its owner typed in, so every request it makes is
|
||||
// cross-origin: it needs CORS to read the answers, a way to open the live-sync
|
||||
// socket without a cookie, and a way to ask "is this a Verdande?" before anyone
|
||||
// has signed in.
|
||||
|
||||
// appCORS answers the app's origin, and nobody else's.
|
||||
//
|
||||
// No Access-Control-Allow-Credentials: the app carries its session as a bearer
|
||||
// token, never a cookie, so a response to a request with a cookie on it is not
|
||||
// something this header ever needs to unlock. Every other origin gets no CORS
|
||||
// headers at all, exactly as before — the browser then keeps a foreign page from
|
||||
// reading anything, which is the default this must not loosen.
|
||||
func (s *Server) appCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if !s.isAppOrigin(origin) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
h.Add("Vary", "Origin")
|
||||
// Read by the client: a rate limit's wait, and a download's file name.
|
||||
h.Set("Access-Control-Expose-Headers", "Retry-After, Content-Disposition")
|
||||
|
||||
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
|
||||
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
h.Set("Access-Control-Max-Age", "600")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// --- the live-sync socket without a cookie -----------------------------------
|
||||
|
||||
// A browser cannot put a header on a WebSocket handshake, so the app's bearer
|
||||
// token has no way onto it. Putting the token in the URL would work and would
|
||||
// leave a credential that lasts months in every proxy log the URL passes. A
|
||||
// ticket is the usual answer: asked for with the token, good for one connection,
|
||||
// gone in thirty seconds.
|
||||
|
||||
const wsTicketTTL = 30 * time.Second
|
||||
|
||||
type wsTicket struct {
|
||||
userID string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type wsTickets struct {
|
||||
mu sync.Mutex
|
||||
m map[string]wsTicket
|
||||
}
|
||||
|
||||
func newWSTickets() *wsTickets { return &wsTickets{m: map[string]wsTicket{}} }
|
||||
|
||||
func (t *wsTickets) issue(userID string) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ticket := hex.EncodeToString(raw)
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
// Swept on the way in, so the map is bounded by thirty seconds of asking.
|
||||
now := time.Now()
|
||||
for k, v := range t.m {
|
||||
if now.After(v.expires) {
|
||||
delete(t.m, k)
|
||||
}
|
||||
}
|
||||
t.m[ticket] = wsTicket{userID: userID, expires: now.Add(wsTicketTTL)}
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// redeem spends a ticket. It is deleted before it is checked, so a second
|
||||
// presentation finds nothing, whether the first one was in time or not.
|
||||
func (t *wsTickets) redeem(ticket string) (string, bool) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
v, ok := t.m[ticket]
|
||||
delete(t.m, ticket)
|
||||
if !ok || time.Now().After(v.expires) {
|
||||
return "", false
|
||||
}
|
||||
return v.userID, true
|
||||
}
|
||||
|
||||
func (s *Server) handleWSTicket(w http.ResponseWriter, r *http.Request) {
|
||||
ticket, err := s.wsTickets.issue(userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "issue websocket ticket", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ticket": ticket,
|
||||
"expires_in": int(wsTicketTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebSocketEntry opens the socket with a ticket, or falls through to the
|
||||
// ordinary session check.
|
||||
//
|
||||
// A socket opened with a ticket skips the origin check, and that is the point
|
||||
// rather than a gap: the origin check exists because a browser attaches the
|
||||
// cookie to a handshake from any page. A ticket is attached by nobody — it has to
|
||||
// be asked for with a credential and put in the URL on purpose — so there is no
|
||||
// ambient authority for a foreign page to borrow.
|
||||
func (s *Server) handleWebSocketEntry(w http.ResponseWriter, r *http.Request) {
|
||||
ticket := r.URL.Query().Get("ticket")
|
||||
if ticket == "" {
|
||||
// The app never holds a cookie, so a handshake from its origin without a
|
||||
// ticket is not the app. Refused here rather than left to the origin
|
||||
// check, which compares host names only: capacitor://localhost and an
|
||||
// instance served at http://localhost have the same one.
|
||||
if s.isAppOrigin(r.Header.Get("Origin")) {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "the app opens this socket with a ticket")
|
||||
return
|
||||
}
|
||||
s.requireAuth(http.HandlerFunc(s.handleWebSocket)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
userID, ok := s.wsTickets.redeem(ticket)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that ticket is not valid")
|
||||
return
|
||||
}
|
||||
user, err := s.db.UserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "load user", err)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(withUser(r.Context(), user), ctxTicket, true)
|
||||
s.handleWebSocket(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
// --- is this a Verdande? -----------------------------------------------------
|
||||
|
||||
// handlePing answers before anybody has signed in, which is when the app needs
|
||||
// it: the person has typed an address and the app has to tell "that is a
|
||||
// Verdande" from "that is a router's login page" before it shows a password
|
||||
// field for it. The edition decides which half of the interface to show.
|
||||
//
|
||||
// No version. /version sits behind a login on purpose, and a public number is a
|
||||
// list of the instances still running an old one.
|
||||
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"product": "verdande",
|
||||
"edition": s.edition(),
|
||||
"api": 1,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The iOS app loads its interface from itself — capacitor://localhost — so it
|
||||
// never shares an origin with the server and never gets a cookie back. It signs
|
||||
// in like a browser does and carries the same kind of session, as a bearer token.
|
||||
|
||||
// appDo is a request the way the app makes it: no cookie jar, the token in a
|
||||
// header, and the browser engine reporting the request as cross-site, because to
|
||||
// WKWebView it is.
|
||||
func (ts *testServer) appDo(t *testing.T, token, method, path string, body any) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequest(method, ts.URL+path, reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
req.Header.Set("Origin", "capacitor://localhost")
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
t.Cleanup(func() { resp.Body.Close() })
|
||||
var decoded map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &decoded)
|
||||
}
|
||||
return resp, decoded
|
||||
}
|
||||
|
||||
func appLogin(t *testing.T, ts *testServer) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
return ts.appDo(t, "", "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "kristian@example.dk", "password": "et langt kodeord",
|
||||
"client": "app", "device_name": "Kristians iPhone",
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppLoginReturnsATokenAndNoCookie(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := appLogin(t, ts)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("app login: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if len(resp.Cookies()) != 0 {
|
||||
t.Errorf("an app login set a cookie: %v", resp.Cookies())
|
||||
}
|
||||
token, _ := body["session_token"].(string)
|
||||
if token == "" {
|
||||
t.Fatalf("no session_token in %v", body)
|
||||
}
|
||||
if body["user"] == nil {
|
||||
t.Error("no user in the response")
|
||||
}
|
||||
|
||||
// The token is the whole credential, from a foreign origin, reads and writes.
|
||||
resp, me := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusOK || me["email"] != "kristian@example.dk" {
|
||||
t.Fatalf("me with the app token: %d %v", resp.StatusCode, me)
|
||||
}
|
||||
resp, body = ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Fra telefonen"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("a write from the app was refused: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// It is a session, not an API token: it reaches what a token may not, and it
|
||||
// is in the list of devices under the name the app gave itself.
|
||||
resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/sessions", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sessions from the app: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
var found bool
|
||||
for _, raw := range body["sessions"].([]any) {
|
||||
s := raw.(map[string]any)
|
||||
if s["current"] == true {
|
||||
found = true
|
||||
if s["device"] != "Kristians iPhone" || s["client"] != "app" {
|
||||
t.Errorf("the app's session reads %v / %v", s["device"], s["client"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the app's own session is not marked current")
|
||||
}
|
||||
|
||||
// And signing out ends it.
|
||||
if resp, _ = ts.appDo(t, token, "POST", "/api/v1/auth/logout", nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("logout: %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("the token still works after logout: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A browser's session never leaves its httpOnly cookie. Its value replayed as a
|
||||
// bearer must not work: that would turn the header into a second way past the
|
||||
// SameSite and CSRF locks the cookie sits behind.
|
||||
func TestABrowserSessionIsNotABearer(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var cookie string
|
||||
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
|
||||
if c.Name == sessionCookieInsecure {
|
||||
cookie = c.Value
|
||||
}
|
||||
}
|
||||
if cookie == "" {
|
||||
t.Fatal("bootstrap left no session cookie")
|
||||
}
|
||||
if resp, _ := ts.appDo(t, cookie, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a browser session worked as a bearer: %d", resp.StatusCode)
|
||||
}
|
||||
// Control: the same value is still a perfectly good cookie.
|
||||
if resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("control: the cookie itself: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppLoginWithASecondFactor(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{"password": "et langt kodeord"})
|
||||
secret, _ := body["secret"].(string)
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body := appLogin(t, ts)
|
||||
token, _ := body["session_token"].(string)
|
||||
if resp.StatusCode != http.StatusOK || body["totp_required"] != true || token == "" {
|
||||
t.Fatalf("app login should hand over a token and ask for a code: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["user"] != nil {
|
||||
t.Error("the user came back before the code was given")
|
||||
}
|
||||
|
||||
// Half a login reaches nothing, as in a browser, and does not slide yet.
|
||||
resp, body = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil)
|
||||
if resp.StatusCode != http.StatusUnauthorized || body["code"] != CodeTOTPRequired {
|
||||
t.Errorf("a pending app session reached /me: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
session, _, err := ts.db.SessionByToken(t.Context(), token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.IdleTTL != 0 || time.Until(session.ExpiresAt) > 11*time.Minute {
|
||||
t.Errorf("a pending app session lives %v and slides by %v", time.Until(session.ExpiresAt), session.IdleTTL)
|
||||
}
|
||||
|
||||
resp, body = ts.appDo(t, token, "POST", "/api/v1/auth/login/totp", map[string]string{"code": codeFor(t, secret)})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp from the app: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if len(resp.Cookies()) != 0 {
|
||||
t.Errorf("finishing an app login set a cookie: %v", resp.Cookies())
|
||||
}
|
||||
if resp, _ = ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("me after the code: %d", resp.StatusCode)
|
||||
}
|
||||
session, _, _ = ts.db.SessionByToken(t.Context(), token)
|
||||
if session.IdleTTL != 7*24*time.Hour {
|
||||
t.Errorf("a finished app session slides by %v, want the app TTL", session.IdleTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// A phone in daily use must not sign itself out; one left in a drawer must. The
|
||||
// end moves forward each time the session is used, and a browser's does not.
|
||||
func TestAnAppSessionSlides(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := appLogin(t, ts)
|
||||
token := body["session_token"].(string)
|
||||
|
||||
// Pretend it was last used a while ago and is a day from ending.
|
||||
soon := time.Now().Add(24 * time.Hour).Unix()
|
||||
if _, err := ts.db.ExecContext(t.Context(),
|
||||
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'app'`,
|
||||
time.Now().Add(-time.Hour).Unix(), soon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp, _ := ts.appDo(t, token, "GET", "/api/v1/auth/me", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("me: %d", resp.StatusCode)
|
||||
}
|
||||
var expires int64
|
||||
if err := ts.db.QueryRowContext(t.Context(),
|
||||
`SELECT expires_at FROM sessions WHERE client = 'app'`).Scan(&expires); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if left := time.Until(time.Unix(expires, 0)); left < 6*24*time.Hour {
|
||||
t.Errorf("using the app session left %v on it, want about the app TTL", left)
|
||||
}
|
||||
|
||||
// Control: a browser session used the same way keeps its fixed end.
|
||||
if _, err := ts.db.ExecContext(t.Context(),
|
||||
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE client = 'web'`,
|
||||
time.Now().Add(-time.Hour).Unix(), soon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
if err := ts.db.QueryRowContext(t.Context(),
|
||||
`SELECT expires_at FROM sessions WHERE client = 'web'`).Scan(&expires); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if expires != soon {
|
||||
t.Errorf("a browser session's end moved from %d to %d", soon, expires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppLoginRefusesWhatItDoesNotKnow(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]string
|
||||
field string
|
||||
}{
|
||||
{"unknown client", map[string]string{"client": "android", "device_name": "x"}, "client"},
|
||||
{"app without a name", map[string]string{"client": "app", "device_name": " "}, "device_name"},
|
||||
} {
|
||||
tc.body["email"] = "kristian@example.dk"
|
||||
tc.body["password"] = "et langt kodeord"
|
||||
resp, body := ts.appDo(t, "", "POST", "/api/v1/auth/login", tc.body)
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("%s: status %d, want 422", tc.name, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
if fields, _ := body["fields"].(map[string]any); fields[tc.field] == nil {
|
||||
t.Errorf("%s: no error on %q in %v", tc.name, tc.field, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
@@ -33,7 +33,14 @@ type userJSON struct {
|
||||
// opposite case and stays in localStorage.
|
||||
SidebarCollapsed []string `json:"sidebar_collapsed"`
|
||||
NavOrder []string `json:"nav_order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// DefaultReminderMin is the account's standing alarm on tasks that have a
|
||||
// clock, as minutes relative to the due moment and negative for before it.
|
||||
// Null is off, which is where every account starts.
|
||||
//
|
||||
// A pointer rather than a number with a sentinel, so that "off" and "at the
|
||||
// due moment" stay different answers: zero is a real setting here.
|
||||
DefaultReminderMin *int `json:"default_reminder_min"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func toUserJSON(u *store.User) userJSON {
|
||||
@@ -51,7 +58,8 @@ func toUserJSON(u *store.User) userJSON {
|
||||
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
|
||||
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
|
||||
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
DefaultReminderMin: u.DefaultReminderMin,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,16 +120,79 @@ func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleSetDefaultReminder turns the account's standing alarm on or off.
|
||||
//
|
||||
// `minutes` is relative to the due moment and negative for before it, which is the
|
||||
// convention reminders.offset_min uses because that is the column it lands in. Null
|
||||
// is off. A positive number is refused: an alarm AFTER the thing it is warning
|
||||
// about is not a reminder, and allowing it here would make the one mistake this
|
||||
// value can make — a sign the wrong way round — look like a choice.
|
||||
func (s *Server) handleSetDefaultReminder(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Minutes *int `json:"minutes"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if req.Minutes != nil {
|
||||
switch {
|
||||
case *req.Minutes > 0:
|
||||
writeFieldErrors(w, map[string]string{"minutes": "must be zero or negative — an alarm comes before"})
|
||||
return
|
||||
// A week is the outer edge of "remind me ahead of this"; beyond it the
|
||||
// number is far more likely to be a unit mistake than an intention.
|
||||
case *req.Minutes < -7*24*60:
|
||||
writeFieldErrors(w, map[string]string{"minutes": "must be within a week of the due time"})
|
||||
return
|
||||
}
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if err := s.db.SetDefaultReminder(r.Context(), user.ID, req.Minutes); err != nil {
|
||||
s.storeError(w, r, "set default reminder", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- sign in -----------------------------------------------------------------
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
// Client is "app" when the iOS app signs in; empty is a browser. DeviceName is
|
||||
// what the app calls itself, shown in the list of signed-in devices.
|
||||
Client string `json:"client"`
|
||||
DeviceName string `json:"device_name"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
User *userJSON `json:"user,omitempty"`
|
||||
TOTPRequired bool `json:"totp_required"`
|
||||
// SessionToken is set for an app login only, and instead of the cookie: the
|
||||
// app loads its interface from itself, so it never shares an origin with the
|
||||
// server and a cookie would not come back. It is sent from then on as
|
||||
// `Authorization: Bearer`. A browser never sees it — its session stays in an
|
||||
// httpOnly cookie no script can read.
|
||||
SessionToken string `json:"session_token,omitempty"`
|
||||
}
|
||||
|
||||
// appClient reports whether a login asked for an app session, and refuses a value
|
||||
// it does not know rather than quietly giving it a browser session: a client that
|
||||
// asked for something else and got a cookie would look signed in until its next
|
||||
// request, and then not be.
|
||||
func appClient(w http.ResponseWriter, client, deviceName string) (app, ok bool) {
|
||||
switch client {
|
||||
case "", "web":
|
||||
return false, true
|
||||
case "app":
|
||||
if strings.TrimSpace(deviceName) == "" {
|
||||
writeFieldErrors(w, map[string]string{"device_name": "an app names the device it signs in from"})
|
||||
return false, false
|
||||
}
|
||||
return true, true
|
||||
}
|
||||
writeFieldErrors(w, map[string]string{"client": `must be "app" or left out`})
|
||||
return false, false
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -129,6 +200,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
app, ok := appClient(w, req.Client, req.DeviceName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.db.UserByEmail(r.Context(), req.Email)
|
||||
if err != nil || auth.VerifyPassword(user.PasswordHash, req.Password) != nil {
|
||||
@@ -173,22 +248,40 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// A session that still needs a code gets a short life of its own: long enough
|
||||
// to fetch a phone, not long enough to be worth stealing.
|
||||
ttl := s.cfg.SessionTTL
|
||||
if app {
|
||||
ttl = s.cfg.AppSessionTTL
|
||||
}
|
||||
if user.TOTPEnabled {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
||||
token, session, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
||||
if err != nil {
|
||||
s.internal(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
|
||||
if user.TOTPEnabled {
|
||||
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
|
||||
return
|
||||
resp := loginResponse{TOTPRequired: user.TOTPEnabled}
|
||||
if app {
|
||||
// Sliding only once the login is finished; a pending one keeps its ten
|
||||
// minutes and handleLoginTOTP turns it on.
|
||||
idle := s.cfg.AppSessionTTL
|
||||
if user.TOTPEnabled {
|
||||
idle = 0
|
||||
}
|
||||
if err := s.db.MarkAppSession(r.Context(), session.ID, req.DeviceName, idle); err != nil {
|
||||
s.internal(w, r, "mark app session", err)
|
||||
return
|
||||
}
|
||||
resp.SessionToken = token
|
||||
} else {
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
}
|
||||
uj := toUserJSON(user)
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||||
|
||||
if !user.TOTPEnabled {
|
||||
uj := toUserJSON(user)
|
||||
resp.User = &uj
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type totpRequest struct {
|
||||
@@ -263,7 +356,19 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
s.totpLimiter.reset(user.ID)
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
||||
// An app's session finishes as a sliding one; it already holds its token and
|
||||
// gets no cookie.
|
||||
if session.Client == "app" {
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.AppSessionTTL, s.cfg.AppSessionTTL); err != nil {
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
}
|
||||
uj := toUserJSON(user)
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL, 0); err != nil {
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,12 +51,13 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
||||
cfg := &config.Config{
|
||||
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
|
||||
// party id, and every browser makes the same exception for localhost.
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
InviteTTL: 7 * 24 * time.Hour,
|
||||
ResetTTL: time.Hour,
|
||||
Dev: true,
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
AppSessionTTL: 7 * 24 * time.Hour,
|
||||
InviteTTL: 7 * 24 * time.Hour,
|
||||
ResetTTL: time.Hour,
|
||||
Dev: true,
|
||||
}
|
||||
if configure != nil {
|
||||
configure(cfg)
|
||||
|
||||
@@ -18,6 +18,8 @@ type ctxKey int
|
||||
const (
|
||||
ctxUser ctxKey = iota
|
||||
ctxSession
|
||||
// ctxTicket marks a socket opened with a one-use ticket rather than a cookie.
|
||||
ctxTicket
|
||||
)
|
||||
|
||||
// sessionCookie is prefixed with __Host- when served over HTTPS. That prefix is
|
||||
@@ -126,9 +128,10 @@ func (s *Server) requireAdmin(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// authenticate resolves a request to a user, by session cookie or by API token.
|
||||
// authenticate resolves a request to a user, by session cookie, by API token, or by
|
||||
// an app's session sent as a bearer token.
|
||||
//
|
||||
// Both are accepted everywhere. An API token belongs to a person and carries their
|
||||
// All are accepted everywhere. An API token belongs to a person and carries their
|
||||
// permissions exactly — there is no separate service identity to reason about, and
|
||||
// a script therefore cannot reach anything its owner could not.
|
||||
// errNoCredentials means the request carried nothing to authenticate with, as
|
||||
@@ -145,6 +148,21 @@ func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, err
|
||||
return user, nil, nil
|
||||
}
|
||||
|
||||
// Any other bearer is an app session (see loginResponse.SessionToken). It is
|
||||
// accepted only for a session that was issued to the app: a browser's session
|
||||
// never leaves its httpOnly cookie, and a cookie value found somewhere and
|
||||
// replayed as a header must not become a second way to use it.
|
||||
if token := bearerToken(r); token != "" {
|
||||
session, user, err := s.db.SessionByToken(r.Context(), token)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if session.Client != "app" {
|
||||
return nil, nil, store.ErrNotFound
|
||||
}
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(s.cookieName())
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, nil, errNoCredentials
|
||||
@@ -200,6 +218,14 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// The iOS app, before it has a token: its sign-in. A web page cannot send
|
||||
// this Origin — the browser writes the header, not the page — and an app
|
||||
// login sets no cookie, so there is no session here for a forged request
|
||||
// to ride on.
|
||||
if s.isAppOrigin(r.Header.Get("Origin")) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Sec-Fetch-Site is set by the browser and cannot be forged by page script.
|
||||
switch r.Header.Get("Sec-Fetch-Site") {
|
||||
@@ -220,6 +246,28 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// appOrigins are where the iOS app's interface is served from. Capacitor loads
|
||||
// the bundled build on this scheme inside WKWebView, so every request the app
|
||||
// makes to a server arrives from here, whichever server it is.
|
||||
var appOrigins = []string{"capacitor://localhost"}
|
||||
|
||||
func (s *Server) isAppOrigin(origin string) bool {
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
for _, o := range appOrigins {
|
||||
if origin == o {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, o := range s.cfg.AppOrigins {
|
||||
if origin == o {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) isOwnOrigin(origin string) bool {
|
||||
if strings.EqualFold(origin, s.cfg.BaseURL) {
|
||||
return true
|
||||
|
||||
@@ -45,11 +45,15 @@ func (s *Server) publishToOwner(r *http.Request, event string, payload any) {
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
ticketed, _ := r.Context().Value(ctxTicket).(bool)
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
// The socket is same-origin only. Without this, any page on the internet
|
||||
// could open a socket carrying the user's cookie and watch their tasks —
|
||||
// the same-origin policy does not apply to WebSocket handshakes.
|
||||
OriginPatterns: s.allowedOrigins(),
|
||||
// Except one opened with a ticket, which carries no cookie for a foreign
|
||||
// page to borrow: see handleWebSocketEntry.
|
||||
InsecureSkipVerify: ticketed,
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Debug("websocket handshake", "err", err)
|
||||
|
||||
@@ -60,6 +60,10 @@ type Server struct {
|
||||
// instance should not be unreachable because one of them cannot be offered.
|
||||
passkeys *auth.WebAuthn
|
||||
|
||||
// One-use tickets for opening the live-sync socket without a cookie — see
|
||||
// app_handlers.go.
|
||||
wsTickets *wsTickets
|
||||
|
||||
// Password guessing is the attack this application is actually exposed to, so
|
||||
// the endpoints that check a secret are limited separately and more tightly
|
||||
// than the rest of the API.
|
||||
@@ -115,6 +119,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// their own code gets it right on the first or second try; ten in an hour is
|
||||
// generous for a person and hopeless for a search through a million.
|
||||
totpLimiter: newLimiter(10, time.Hour),
|
||||
wsTickets: newWSTickets(),
|
||||
}
|
||||
// Before the CSP, deliberately: the policy has to describe the bytes that go
|
||||
// out, and in the notes edition those are the renamed ones.
|
||||
@@ -273,11 +278,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
}
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// Before the CSRF lock, so a preflight is answered and never reaches it.
|
||||
r.Use(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
|
||||
@@ -328,6 +337,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Patch("/me", s.handleUpdateProfile)
|
||||
r.Put("/sidebar-sections", s.handleSetSidebarSections)
|
||||
r.Put("/nav-order", s.handleSetNavOrder)
|
||||
r.Put("/default-reminder", s.handleSetDefaultReminder)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Post("/password/change", s.handleChangePassword)
|
||||
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
||||
@@ -377,7 +387,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
full := !s.cfg.NotesOnly()
|
||||
r.Use(s.requireAuth)
|
||||
|
||||
r.Get("/ws", s.handleWebSocket)
|
||||
r.Post("/ws-ticket", s.handleWSTicket)
|
||||
|
||||
// The MCP endpoint. Reachable with a personal API token, so a connector
|
||||
// sees exactly what its owner sees.
|
||||
|
||||
@@ -23,8 +23,11 @@ type sessionJSON struct {
|
||||
// Device is a summary; UserAgent is what it was summarised from. Both, because
|
||||
// the summary is what you read and the original is what settles the question
|
||||
// when the summary is not enough.
|
||||
Device string `json:"device"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Device string `json:"device"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
// Client is "web" or "app". An app session slides — its expires_at moves
|
||||
// forward each time it is used — where a browser's is fixed.
|
||||
Client string `json:"client"`
|
||||
IP string `json:"ip"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
@@ -47,11 +50,17 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
out := make([]sessionJSON, 0, len(sessions))
|
||||
for _, sess := range sessions {
|
||||
// The app names itself; a browser is described from its user agent.
|
||||
device := sess.DeviceName
|
||||
if device == "" {
|
||||
device = describeUserAgent(sess.UA)
|
||||
}
|
||||
out = append(out, sessionJSON{
|
||||
ID: sess.ID,
|
||||
Current: current != nil && sess.ID == current.ID,
|
||||
Device: describeUserAgent(sess.UA),
|
||||
Device: device,
|
||||
UserAgent: sess.UA,
|
||||
Client: sess.Client,
|
||||
IP: sess.IP,
|
||||
CreatedAt: sess.CreatedAt.Format(time.RFC3339),
|
||||
LastSeenAt: sess.LastSeenAt.Format(time.RFC3339),
|
||||
|
||||
@@ -243,6 +243,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
// The store wrote the labels; the struct it was handed does not know that, and
|
||||
// the response has to describe the task that now exists.
|
||||
t.Labels = req.Labels
|
||||
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
|
||||
s.internal(w, r, "default reminder", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||
// Lavet til en anden med det samme. Det er den samme besked som at blive
|
||||
@@ -337,6 +341,12 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
// Et klokkeslu00e6t kan ogsu00e5 komme til EFTER at opgaven er lavet, og det er den
|
||||
// halvdel, der ellers mangler i et lu00f8fte om "alle opgaver med en dato og tid".
|
||||
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
|
||||
s.internal(w, r, "default reminder", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
||||
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||
if req.AssigneeID != nil && t.AssigneeID != wasAssigned {
|
||||
@@ -620,14 +630,55 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||
|
||||
if unknownProject != "" || unknownSection != "" {
|
||||
// The alarm the line asked for, after the task because a reminder needs its id.
|
||||
//
|
||||
// Set when the line asked to be reminded and the task has nothing to count back
|
||||
// from. An offset is relative to the due moment, and DueReminders selects the
|
||||
// offset form only `AND t.due_datetime IS NOT NULL` — so on a task with a day
|
||||
// but no clock there is nothing to be an hour before, and the row would sit in
|
||||
// the table forever without firing. Reported the same way an unknown project
|
||||
// is: the task is created either way, and the caller is told what could not be
|
||||
// honoured. An alarm that silently never rings is the one way a reminder must
|
||||
// not fail, because nothing afterwards reveals it.
|
||||
reminderNotSet := ""
|
||||
switch {
|
||||
case len(parsed.ReminderAt) == len("2006-01-02T15:04"):
|
||||
day, clock := parsed.ReminderAt[:10], parsed.ReminderAt[11:]
|
||||
if _, when, err := resolveDue(day, clock, user.Timezone); err == nil && when != nil {
|
||||
if _, err := s.db.CreateReminder(r.Context(), t.ID, user.ID, when, nil); err != nil {
|
||||
s.internal(w, r, "create reminder", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
case parsed.ReminderOffsetMin != nil:
|
||||
if t.DueDatetime == nil {
|
||||
reminderNotSet = "no due time to count back from"
|
||||
} else if _, err := s.db.CreateReminder(r.Context(), t.ID, user.ID, nil, parsed.ReminderOffsetMin); err != nil {
|
||||
s.internal(w, r, "create reminder", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Standarden til sidst, og efter den udtrykkelige: "mind mig en time fu00f8r" har
|
||||
// allerede lagt sin, og applyDefaultReminder lu00e6gger ikke en oven pu00e5. "ingen
|
||||
// alarm" er den anden vej ud u2014 den eneste mu00e5de at sige nej til standarden,
|
||||
// mens man skriver opgaven, frem for at u00e5bne den bagefter og slette alarmen.
|
||||
if !parsed.NoReminder {
|
||||
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
|
||||
s.internal(w, r, "default reminder", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if unknownProject != "" || unknownSection != "" || reminderNotSet != "" {
|
||||
// Alongside the task rather than instead of it: the task was created, and
|
||||
// this says what could not be honoured while creating it.
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
taskJSON
|
||||
UnknownProject string `json:"unknown_project,omitempty"`
|
||||
UnknownSection string `json:"unknown_section,omitempty"`
|
||||
}{toTaskJSON(*t), unknownProject, unknownSection})
|
||||
ReminderNotSet string `json:"reminder_not_set,omitempty"`
|
||||
}{toTaskJSON(*t), unknownProject, unknownSection, reminderNotSet})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||
@@ -804,3 +855,38 @@ func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) {
|
||||
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||
}
|
||||
|
||||
// applyDefaultReminder gives a task the alarm the account asks for without being
|
||||
// asked each time.
|
||||
//
|
||||
// Three things make it a no-op, and each of them is the point rather than a guard:
|
||||
//
|
||||
// - The account has the default off. That is where every account starts, and it
|
||||
// stays there until somebody turns it on: switching it on in a migration would
|
||||
// have given every existing user alarms they never asked for, on tasks already
|
||||
// lying there, in software other people run themselves.
|
||||
// - The task has no clock. A task due "tomorrow" has no moment to ring at, and
|
||||
// DueReminders selects the offset form only `AND t.due_datetime IS NOT NULL` —
|
||||
// so the row would sit there and never fire.
|
||||
// - The task already has a reminder. The line may have asked for one itself, or
|
||||
// the task may be being edited for the second time; either way the default is
|
||||
// a default and must not stack a second alarm on top of a stated one.
|
||||
//
|
||||
// Called on every path a task can gain a clock — created, edited, or quick-added —
|
||||
// because a default that only applies to one of them is a setting that is true on
|
||||
// Tuesdays. CalDAV is deliberately not one of them: a task arriving from Apple
|
||||
// Reminders brings its own alarm, and two alarms for one task is worse than none.
|
||||
func (s *Server) applyDefaultReminder(ctx context.Context, t *store.Task, user *store.User) error {
|
||||
if user.DefaultReminderMin == nil || t == nil || t.DueDatetime == nil {
|
||||
return nil
|
||||
}
|
||||
existing, err := s.db.ListReminders(ctx, t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
return nil
|
||||
}
|
||||
_, err = s.db.CreateReminder(ctx, t.ID, user.ID, nil, user.DefaultReminderMin)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2080,3 +2080,390 @@ func TestSnoozeSinksAndWakes(t *testing.T) {
|
||||
t.Errorf("woken task did not return: order = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- alarms written into the line ---------------------------------------------
|
||||
|
||||
// A dictated sentence sets its own alarm, and the alarm actually goes off.
|
||||
//
|
||||
// The assertion is on DueReminders rather than on a row existing, and the
|
||||
// difference is the whole test: a reminder row with the offset the wrong way round
|
||||
// exists just as convincingly and rings an hour LATE. So the question asked here is
|
||||
// "would this fire, and when" — one minute before it is due it must not be in the
|
||||
// list, one minute after it must.
|
||||
func TestQuickAddSetsTheAlarmTheLineAskedFor(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "ring til tandlægen i morgen kl 9 mind mig en time før",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["content"] != "ring til tandlægen" {
|
||||
t.Errorf("content = %v, want %q", body["content"], "ring til tandlægen")
|
||||
}
|
||||
if body["reminder_not_set"] != nil {
|
||||
t.Errorf("reminder_not_set = %v, want absent — the task has a clock", body["reminder_not_set"])
|
||||
}
|
||||
|
||||
taskID, _ := body["id"].(string)
|
||||
rems, err := ts.db.ListReminders(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 1 {
|
||||
t.Fatalf("got %d reminders, want 1", len(rems))
|
||||
}
|
||||
if rems[0].OffsetMin == nil {
|
||||
t.Fatalf("reminder has no offset; it should not be absolute")
|
||||
}
|
||||
if *rems[0].OffsetMin != -60 {
|
||||
t.Errorf("offset = %d, want -60 — the store ADDS this to the due time, so a "+
|
||||
"positive number is an alarm an hour late", *rems[0].OffsetMin)
|
||||
}
|
||||
|
||||
due, err := time.Parse(time.RFC3339, body["due_datetime"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("parse due_datetime %v: %v", body["due_datetime"], err)
|
||||
}
|
||||
|
||||
early, err := ts.db.DueReminders(ctx, due.Add(-61*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("due reminders (early): %v", err)
|
||||
}
|
||||
if len(early) != 0 {
|
||||
t.Errorf("61 minutes before it is due, %d reminders are already due", len(early))
|
||||
}
|
||||
|
||||
ripe, err := ts.db.DueReminders(ctx, due.Add(-59*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("due reminders (ripe): %v", err)
|
||||
}
|
||||
if len(ripe) != 1 {
|
||||
t.Fatalf("59 minutes before it is due, got %d reminders, want 1", len(ripe))
|
||||
}
|
||||
if ripe[0].TaskContent != "ring til tandlægen" {
|
||||
t.Errorf("the reminder that came due is for %q", ripe[0].TaskContent)
|
||||
}
|
||||
}
|
||||
|
||||
// An alarm with nothing to count back from is refused out loud.
|
||||
//
|
||||
// "mind mig en time før" on a task with a day but no clock has no moment to be an
|
||||
// hour before, and DueReminders selects the offset form only when the task has a
|
||||
// due_datetime — so the row would sit there and never fire. The task is created and
|
||||
// the caller is told, the same way an unknown #project is reported. An alarm that
|
||||
// silently never rings is the one way a reminder must not fail.
|
||||
func TestQuickAddSaysWhenTheAlarmCannotFire(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "køb mælk i morgen mind mig en time før",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["content"] != "køb mælk" {
|
||||
t.Errorf("content = %v, want %q", body["content"], "køb mælk")
|
||||
}
|
||||
if body["reminder_not_set"] == nil {
|
||||
t.Errorf("reminder_not_set is absent; the alarm could not be set and nothing says so")
|
||||
}
|
||||
|
||||
taskID, _ := body["id"].(string)
|
||||
rems, err := ts.db.ListReminders(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 0 {
|
||||
t.Errorf("got %d reminders, want 0 — a reminder that can never fire is worse "+
|
||||
"than none, because nothing afterwards reveals it", len(rems))
|
||||
}
|
||||
}
|
||||
|
||||
// A reminder given a clock of its own is kept as an absolute moment.
|
||||
func TestQuickAddSetsAnAbsoluteAlarm(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "ring til tandlægen i morgen kl 9 mind mig kl 8",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
taskID, _ := body["id"].(string)
|
||||
rems, err := ts.db.ListReminders(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 1 {
|
||||
t.Fatalf("got %d reminders, want 1", len(rems))
|
||||
}
|
||||
if rems[0].OffsetMin != nil {
|
||||
t.Errorf("offset = %d, want none — a clock was written, so it is absolute", *rems[0].OffsetMin)
|
||||
}
|
||||
|
||||
due, err := time.Parse(time.RFC3339, body["due_datetime"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("parse due_datetime %v: %v", body["due_datetime"], err)
|
||||
}
|
||||
// Due at nine, reminded at eight: one hour earlier, on the task's own day.
|
||||
if want := due.Add(-time.Hour); !rems[0].RemindAt.Equal(want) {
|
||||
t.Errorf("RemindAt = %v, want %v", rems[0].RemindAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- the account's standing alarm --------------------------------------------
|
||||
|
||||
// turnOnDefaultAlarm switches the account's standing alarm on, so many minutes
|
||||
// before the due moment.
|
||||
func turnOnDefaultAlarm(t *testing.T, ts *testServer, minutes int) {
|
||||
t.Helper()
|
||||
resp, body := ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": minutes})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("set default reminder: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// With the setting on, a task that has a clock gets its alarm without being asked.
|
||||
func TestDefaultAlarmAppliesToATaskWithAClock(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
turnOnDefaultAlarm(t, ts, -10)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente cyklen i morgen kl 16",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 1 {
|
||||
t.Fatalf("got %d reminders, want 1", len(rems))
|
||||
}
|
||||
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -10 {
|
||||
t.Errorf("offset = %v, want -10", rems[0].OffsetMin)
|
||||
}
|
||||
}
|
||||
|
||||
// And off is where every account starts. This is the control for the test above:
|
||||
// without it, an alarm appearing for any reason at all reads as the setting working.
|
||||
func TestNoDefaultAlarmUntilItIsTurnedOn(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente cyklen i morgen kl 16",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 0 {
|
||||
t.Errorf("got %d reminders on a fresh account, want 0 — turning a notification "+
|
||||
"on for everybody is not a default anybody chose", len(rems))
|
||||
}
|
||||
}
|
||||
|
||||
// A task with a day but no clock gets nothing, because there is no moment to ring
|
||||
// at: DueReminders selects the offset form only when due_datetime is set, so the
|
||||
// row would be one that never fires.
|
||||
func TestDefaultAlarmNeedsAClockNotJustADay(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
turnOnDefaultAlarm(t, ts, -10)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente cyklen i morgen",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 0 {
|
||||
t.Errorf("got %d reminders, want 0 — there is no clock to count back from", len(rems))
|
||||
}
|
||||
}
|
||||
|
||||
// The default does not stack on top of an alarm the line asked for.
|
||||
func TestDefaultAlarmDoesNotStackOnAStatedOne(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
turnOnDefaultAlarm(t, ts, -10)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente cyklen i morgen kl 16 mind mig en time før",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 1 {
|
||||
t.Fatalf("got %d reminders, want 1 — the stated alarm, and no default on top", len(rems))
|
||||
}
|
||||
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -60 {
|
||||
t.Errorf("offset = %v, want -60 — what the line said, not the default", rems[0].OffsetMin)
|
||||
}
|
||||
}
|
||||
|
||||
// "ingen alarm" is the way out of the default while writing the task.
|
||||
//
|
||||
// Without it the only way to have a timed task that does not ring is to create it,
|
||||
// open it, and delete the alarm that was just made — which is the omission a
|
||||
// default quietly creates.
|
||||
func TestNoAlarmInTheLineBeatsTheDefault(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
turnOnDefaultAlarm(t, ts, -10)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente cyklen i morgen kl 16 ingen alarm",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["content"] != "hente cyklen" {
|
||||
t.Errorf("content = %v, want %q — the refusal should leave the title", body["content"], "hente cyklen")
|
||||
}
|
||||
|
||||
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 0 {
|
||||
t.Errorf("got %d reminders, want 0 — the line said no alarm", len(rems))
|
||||
}
|
||||
}
|
||||
|
||||
// A clock added later gets the alarm too.
|
||||
//
|
||||
// This is the half a promise about "every task with a date and time" loses if the
|
||||
// default is only applied where tasks are created: a task written now and given a
|
||||
// time this afternoon is the same task.
|
||||
func TestDefaultAlarmAppliesWhenTheClockComesLater(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
turnOnDefaultAlarm(t, ts, -10)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente cyklen",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
taskID := body["id"].(string)
|
||||
|
||||
rems, err := ts.db.ListReminders(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 0 {
|
||||
t.Fatalf("got %d reminders before there was a clock, want 0", len(rems))
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "PATCH", "/api/v1/tasks/"+taskID, map[string]any{
|
||||
"due_date": time.Now().AddDate(0, 0, 1).Format("2006-01-02"),
|
||||
"due_time": "16:00",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("patch task: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
rems, err = ts.db.ListReminders(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 1 {
|
||||
t.Fatalf("got %d reminders after the clock was added, want 1", len(rems))
|
||||
}
|
||||
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -10 {
|
||||
t.Errorf("offset = %v, want -10", rems[0].OffsetMin)
|
||||
}
|
||||
}
|
||||
|
||||
// A positive offset is refused. An alarm after the thing it warns about is not a
|
||||
// reminder, and accepting one here would make the single mistake this value can
|
||||
// make — a sign the wrong way round — look like a choice somebody made.
|
||||
func TestDefaultAlarmRefusesAnOffsetAfterTheDueTime(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": 10})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity && resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("set default reminder with +10: %d %v, want a refusal", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Null turns it off again, and the alarms already made are left alone: a default is
|
||||
// what happens to the NEXT task, not a statement about the ones already written.
|
||||
func TestDefaultAlarmCanBeTurnedOffAgain(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ctx := t.Context()
|
||||
turnOnDefaultAlarm(t, ts, -10)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente cyklen i morgen kl 16",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
kept := body["id"].(string)
|
||||
|
||||
resp, body = ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": nil})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("turn off: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "hente bilen i morgen kl 17",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 0 {
|
||||
t.Errorf("got %d reminders after turning the default off, want 0", len(rems))
|
||||
}
|
||||
|
||||
rems, err = ts.db.ListReminders(ctx, kept)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rems) != 1 {
|
||||
t.Errorf("the alarm made while it was on is gone; turning a default off is not " +
|
||||
"a statement about the tasks already written")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ const (
|
||||
KindSection Kind = "section"
|
||||
KindLabel Kind = "label"
|
||||
KindRepeat Kind = "repeat"
|
||||
KindReminder Kind = "reminder"
|
||||
)
|
||||
|
||||
// Span is a byte range of the input that was consumed, and what it was read as.
|
||||
@@ -70,7 +71,28 @@ type Result struct {
|
||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||
// RecurrenceText is that rule written back out in Danish, for the preview.
|
||||
RecurrenceText string `json:"recurrence_text,omitempty"`
|
||||
Spans []Span `json:"spans,omitempty"`
|
||||
// ReminderOffsetMin is when to be told, as minutes relative to the task's due
|
||||
// moment: negative for before it, zero for at it. Nil when the line asked for
|
||||
// no reminder.
|
||||
//
|
||||
// Relative rather than absolute, so that moving the task moves its alarm with
|
||||
// it. Mind the sign: the store ADDS this to the due time
|
||||
// (`due_datetime + offset_min * 60`), so "an hour before" is -60. Getting that
|
||||
// backwards gives an alarm an hour late, which is the one direction a reminder
|
||||
// must never be wrong in.
|
||||
ReminderOffsetMin *int `json:"reminder_offset_min,omitempty"`
|
||||
// ReminderAt is an absolute reminder, "YYYY-MM-DDTHH:MM", set when the line
|
||||
// gave the reminder a clock of its own. It takes the day the task is due:
|
||||
// "mind mig kl 8" about something due Friday means Friday at eight, not today.
|
||||
ReminderAt string `json:"reminder_at,omitempty"`
|
||||
// NoReminder is the line saying it wants no alarm on this one — "ingen alarm".
|
||||
//
|
||||
// It exists because of the account-wide default: with alarms on for everything
|
||||
// that has a clock, there has to be a way to say "not this one" in the same
|
||||
// breath as writing it. Without that the only way out is to create the task,
|
||||
// open it, and delete the alarm that was just made.
|
||||
NoReminder bool `json:"no_reminder,omitempty"`
|
||||
Spans []Span `json:"spans,omitempty"`
|
||||
}
|
||||
|
||||
// Parse reads input as of the moment `now`, which is also the timezone dates are
|
||||
@@ -96,6 +118,20 @@ func Parse(input string, now time.Time, locale string) Result {
|
||||
// mask keeps byte offsets intact, so spans still point into the original input.
|
||||
masked := mask(input, spans)
|
||||
|
||||
// Reminders before the clock and the calendar, and that order is the whole
|
||||
// trick: "mind mig en time før" has a duration in it and "mind mig kl 8" has a
|
||||
// clock, and either one read by the time parser first becomes the task's own
|
||||
// due time instead of its alarm. The phrase is anchored on a keyword the way a
|
||||
// project is anchored on '#', so consuming it this early cannot take anything
|
||||
// that was not offered to it.
|
||||
// "No alarm" before "alarm", or the refusal leaves the very word the stage
|
||||
// below is looking for standing in the line.
|
||||
res.NoReminder, spans = extractNoReminder(masked, spans)
|
||||
masked = mask(masked, spans)
|
||||
|
||||
rem, spans := extractReminder(masked, spans)
|
||||
masked = mask(masked, spans)
|
||||
|
||||
// Recurrence before the date parser, and this order is load-bearing: "hver
|
||||
// mandag" contains a weekday, and a date parser reading it first would claim
|
||||
// Monday as a one-off due date and leave "hver" stranded in the title.
|
||||
@@ -137,6 +173,21 @@ func Parse(input string, now time.Time, locale string) Result {
|
||||
res.DueTime = pad2(hour) + ":" + pad2(minute)
|
||||
}
|
||||
|
||||
// The reminder is resolved last, because an absolute one needs the due date the
|
||||
// stages above just worked out.
|
||||
if rem.found {
|
||||
if rem.hasClock {
|
||||
day := res.DueDate
|
||||
if day == "" {
|
||||
day = now.Format("2006-01-02")
|
||||
}
|
||||
res.ReminderAt = day + "T" + pad2(rem.hour) + ":" + pad2(rem.min)
|
||||
} else {
|
||||
off := rem.offsetMin
|
||||
res.ReminderOffsetMin = &off
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].Start < spans[j].Start })
|
||||
res.Spans = spans
|
||||
res.Content = remove(input, spans)
|
||||
@@ -687,3 +738,131 @@ func extractRecurrence(input string, spans []Span) (string, []Span) {
|
||||
}
|
||||
return "", spans
|
||||
}
|
||||
|
||||
// --- reminders ----------------------------------------------------------------
|
||||
|
||||
// reReminderLead finds the words that introduce an alarm. A reminder is anchored
|
||||
// on a keyword the way a project is anchored on '#': nothing becomes an alarm by
|
||||
// standing near a clock, because "ring til tandlægen kl 9" asks for a task at nine
|
||||
// and not to be woken before it.
|
||||
//
|
||||
// The trailing \b is load-bearing. Without it the pattern matches the first six
|
||||
// letters of "alarmen" and of "mind migrationen", takes them out of the title, and
|
||||
// sets an alarm nobody asked for.
|
||||
var reReminderLead = regexp.MustCompile(
|
||||
`(?i)(?:^|\s)(mind\s+mig(?:\s+om\s+det)?|påmind(?:\s+mig)?|paamind(?:\s+mig)?` +
|
||||
`|remind\s+me(?:\s+about\s+it)?|med\s+alarm)\b`)
|
||||
|
||||
// reReminderOffset reads the tail as "this long before": "en time før", "10 min
|
||||
// før", "et kvarter før", "dagen før", "two days before".
|
||||
var reReminderOffset = regexp.MustCompile(
|
||||
`(?i)^[\s,]*(?:om\s+)?(\d+|en|et|a|an|to|two|tre|three)?\s*` +
|
||||
`(halv\s+time|halvtime|kvarter|minutter|minut|minutes|minute|min\.?` +
|
||||
`|timer|time|hours|hour|dage|dagen|dag|days|day)\s*` +
|
||||
`(?:før|foer|inden|before)`)
|
||||
|
||||
// reReminderClock reads the tail as a clock of its own: "kl 8", "kl. 7.30", "at 7:30".
|
||||
var reReminderClock = regexp.MustCompile(`(?i)^[\s,]*(?:kl\.?|klokken|at)\s*(\d{1,2})(?:[.:](\d{2}))?`)
|
||||
|
||||
// reminder is what the phrase said. OffsetMin is minutes relative to the due
|
||||
// moment and negative for "before"; zero means at the due moment, which is what a
|
||||
// bare "mind mig" asks for.
|
||||
type reminder struct {
|
||||
found bool
|
||||
offsetMin int
|
||||
hour, min int
|
||||
hasClock bool
|
||||
}
|
||||
|
||||
// extractReminder reads the alarm out of the line.
|
||||
//
|
||||
// The lead-in is matched first and the tail interpreted afterwards, rather than in
|
||||
// one pattern, for the same reason the recurrence stage does it: a single
|
||||
// expression able to express all three endings — a duration, a clock, or nothing
|
||||
// at all — matches far more than the three things it is for.
|
||||
func extractReminder(input string, spans []Span) (reminder, []Span) {
|
||||
m := reReminderLead.FindStringSubmatchIndex(input)
|
||||
if m == nil {
|
||||
return reminder{}, spans
|
||||
}
|
||||
|
||||
rem := reminder{found: true}
|
||||
start, end := m[2], m[3]
|
||||
tail := input[end:]
|
||||
|
||||
if t := reReminderOffset.FindStringSubmatchIndex(tail); t != nil {
|
||||
n := 1
|
||||
if t[2] >= 0 {
|
||||
n = spokenNumber(tail[t[2]:t[3]])
|
||||
}
|
||||
rem.offsetMin = -n * unitMinutes(tail[t[4]:t[5]])
|
||||
end += t[1]
|
||||
} else if t := reReminderClock.FindStringSubmatchIndex(tail); t != nil {
|
||||
hour := atoi(tail, t[2], t[3])
|
||||
minute := 0
|
||||
if t[4] >= 0 {
|
||||
minute = atoi(tail, t[4], t[5])
|
||||
}
|
||||
// An impossible clock is not a reminder. Saying nothing is better than
|
||||
// setting one for 25:00, which the store would happily keep and never fire.
|
||||
if !valid(hour, minute) {
|
||||
return reminder{}, spans
|
||||
}
|
||||
rem.hour, rem.min, rem.hasClock = hour, minute, true
|
||||
end += t[1]
|
||||
}
|
||||
|
||||
return rem, append(spans, Span{Start: start, End: end, Kind: KindReminder})
|
||||
}
|
||||
|
||||
// spokenNumber reads the count in front of a unit. People dictating say the word
|
||||
// rather than the digit, and "mind mig en time før" is the commonest form there is.
|
||||
func spokenNumber(w string) int {
|
||||
switch strings.ToLower(w) {
|
||||
case "en", "et", "a", "an":
|
||||
return 1
|
||||
case "to", "two":
|
||||
return 2
|
||||
case "tre", "three":
|
||||
return 3
|
||||
}
|
||||
if n, err := strconv.Atoi(w); err == nil {
|
||||
return n
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// unitMinutes turns the unit into minutes. "halv time" and "kvarter" carry their
|
||||
// own count, so they come first: with a leading "en" they multiply by one and come
|
||||
// out as themselves.
|
||||
func unitMinutes(unit string) int {
|
||||
u := strings.ToLower(strings.Join(strings.Fields(unit), " "))
|
||||
switch {
|
||||
case strings.HasPrefix(u, "halv"):
|
||||
return 30
|
||||
case strings.HasPrefix(u, "kvarter"):
|
||||
return 15
|
||||
case strings.HasPrefix(u, "min"):
|
||||
return 1
|
||||
case strings.HasPrefix(u, "time"), strings.HasPrefix(u, "hour"):
|
||||
return 60
|
||||
case strings.HasPrefix(u, "dag"), strings.HasPrefix(u, "day"):
|
||||
return 1440
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// reNoReminder finds the line asking not to be reminded. The account-wide default
|
||||
// gives every task with a clock an alarm, and this is how one task opts out while
|
||||
// it is being written rather than afterwards.
|
||||
var reNoReminder = regexp.MustCompile(
|
||||
`(?i)(?:^|\s)(ingen\s+(?:alarm|påmindelse|paamindelse)|uden\s+alarm` +
|
||||
`|no\s+alarm|no\s+reminder|without\s+an?\s+alarm)\b`)
|
||||
|
||||
func extractNoReminder(input string, spans []Span) (bool, []Span) {
|
||||
m := reNoReminder.FindStringSubmatchIndex(input)
|
||||
if m == nil {
|
||||
return false, spans
|
||||
}
|
||||
return true, append(spans, Span{Start: m[2], End: m[3], Kind: KindReminder})
|
||||
}
|
||||
|
||||
@@ -636,3 +636,151 @@ func TestEveryOtherWeekdayIsARepetition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- reminders ----------------------------------------------------------------
|
||||
|
||||
// A dictated sentence can set its own alarm.
|
||||
//
|
||||
// The offset is negative because the store ADDS it to the due moment
|
||||
// (`due_datetime + offset_min * 60`). A positive number there is an alarm after
|
||||
// the thing it was meant to warn about, which is the one direction a reminder must
|
||||
// never be wrong in — so the sign is asserted here and not assumed.
|
||||
func TestParseReminderOffset(t *testing.T) {
|
||||
now := ref(t)
|
||||
cases := []struct {
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{"ring til tandlægen i morgen kl 9 mind mig en time før", -60},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig 10 min før", -10},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig 10 minutter før", -10},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig et kvarter før", -15},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig en halv time før", -30},
|
||||
{"møde i morgen kl 9 mind mig dagen før", -1440},
|
||||
{"møde i morgen kl 9 mind mig to dage før", -2880},
|
||||
{"møde i morgen kl 9 mind mig 2 timer før", -120},
|
||||
// Det er talt ind, så kommaet hører til sætningen og ikke til syntaksen.
|
||||
{"ring til tandlægen i morgen kl 9, mind mig en time før", -60},
|
||||
{"ring til tandlægen i morgen kl 9, påmind mig en time før", -60},
|
||||
{"ring til tandlægen i morgen kl 9, mind mig om det en time før", -60},
|
||||
// Engelsk i samme linje, som resten af parseren gør det.
|
||||
{"call the dentist tomorrow at 9 remind me an hour before", -60},
|
||||
{"call the dentist tomorrow at 9 remind me 15 minutes before", -15},
|
||||
{"call the dentist tomorrow at 9 remind me two days before", -2880},
|
||||
// En bar påmindelse er på selve forfaldstidspunktet.
|
||||
{"ring til tandlægen i morgen kl 9 mind mig", 0},
|
||||
{"ring til tandlægen i morgen kl 9 med alarm", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := Parse(c.in, now, "da")
|
||||
if got.ReminderOffsetMin == nil {
|
||||
t.Errorf("Parse(%q): ReminderOffsetMin = nil, want %d", c.in, c.want)
|
||||
continue
|
||||
}
|
||||
if *got.ReminderOffsetMin != c.want {
|
||||
t.Errorf("Parse(%q): ReminderOffsetMin = %d, want %d", c.in, *got.ReminderOffsetMin, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A reminder with a clock of its own takes the day the task is due.
|
||||
//
|
||||
// "mind mig kl 8" about something due Friday means Friday at eight. Resolving it
|
||||
// against today instead would fire the alarm days early, silently.
|
||||
func TestParseReminderClockTakesTheDueDay(t *testing.T) {
|
||||
now := ref(t)
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"ring til tandlægen i morgen kl 9 mind mig kl 8", "2026-03-11T08:00"},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig kl. 7.30", "2026-03-11T07:30"},
|
||||
{"ring til tandlægen i morgen kl 9, påmind kl 7:15", "2026-03-11T07:15"},
|
||||
{"call the dentist tomorrow at 9 remind me at 8", "2026-03-11T08:00"},
|
||||
// Ingen dato i linjen: så er det i dag, som en bar klokketid også er.
|
||||
{"ring til tandlægen mind mig kl 23", "2026-03-10T23:00"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := Parse(c.in, now, "da")
|
||||
if got.ReminderAt != c.want {
|
||||
t.Errorf("Parse(%q): ReminderAt = %q, want %q", c.in, got.ReminderAt, c.want)
|
||||
}
|
||||
if got.ReminderOffsetMin != nil {
|
||||
t.Errorf("Parse(%q): also set an offset (%d); a reminder is one or the other",
|
||||
c.in, *got.ReminderOffsetMin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The alarm does not eat the task's own time, and the task's time does not eat the
|
||||
// alarm. This is the ordering the stage exists for.
|
||||
func TestReminderDoesNotTakeTheTasksOwnTime(t *testing.T) {
|
||||
got := Parse("ring til tandlægen i morgen kl 9 mind mig kl 8", ref(t), "da")
|
||||
|
||||
if got.DueTime != "09:00" {
|
||||
t.Errorf("DueTime = %q, want 09:00 — the reminder took the task's clock", got.DueTime)
|
||||
}
|
||||
if got.DueDate != "2026-03-11" {
|
||||
t.Errorf("DueDate = %q, want 2026-03-11", got.DueDate)
|
||||
}
|
||||
if got.ReminderAt != "2026-03-11T08:00" {
|
||||
t.Errorf("ReminderAt = %q, want 2026-03-11T08:00", got.ReminderAt)
|
||||
}
|
||||
if got.Content != "ring til tandlægen" {
|
||||
t.Errorf("Content = %q, want %q", got.Content, "ring til tandlægen")
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing becomes an alarm by standing near a clock.
|
||||
func TestNoReminderWhereNoneWasAskedFor(t *testing.T) {
|
||||
now := ref(t)
|
||||
for _, in := range []string{
|
||||
"ring til tandlægen i morgen kl 9",
|
||||
"betal moms i morgen kl 10 p1 #Firma @regnskab",
|
||||
// The \b cases: these words merely begin with the keywords.
|
||||
"skift batteri i alarmen",
|
||||
"mind migrationen om at køre",
|
||||
"vand planterne hver mandag kl 9",
|
||||
} {
|
||||
got := Parse(in, now, "da")
|
||||
if got.ReminderOffsetMin != nil {
|
||||
t.Errorf("Parse(%q): ReminderOffsetMin = %d, want none", in, *got.ReminderOffsetMin)
|
||||
}
|
||||
if got.ReminderAt != "" {
|
||||
t.Errorf("Parse(%q): ReminderAt = %q, want none", in, got.ReminderAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The phrase is highlighted, like every other thing the parser claims — see the
|
||||
// package comment: a parser you cannot see working is one you stop trusting.
|
||||
func TestReminderIsHighlighted(t *testing.T) {
|
||||
in := "ring til tandlægen i morgen kl 9 mind mig en time før"
|
||||
got := Parse(in, ref(t), "da")
|
||||
|
||||
var span *Span
|
||||
for i := range got.Spans {
|
||||
if got.Spans[i].Kind == KindReminder {
|
||||
span = &got.Spans[i]
|
||||
}
|
||||
}
|
||||
if span == nil {
|
||||
t.Fatalf("no %q span in %v", KindReminder, got.Spans)
|
||||
}
|
||||
if marked := in[span.Start:span.End]; marked != "mind mig en time før" {
|
||||
t.Errorf("highlighted %q, want %q", marked, "mind mig en time før")
|
||||
}
|
||||
}
|
||||
|
||||
// An impossible clock is not a reminder: the store would keep 25:00 and never fire
|
||||
// it, so saying nothing is the honest answer.
|
||||
func TestImpossibleReminderClockIsNotKept(t *testing.T) {
|
||||
got := Parse("ring til tandlægen i morgen kl 9 mind mig kl 25", ref(t), "da")
|
||||
|
||||
if got.ReminderAt != "" {
|
||||
t.Errorf("ReminderAt = %q, want none", got.ReminderAt)
|
||||
}
|
||||
if got.ReminderOffsetMin != nil {
|
||||
t.Errorf("ReminderOffsetMin = %d, want none", *got.ReminderOffsetMin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Alarm som standard på opgaver med et klokkeslæt.
|
||||
--
|
||||
-- Påmindelser har virket længe og skulle sættes én ad gangen: en opgave med
|
||||
-- "i morgen kl 9" fik en tid og ingen alarm, for intet skaber en påmindelse af sig
|
||||
-- selv. Det er den rigtige standard for en opgave UDEN klokkeslæt — der er intet
|
||||
-- tidspunkt at ringe på — men for en med et er det en omvej, man går hver gang.
|
||||
--
|
||||
-- Minutter i forhold til forfaldstiden, og NULL betyder slået fra. Én nullable
|
||||
-- kolonne frem for et flueben plus et tal, så tilstanden "slået til uden
|
||||
-- forskydning" ikke findes og derfor ikke kan blive uenig med sig selv.
|
||||
--
|
||||
-- Fortegnet er det samme som i reminders.offset_min, fordi det er den kolonne,
|
||||
-- værdien ender i: lageret LÆGGER tallet til forfaldstiden, så en alarm FØR er
|
||||
-- negativ og 0 er præcis på tidspunktet. To forskellige fortegnsregler for det
|
||||
-- samme begreb er den slags forskel, der kun viser sig som en alarm, der ringer
|
||||
-- på det forkerte tidspunkt.
|
||||
--
|
||||
-- NULL for alle, der findes i dag, og det er med vilje. At slå den til i en
|
||||
-- migrering ville give hver eksisterende bruger alarmer, de ikke har bedt om, på
|
||||
-- opgaver de har liggende — og det her er software, andre selv driver. En
|
||||
-- indstilling, man selv slår til, er en anden sag end en notifikation, der
|
||||
-- pludselig kommer.
|
||||
ALTER TABLE users ADD COLUMN default_reminder_min INTEGER;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Sessioner fra en app.
|
||||
--
|
||||
-- En app logger ind som en browser gør, med adgangskode og evt. kode, og får en
|
||||
-- almindelig session. Forskellen er transporten: tokenet kommer i svaret og sendes
|
||||
-- som `Authorization: Bearer`, fordi en app, der henter sit UI fra sig selv, ikke
|
||||
-- deler origin med serveren og derfor aldrig får cookien tilbage. At det er en
|
||||
-- SESSION og ikke et API-token er hele pointen: den står i listen over enheder,
|
||||
-- kan logges ud derfra, og den når indstillingerne for 2FA og nøgler, som et
|
||||
-- token med vilje ikke gør.
|
||||
--
|
||||
-- device_name er det navn, appen giver sig selv ("Kristians iPhone"), så listen
|
||||
-- siger noget, en person genkender. idle_ttl er sekunder: større end 0 betyder, at
|
||||
-- sessionen glider — hver gang den bruges, flyttes udløbet frem. En browser får
|
||||
-- 0 og sin faste levetid som før; en telefon, man har i lommen hver dag, må ikke
|
||||
-- logge sig selv ud hver 30. dag, men en telefon, der ligger i en skuffe et halvt
|
||||
-- år, skal.
|
||||
ALTER TABLE sessions ADD COLUMN client TEXT NOT NULL DEFAULT 'web';
|
||||
ALTER TABLE sessions ADD COLUMN device_name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE sessions ADD COLUMN idle_ttl INTEGER NOT NULL DEFAULT 0;
|
||||
+70
-31
@@ -15,9 +15,36 @@ type Session struct {
|
||||
UA string
|
||||
IP string
|
||||
PendingTOTP bool
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
ExpiresAt time.Time
|
||||
// Client is "web" for a browser and "app" for the iOS app, which carries its
|
||||
// session as a bearer token rather than a cookie. DeviceName is what the app
|
||||
// calls itself; a browser has none and is described from its user agent.
|
||||
Client string
|
||||
DeviceName string
|
||||
// IdleTTL above zero makes the session slide: each use moves ExpiresAt to
|
||||
// IdleTTL from now. Zero is the fixed lifetime a browser session has always had.
|
||||
IdleTTL time.Duration
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
const sessionColumns = `id, user_id, ua, ip, pending_totp, client, device_name, idle_ttl,
|
||||
created_at, last_seen_at, expires_at`
|
||||
|
||||
func scanSession(row interface{ Scan(...any) error }) (*Session, error) {
|
||||
var s Session
|
||||
var pending int
|
||||
var idle, created, lastSeen, expires int64
|
||||
if err := row.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &s.Client, &s.DeviceName, &idle,
|
||||
&created, &lastSeen, &expires); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.PendingTOTP = pending == 1
|
||||
s.IdleTTL = time.Duration(idle) * time.Second
|
||||
s.CreatedAt = time.Unix(created, 0).UTC()
|
||||
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
|
||||
s.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// CreateSession issues a session and returns the value to put in the cookie.
|
||||
@@ -40,6 +67,7 @@ func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time
|
||||
UA: truncate(ua, 500),
|
||||
IP: truncate(ip, 100),
|
||||
PendingTOTP: pendingTOTP,
|
||||
Client: "web",
|
||||
CreatedAt: now,
|
||||
LastSeenAt: now,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
@@ -63,13 +91,8 @@ func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time
|
||||
func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User, error) {
|
||||
id := auth.HashToken(token)
|
||||
|
||||
var s Session
|
||||
var pending int
|
||||
var created, lastSeen, expires int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at
|
||||
FROM sessions WHERE id = ?`, id).
|
||||
Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires)
|
||||
s, err := scanSession(db.QueryRowContext(ctx,
|
||||
`SELECT `+sessionColumns+` FROM sessions WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
@@ -77,11 +100,6 @@ func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
s.PendingTOTP = pending == 1
|
||||
s.CreatedAt = time.Unix(created, 0).UTC()
|
||||
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
|
||||
s.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
|
||||
if time.Now().After(s.ExpiresAt) {
|
||||
_, _ = db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, s.ID)
|
||||
return nil, nil, ErrNotFound
|
||||
@@ -95,20 +113,47 @@ func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User
|
||||
// last_seen_at drives the session list in settings ("this device, 2 minutes
|
||||
// ago"). It is written at most once a minute: every request would be a write
|
||||
// per request, which on SQLite means a write lock per request.
|
||||
//
|
||||
// A sliding session moves its end in the same write, and only then: its
|
||||
// expiry is therefore accurate to the minute, which is far finer than the
|
||||
// months it is measured in.
|
||||
if time.Since(s.LastSeenAt) > time.Minute {
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), s.ID)
|
||||
now := time.Now()
|
||||
if s.IdleTTL > 0 && !s.PendingTOTP {
|
||||
s.ExpiresAt = now.Add(s.IdleTTL).UTC()
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`,
|
||||
now.Unix(), s.ExpiresAt.Unix(), s.ID)
|
||||
} else {
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`UPDATE sessions SET last_seen_at = ? WHERE id = ?`, now.Unix(), s.ID)
|
||||
}
|
||||
}
|
||||
return &s, user, nil
|
||||
return s, user, nil
|
||||
}
|
||||
|
||||
// MarkAppSession records that a session belongs to the iOS app, what the device
|
||||
// calls itself, and how long it may sit unused (see Session.IdleTTL).
|
||||
//
|
||||
// A login still waiting on its code is marked with idle 0, so it keeps its ten
|
||||
// minutes; PromoteSession turns sliding on once the code is in.
|
||||
func (db *DB) MarkAppSession(ctx context.Context, sessionID, deviceName string, idle time.Duration) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE sessions SET client = 'app', device_name = ?, idle_ttl = ? WHERE id = ?`,
|
||||
truncate(deviceName, 100), int64(idle/time.Second), sessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
// PromoteSession completes a two-step login: the session stops being pending and
|
||||
// its clock restarts, so the full lifetime is counted from finishing the login and
|
||||
// not from typing the password.
|
||||
func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl time.Duration) error {
|
||||
//
|
||||
// idle above zero makes the finished session slide (see Session.IdleTTL); a
|
||||
// browser passes zero.
|
||||
func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl, idle time.Duration) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE sessions SET pending_totp = 0, expires_at = ? WHERE id = ? AND pending_totp = 1`,
|
||||
time.Now().Add(ttl).Unix(), sessionID)
|
||||
`UPDATE sessions SET pending_totp = 0, expires_at = ?, idle_ttl = ? WHERE id = ? AND pending_totp = 1`,
|
||||
time.Now().Add(ttl).Unix(), int64(idle/time.Second), sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -147,7 +192,7 @@ func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error {
|
||||
|
||||
func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at
|
||||
`SELECT `+sessionColumns+`
|
||||
FROM sessions WHERE user_id = ? AND pending_totp = 0 ORDER BY last_seen_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -156,17 +201,11 @@ func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error
|
||||
|
||||
var out []Session
|
||||
for rows.Next() {
|
||||
var s Session
|
||||
var pending int
|
||||
var created, lastSeen, expires int64
|
||||
if err := rows.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires); err != nil {
|
||||
s, err := scanSession(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.PendingTOTP = pending == 1
|
||||
s.CreatedAt = time.Unix(created, 0).UTC()
|
||||
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
|
||||
s.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
out = append(out, s)
|
||||
out = append(out, *s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
+44
-3
@@ -46,7 +46,22 @@ type User struct {
|
||||
// NavOrder is the order this person wants the fixed views in, by key. Empty
|
||||
// means the order the program ships with. Unknown keys are ignored and missing
|
||||
// ones are appended, so adding a view later is not a migration.
|
||||
NavOrder []string
|
||||
NavOrder []string
|
||||
|
||||
// DefaultReminderMin gives every task that has a clock an alarm without being
|
||||
// asked, as minutes relative to the due moment: negative for before it, zero
|
||||
// for at it. Nil is off, which is what every account starts as.
|
||||
//
|
||||
// The same sign convention as reminders.offset_min, because this is the column
|
||||
// the value ends up in — the store ADDS it to the due time. Two sign rules for
|
||||
// one idea is the kind of difference that only shows up as an alarm going off
|
||||
// at the wrong moment.
|
||||
//
|
||||
// Only tasks with a clock. A task due "tomorrow" with no time has no moment to
|
||||
// ring at, and DueReminders selects the offset form only when due_datetime is
|
||||
// set, so an alarm there would be a row that never fires.
|
||||
DefaultReminderMin *int
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -136,7 +151,8 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
|
||||
}
|
||||
|
||||
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
|
||||
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order, created_at, updated_at`
|
||||
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order,
|
||||
default_reminder_min, created_at, updated_at`
|
||||
|
||||
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
|
||||
var u User
|
||||
@@ -145,10 +161,12 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
||||
var created, updated int64
|
||||
|
||||
var collapsed, navOrder string
|
||||
var defaultReminder sql.NullInt64
|
||||
|
||||
err := db.QueryRowContext(ctx, query, args...).Scan(
|
||||
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
|
||||
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder, &created, &updated)
|
||||
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder,
|
||||
&defaultReminder, &created, &updated)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -172,6 +190,10 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
||||
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
|
||||
u.NavOrder = []string{}
|
||||
_ = json.Unmarshal([]byte(navOrder), &u.NavOrder)
|
||||
if defaultReminder.Valid {
|
||||
v := int(defaultReminder.Int64)
|
||||
u.DefaultReminderMin = &v
|
||||
}
|
||||
u.CreatedAt = time.Unix(created, 0).UTC()
|
||||
u.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
return &u, nil
|
||||
@@ -691,3 +713,22 @@ func (db *DB) UserByICSToken(ctx context.Context, token string) (*User, error) {
|
||||
}
|
||||
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE ics_token = ?`, token)
|
||||
}
|
||||
|
||||
// SetDefaultReminder records whether tasks with a clock get an alarm unasked, and
|
||||
// how far ahead of the due moment it rings. Nil turns it off.
|
||||
func (db *DB) SetDefaultReminder(ctx context.Context, userID string, minutes *int) error {
|
||||
var value any
|
||||
if minutes != nil {
|
||||
value = *minutes
|
||||
}
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET default_reminder_min = ?, updated_at = ? WHERE id = ?`,
|
||||
value, time.Now().Unix(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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:');
|
||||
});
|
||||
@@ -86,6 +86,31 @@ async function nyNote(side) {
|
||||
return felt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Som `nyNote`, men UDEN klikket i feltet.
|
||||
*
|
||||
* Klikket er netop det, markør-prøverne ikke må gøre: de måler, at markøren
|
||||
* står i overskriften, uden at nogen har sat den. Ventetiden skal de til
|
||||
* gengæld have, og manglen på den kostede to røde CI-kørsler.
|
||||
*
|
||||
* `click()` på "Ny note" vender tilbage, så snart klikket er sendt, mens noten
|
||||
* først skal oprettes på serveren og tegnes bagefter. Uden ventetiden går det,
|
||||
* der tastes, ind i den FORRIGE note eller ingen steder — og så er der heller
|
||||
* ingen gemning at vente på, så en `waitForResponse` løber tørt i stedet. Det
|
||||
* er to forskellige påstande, der fejler, med én årsag. Se kommentaren over
|
||||
* `nyNote`: faren var skrevet ned, med sin løsning, før prøverne blev skrevet.
|
||||
*
|
||||
* Overskriften er værnet frem for bare det tomme felt: <h1> skrives af samme
|
||||
* effekt, der sætter markøren, så når den er der, er markøren sat.
|
||||
*/
|
||||
async function nyTomNote(side) {
|
||||
await side.getByRole('button', { name: 'Ny note' }).click();
|
||||
const felt = side.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(felt).toHaveText('');
|
||||
await expect(side.locator('.editor h1')).toHaveCount(1);
|
||||
return felt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kommendes liste kan se længere frem end en uge.
|
||||
*
|
||||
@@ -5415,6 +5440,84 @@ test('et indsat billede kan åbnes stort og kopieres', async ({ page, context })
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
/**
|
||||
* Et billede bliver større af at blive åbnet — ikke mindre.
|
||||
*
|
||||
* Rapporteret som "billederne er meget små, når man klikker på dem". Ruden havde
|
||||
* kun `max-width` og `max-height`, så den kunne gøre et billede mindre og aldrig
|
||||
* større. Målt i den rigtige app: et lille billede (600×400) stod 600 pixels bredt
|
||||
* i noten og 600 i ruden, og et højt skærmbillede fra en telefon stod 701 bredt i
|
||||
* noten og 357 i ruden. At klikke for at se billedet bedre gav et dårligere billede.
|
||||
*
|
||||
* Målt på kasserne, med tal: "billedet er synligt" var sandt hele tiden.
|
||||
*/
|
||||
test('et åbnet billede fylder skærmen og kan ses i fuld størrelse', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const body = page.getByLabel('Notens tekst');
|
||||
await body.fill('Billednote med mål');
|
||||
|
||||
const paste = (w, h) =>
|
||||
body.evaluate(async (el, [w, h]) => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
const g = c.getContext('2d');
|
||||
g.fillStyle = '#c33';
|
||||
g.fillRect(0, 0, w, h);
|
||||
const blob = await new Promise((r) => c.toBlob(r, 'image/png'));
|
||||
const data = new DataTransfer();
|
||||
data.items.add(new File([blob], 'skud.png', { type: 'image/png' }));
|
||||
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
|
||||
}, [w, h]);
|
||||
|
||||
const viewport = page.viewportSize();
|
||||
const shot = page.getByRole('dialog', { name: 'Billede' });
|
||||
const big = shot.locator('img');
|
||||
|
||||
// Et lille billede: ruden forstørrer det, til det fylder skærmen i den ene led.
|
||||
await paste(600, 400);
|
||||
const small = page.locator('[aria-label="Notens tekst"] img').last();
|
||||
await expect(small).toHaveJSProperty('complete', true);
|
||||
const inNote = await small.boundingBox();
|
||||
await small.click();
|
||||
await expect(big).toHaveClass(/sized/);
|
||||
const fit = await big.boundingBox();
|
||||
expect(fit.width).toBeGreaterThan(inNote.width * 1.5);
|
||||
expect(fit.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(fit.y + fit.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
// Fuld størrelse er aldrig mindre end det, man kom fra.
|
||||
await shot.getByRole('button', { name: 'Fuld størrelse' }).click();
|
||||
const full = await big.boundingBox();
|
||||
expect(full.width).toBeGreaterThanOrEqual(fit.width);
|
||||
await shot.getByRole('button', { name: 'Tilpas skærmen' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(shot).toHaveCount(0);
|
||||
|
||||
// Et højt skærmbillede fra en telefon: i fuld størrelse er det mindst lige så
|
||||
// bredt som i noten, og ruden ruller, så hele billedet kan nås.
|
||||
await paste(1170, 2532);
|
||||
const tall = page.locator('[aria-label="Notens tekst"] img').last();
|
||||
await expect(tall).toHaveJSProperty('complete', true);
|
||||
const tallInNote = await tall.boundingBox();
|
||||
await tall.click();
|
||||
await expect(big).toHaveClass(/sized/);
|
||||
await big.click();
|
||||
const tallFull = await big.boundingBox();
|
||||
expect(tallFull.width).toBeGreaterThanOrEqual(tallInNote.width);
|
||||
expect(tallFull.width).toBeGreaterThanOrEqual(1170);
|
||||
const scroll = await shot.evaluate((d) => d.scrollHeight - d.clientHeight);
|
||||
expect(scroll).toBeGreaterThan(1000);
|
||||
// Et klik på billedet igen bringer det tilbage til skærmens mål.
|
||||
await big.click();
|
||||
const back = await big.boundingBox();
|
||||
expect(back.y + back.height).toBeLessThanOrEqual(viewport.height);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En opgaves tekst kan læses færdig, også på en telefon.
|
||||
*
|
||||
@@ -5699,3 +5802,290 @@ test('the verdande door is unaffected by the urd one', async ({ page }) => {
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En ny note åbner med markøren i sin titel.
|
||||
*
|
||||
* Titlen er første linje, som i Apple Notes — der er ikke et selvstændigt felt,
|
||||
* så "sæt markøren i overskriften" betyder et område placeret inde i <h1> i et
|
||||
* contenteditable. Derfor måles der på markeringen og ikke på hvad der har fokus:
|
||||
* editoren kan have fokus med markøren stående et helt andet sted.
|
||||
*/
|
||||
async function caretBlock(page) {
|
||||
return page.evaluate(() => {
|
||||
const s = window.getSelection();
|
||||
if (!s || !s.anchorNode) return null;
|
||||
const node = s.anchorNode.nodeType === 1 ? s.anchorNode : s.anchorNode.parentElement;
|
||||
const block = node?.closest('h1, h2, h3, p, li, pre');
|
||||
return block ? block.tagName.toLowerCase() : null;
|
||||
});
|
||||
}
|
||||
|
||||
test('en ny note åbner med markøren i overskriften', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
await nyTomNote(page);
|
||||
|
||||
// `expect.poll` og ikke én læsning: `click()` vender tilbage, så snart klikket er
|
||||
// sendt, mens markøren sættes efter en netværksrundtur OG en gentegning. Lokalt
|
||||
// er rundturen under et millisekund, så ét snapshot ramte altid — på en runner
|
||||
// ramte det `null`, og prøven bestod på main ved held frem for ved regel.
|
||||
//
|
||||
// Kontrollen nedenfor (`stjæler ikke markøren`) må IKKE laves om til en poll:
|
||||
// den forventer null, så en poll ville bestå på første læsning, uanset hvad
|
||||
// koden gjorde. Den ankrer sig i stedet på overskriftens tekst først.
|
||||
await expect
|
||||
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1' })
|
||||
.toBe('h1');
|
||||
|
||||
// Og den står et sted, man kan skrive: det, der tastes, bliver titlen, og
|
||||
// titlen er det, listen kalder noten.
|
||||
await page.keyboard.type('Pakkeliste til Island');
|
||||
await expect(page.locator('.editor h1').first()).toHaveText('Pakkeliste til Island');
|
||||
await expect(page.locator('.notes button.row strong').first()).toHaveText(
|
||||
/^Pakkeliste til Island$/
|
||||
);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en note med indhold stjæler ikke markøren', async ({ page }) => {
|
||||
// Kontrollen, og den halvdel der gør det til en regel frem for en vane: åbner
|
||||
// man en note, man har skrevet i forvejen, skal markøren IKKE hoppe op i
|
||||
// overskriften og skubbe det, man var i gang med.
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
await nyTomNote(page);
|
||||
|
||||
// Lyt FØR der skrives. Her stod `waitForTimeout(1200)`, og kommentaren over
|
||||
// `gemt` i toppen af filen nævner præcis det tal: et fast ophold er et gæt om,
|
||||
// hvor hurtig maskinen er, og gættet holder indtil CI er travl. Det holdt indtil
|
||||
// CI var travl — gemningen var ikke nået, genindlæsningen smed det skrevne væk,
|
||||
// og prøven faldt på at noten ikke fandtes frem for på markøren, som er det den
|
||||
// måler. Svaret på PATCH'en er det eneste, der beviser, at serveren har hørt om
|
||||
// det.
|
||||
const gemt = noteGemt(page);
|
||||
await page.keyboard.type('Allerede skrevet');
|
||||
await gemt;
|
||||
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Allerede skrevet/ }).first().click();
|
||||
await expect(page.locator('.editor h1').first()).toHaveText('Allerede skrevet');
|
||||
expect(await caretBlock(page), 'ingen markør skal være sat').toBe(null);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('urd-døren opfører sig ens: ny note, markør i overskriften', async ({ page }) => {
|
||||
// Han bad om "Urd og noter". Det er samme side, men målt gennem døren, fordi
|
||||
// det er den vej en bruger af urd-appen kommer — og fordi en regel, der kun er
|
||||
// prøvet ad én vej, er prøvet ét sted.
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/urd');
|
||||
await expect(page).toHaveURL(/\/noter$/);
|
||||
await nyTomNote(page);
|
||||
|
||||
await expect
|
||||
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1 også i urd' })
|
||||
.toBe('h1');
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en note oprettet fra et projekt åbner også med markøren i overskriften', async ({ page }) => {
|
||||
// Den anden vej ind, og den blev påstået dækket før den blev målt: projektets
|
||||
// "Ny note" opretter noten og sender videre med ?note=<id>, så markøren
|
||||
// placeres af editoren på den anden side af en navigation frem for i samme
|
||||
// hug. Reglen sidder i editoren netop for at dække begge — det er værdiløst
|
||||
// uden en prøve, der går den anden vej.
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByLabel('Nyt projekt').click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Islandsturen');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await sidebar.getByRole('link', { name: 'Islandsturen' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Islandsturen' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/noter\?note=/);
|
||||
|
||||
await expect
|
||||
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1 efter navigationen' })
|
||||
.toBe('h1');
|
||||
await page.keyboard.type('Dæk og kort');
|
||||
await expect(page.locator('.editor h1').first()).toHaveText('Dæk og kort');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Tal en opgave ind.
|
||||
*
|
||||
* The button does not do speech recognition and is not meant to: it puts the
|
||||
* quick-add field in front of the thumb so the keyboard's own dictation can be
|
||||
* used. So what there is to test is the field — that it is reachable, that it ends
|
||||
* up with the caret in it, and that what lands there becomes a task.
|
||||
*
|
||||
* What a browser cannot be asked: whether iOS opened its keyboard. That happens
|
||||
* only when focus() is called inside the user gesture, and no test can see the
|
||||
* difference. What a test CAN see is the precondition — that the field is already
|
||||
* in the document before the button is pressed — and that is asserted below,
|
||||
* because it is exactly what a well-meaning `{#if open}` would take away.
|
||||
*/
|
||||
const PHONE = { width: 390, height: 844 };
|
||||
|
||||
test('den runde knap sætter markøren i feltet, så tastaturet kan diktere', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.setViewportSize(PHONE);
|
||||
await page.goto('/');
|
||||
|
||||
const knap = page.getByRole('button', { name: 'Tal en opgave ind' });
|
||||
await expect(knap).toBeVisible();
|
||||
|
||||
// Feltet findes FØR klikket. Det er forudsætningen for at fokus kan sættes
|
||||
// synkront, og den eneste del af iOS-kravet en prøve kan se.
|
||||
//
|
||||
// `toHaveCount` og ikke `count()`: den første venter, den anden er et
|
||||
// øjebliksbillede taget med det samme — og en SPA har ikke tegnet endnu, når
|
||||
// `goto` vender tilbage, så den ville tælle nul uanset hvad koden gjorde.
|
||||
await expect(page.locator('.sheet input[type="text"]')).toHaveCount(1);
|
||||
|
||||
await knap.click();
|
||||
|
||||
// Markøren er i skuffens felt — ikke i sidens eget.
|
||||
const fokusIskuffen = await page.evaluate(
|
||||
() => !!document.activeElement?.closest('.sheet')
|
||||
);
|
||||
expect(fokusIskuffen, 'markøren skal stå i skuffens felt efter trykket').toBe(true);
|
||||
|
||||
// Og det, der dikteres, bliver læst af den samme parser som alt andet.
|
||||
const felt = page.locator('.sheet input[type="text"]');
|
||||
await felt.fill('ring til tandlægen i dag p1');
|
||||
await felt.press('Enter');
|
||||
|
||||
// "i dag", af samme grund som de andre prøver her siger det: dette er
|
||||
// I dag-visningen, og en opgave uden dato eller med "i morgen" er korrekt
|
||||
// fraværende fra den — hvilket ville ligne, at tilføjelsen var fejlet.
|
||||
await expect(page.getByText('ring til tandlægen').first()).toBeVisible();
|
||||
// Og skuffen lukker sig selv, så man ikke står med tastaturet oppe bagefter.
|
||||
await expect(page.locator('.sheet.open')).toHaveCount(0);
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En talt opgave kan sætte sin egen alarm.
|
||||
*
|
||||
* Det var omvejen: en dikteret "i morgen kl 9" fik en tid og INGEN alarm, for
|
||||
* ingenting skaber en påmindelse af sig selv — `CreateReminder` blev kun kaldt fra
|
||||
* sit eget endepunkt. Så skulle man bagefter åbne opgaven, finde Påmindelser og
|
||||
* vælge et klokkeslæt, og den omvej er længere end at skrive opgaven.
|
||||
*
|
||||
* Nu siger sætningen det selv, og det gælder uanset hvor stemmen kommer ind:
|
||||
* tastaturets diktering, en genvej eller noget tredje ender som tekst gennem den
|
||||
* samme parser.
|
||||
*
|
||||
* Prøven går hele vejen til skærmbilledet frem for at stoppe ved svaret, fordi
|
||||
* forskydningen er negativ i lageret — det lægger den til forfaldstiden — og
|
||||
* "-60 min." på skærmen er et fortegn, læseren selv skal oversætte.
|
||||
*/
|
||||
test('en talt opgave kan sætte sin egen alarm', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.setViewportSize(PHONE);
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByRole('button', { name: 'Tal en opgave ind' }).click();
|
||||
const felt = page.locator('.sheet input[type="text"]');
|
||||
|
||||
// Set, før den sendes: alarmen lyser op som et forstået felt, ved siden af
|
||||
// datoen. Det er hele pointen med fremvisningen — en parser, man ikke kan se
|
||||
// arbejde, holder man op med at stole på, første gang den tager fejl.
|
||||
await felt.fill('booke tandrens hos Lisa i morgen kl 9 mind mig en time før');
|
||||
await expect(page.locator(".sheet mark[data-kind='reminder']")).toHaveText('mind mig en time før');
|
||||
|
||||
await felt.press('Enter');
|
||||
await expect(page.locator('.sheet.open')).toHaveCount(0);
|
||||
|
||||
// Opgaven er i morgen, så den står ikke på I dag — se de andre prøver her.
|
||||
// Et navn ingen anden prøve bruger, og det er ikke pynt: suiten kører med én
|
||||
// arbejder mod ÉN database, så "ring til tandlægen" findes fire steder i denne
|
||||
// fil, når denne prøve kommer til. `.first()` ramte en af de andres opgave, som
|
||||
// ikke har nogen alarm — og kørt alene bestod prøven, fordi der kun var én.
|
||||
await page.goto('/upcoming');
|
||||
await page.getByText('booke tandrens hos Lisa').click();
|
||||
|
||||
// Og alarmen står der, skrevet som ord og ikke som et fortegn.
|
||||
await expect(page.getByText('1 time før')).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* En alarm, der ikke kan ringe, bliver sagt højt.
|
||||
*
|
||||
* "mind mig en time før" på en opgave med en dag men ingen klokketid har intet at
|
||||
* være en time før: `DueReminders` vælger forskydningsformen kun
|
||||
* `AND t.due_datetime IS NOT NULL`, så rækken ville stå i tabellen og aldrig fyre.
|
||||
*
|
||||
* Det er den ene slags påmindelse, hvis fejl intet bagefter afslører — der står
|
||||
* ingen række at savne, og man opdager den først, da den ikke ringede. Derfor
|
||||
* bliver opgaven oprettet og beskeden sagt, i stedet for at alarmen tabes i
|
||||
* stilhed.
|
||||
*/
|
||||
test('en alarm uden klokkeslæt at regne tilbage fra bliver sagt højt', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.setViewportSize(PHONE);
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByRole('button', { name: 'Tal en opgave ind' }).click();
|
||||
const felt = page.locator('.sheet input[type="text"]');
|
||||
await felt.fill('hente pakken på posthuset i morgen mind mig en time før');
|
||||
await felt.press('Enter');
|
||||
|
||||
await expect(page.getByText(/Alarmen er ikke/)).toBeVisible();
|
||||
|
||||
// Opgaven er lavet alligevel. Et forkert ord må ikke koste tanken.
|
||||
await page.goto('/upcoming');
|
||||
await expect(page.getByText('hente pakken på posthuset')).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('skuffens felt stjæler ikke skriv-hvor-som-helst-genvejen', async ({ page }) => {
|
||||
// Genvejen finder feltet med querySelector('[data-quickadd]'), som returnerer
|
||||
// FØRSTE match i dokumentet. Skuffens felt må derfor ikke bære mærket — ellers
|
||||
// begynder et tastetryk at skrive i en kasse, ingen kan se.
|
||||
await page.setViewportSize(PHONE);
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
await expect(page.locator('[data-quickadd]'), 'kun ét mærket felt').toHaveCount(1);
|
||||
const mærketErIskuffen = await page.evaluate(
|
||||
() => !!document.querySelector('[data-quickadd]')?.closest('.sheet')
|
||||
);
|
||||
expect(mærketErIskuffen, 'mærket skal sidde på sidens felt, ikke skuffens').toBe(false);
|
||||
|
||||
await page.keyboard.press('k');
|
||||
await expect(page.locator('[data-quickadd]')).toHaveValue('k');
|
||||
});
|
||||
|
||||
test('knappen findes ikke på en skrivebordsskærm', async ({ page }) => {
|
||||
// Der er feltet fremme hele tiden og et tastatur at skrive på. En rund knap i
|
||||
// bunden er til tommelfingeren.
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeHidden();
|
||||
});
|
||||
|
||||
test('knappen findes ikke i urd-ansigtet', async ({ page }) => {
|
||||
// Der er ingen opgaver at lægge noget i. Målt gennem døren frem for ved at
|
||||
// skrue på en variabel, så det er den vej en bruger faktisk kommer.
|
||||
await page.setViewportSize(PHONE);
|
||||
await page.goto('/urd');
|
||||
await expect(page.getByRole('navigation', { name: /Hovedmenu|Main menu/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toHaveCount(0);
|
||||
|
||||
// Kontrollen: samme telefon, den anden dør, og så er den der.
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+36
-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,15 +235,39 @@ 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
|
||||
// a save button, and this writes on every click of a chevron.
|
||||
setNavOrder: (order) => put('/auth/nav-order', { order }),
|
||||
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
|
||||
// null slu00e5r den fra. Minutter i forhold til forfaldstiden, negativt for fu00f8r —
|
||||
// samme fortegn som reminders.offset_min, fordi det er den kolonne, den ender i.
|
||||
setDefaultReminder: (minutes) => put('/auth/default-reminder', { minutes }),
|
||||
signup: (data) => post('/auth/signup', data),
|
||||
forgotPassword: (email) => post('/auth/password/forgot', { email }),
|
||||
resetPassword: (token, password) => post('/auth/password/reset', { token, password }),
|
||||
@@ -418,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'),
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<script>
|
||||
/**
|
||||
* Tal en opgave ind.
|
||||
*
|
||||
* En stor rund knap i bunden af skærmen, inden for tommelfingerens rækkevidde.
|
||||
* Den åbner en skuffe med quick-add-feltet og sætter markøren i det, så
|
||||
* tastaturet kommer frem — og så trykker man mikrofonen på tastaturet.
|
||||
*
|
||||
* VI LAVER INGEN TALEGENKENDELSE, og det er et valg frem for en mangel. iOS'
|
||||
* egen diktering kører på enheden på nyere telefoner; en talegenkendelse bygget
|
||||
* ind her ville sende lyden fra et selvhostet program videre til Apple eller
|
||||
* Google, og det er en datavej, der skal forklares på privatlivssiden for at
|
||||
* spare ét tryk. Teksten lander i det felt, der i forvejen viser, hvad parseren
|
||||
* forstod — så "ring til tandlægen i morgen kl 10 p1" bliver læst, mens man ser
|
||||
* på det, og intet oprettes før man siger ja.
|
||||
*
|
||||
* Derfor hedder komponenten diktat og ikke tale: den gør ikke selv noget ved
|
||||
* lyden, den stiller feltet frem.
|
||||
*/
|
||||
import QuickAdd from './QuickAdd.svelte';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
let open = $state(false);
|
||||
let box;
|
||||
let sheet;
|
||||
|
||||
/**
|
||||
* Åbner og sætter fokus I SAMME HUG som klikket.
|
||||
*
|
||||
* Rækkefølgen her er hele funktionen. Safari på iOS åbner kun tastaturet, hvis
|
||||
* `focus()` kaldes inde i den brugerhandling, der bad om det — og Sveltes
|
||||
* opdateringer er asynkrone, så feltet kan ikke være betinget tegnet og få
|
||||
* fokus i samme omgang. Derfor er skuffen ALTID monteret og bare skubbet ud af
|
||||
* syne med `transform` og `opacity`: elementet findes, så det kan fokuseres nu
|
||||
* frem for efter næste opdatering.
|
||||
*
|
||||
* `display: none` og `visibility: hidden` ville begge gøre feltet ufokuserbart,
|
||||
* og `inert` ligeså. Det er grunden til, at den lukkede tilstand er gennemsigtig
|
||||
* og uden pointer-events frem for skjult.
|
||||
*/
|
||||
function start() {
|
||||
// `inert` fjernes med hånden her, og det er ikke sjusk. Attributten sættes
|
||||
// reaktivt af `inert={!open}` nedenfor, så den er der stadig i det øjeblik,
|
||||
// klikket kører — Sveltes opdateringer er asynkrone. Og et inert element kan
|
||||
// ikke fokuseres, så `focusField()` ville ramme ingenting, og iOS ville ikke
|
||||
// åbne tastaturet.
|
||||
//
|
||||
// Den findes, fordi alternativet er værre: uden den kan skuffens knapper
|
||||
// tabbes til, mens den er usynlig, på hver eneste side.
|
||||
sheet?.removeAttribute('inert');
|
||||
open = true;
|
||||
box?.focusField();
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
// Tastaturet bliver stående, hvis feltet beholder fokus.
|
||||
if (sheet?.contains(document.activeElement)) document.activeElement.blur();
|
||||
}
|
||||
|
||||
function onkeydown(event) {
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
<!-- Knappen tegnes ikke i urd-ansigtet: der er ingen opgaver at lægge noget i.
|
||||
Og den er skjult over 820px i CSS frem for her, fordi en skærm, der skifter
|
||||
bredde, ikke skal kunne tabe et felt, der har fokus. -->
|
||||
{#if !app.showsUrd}
|
||||
<button class="fab" class:away={open} onclick={start} aria-label={t('task.speak')}>
|
||||
<!-- En mikrofon, tegnet i samme streger som mærkerne: kapslen, bøjlen under
|
||||
den og foden. Ingen tekst i knappen — den er rund og tommelfingerstor,
|
||||
og navnet står i aria-label. -->
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="11" rx="3" />
|
||||
<path d="M5.5 11a6.5 6.5 0 0 0 13 0" />
|
||||
<path d="M12 17.5V21" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="scrim"
|
||||
class:open
|
||||
aria-hidden="true"
|
||||
onclick={close}
|
||||
role="presentation"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="sheet"
|
||||
class:open
|
||||
bind:this={sheet}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label={t('task.speak')}
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
>
|
||||
<div class="grab" aria-hidden="true"></div>
|
||||
<p class="hint">{t('task.speakHint')}</p>
|
||||
<!-- `marked={false}`: den her må ikke være den, type-anywhere-genvejen finder.
|
||||
Se kommentaren ved `marked` i QuickAdd. -->
|
||||
<QuickAdd bind:this={box} marked={false} label={t('task.speak')} onadded={close} />
|
||||
<button class="done" onclick={close}>{t('detail.close')}</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: max(var(--s4), env(safe-area-inset-right));
|
||||
/* Over hjemmeindikatoren på en iPhone, ikke under den. */
|
||||
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
|
||||
z-index: 40;
|
||||
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--fast) var(--ease-out),
|
||||
opacity var(--fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.fab svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* Væk, mens skuffen er åben: den ville ellers stå oven på feltet. */
|
||||
.fab.away {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 44;
|
||||
background: rgb(0 0 0 / 0.4);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--medium) var(--ease-out);
|
||||
}
|
||||
|
||||
.scrim.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: fixed;
|
||||
inset: auto 0 0 0;
|
||||
z-index: 45;
|
||||
padding: var(--s3) max(var(--s3), env(safe-area-inset-left))
|
||||
calc(var(--s3) + env(safe-area-inset-bottom)) max(var(--s3), env(safe-area-inset-right));
|
||||
background: var(--surface-raised);
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: grid;
|
||||
gap: var(--s2);
|
||||
|
||||
/* Lukket: gennemsigtig og skubbet ned, IKKE display:none — feltet skal kunne
|
||||
fokuseres i samme hug som klikket. Se `start()`. */
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
transform var(--medium) var(--ease-out),
|
||||
opacity var(--medium) var(--ease-out);
|
||||
}
|
||||
|
||||
.sheet.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.grab {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--line-strong);
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.done {
|
||||
justify-self: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ink-muted);
|
||||
padding: var(--s2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* En skrivebordsskærm har feltet fremme hele tiden og et tastatur at skrive
|
||||
på. Knappen er til tommelfingeren. */
|
||||
@media (min-width: 821px) {
|
||||
.fab,
|
||||
.scrim,
|
||||
.sheet {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fab,
|
||||
.scrim,
|
||||
.sheet {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@
|
||||
* unforgivable. When the replacement (the EditContext API) is everywhere, this
|
||||
* is the file to change.
|
||||
*/
|
||||
import { tick } from 'svelte';
|
||||
import { markdownToHtml, htmlToMarkdown, blockText } from '$lib/richtext.js';
|
||||
import { highlight, guessLanguage } from '$lib/highlight.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
@@ -81,12 +82,55 @@
|
||||
// fired.
|
||||
editor.innerHTML = body.trim() ? markdownToHtml(body) : '<h1><br></h1>';
|
||||
colourCode();
|
||||
// Og markøren sættes DER.
|
||||
//
|
||||
// Indtil nu lavede linjen ovenfor kun titelblokken, så sætningen i
|
||||
// kommentaren — "opens on its title" — handlede om, hvad der blev tegnet,
|
||||
// ikke om hvor man kunne skrive. Noter-siden prøvede at råde bod på det
|
||||
// med `document.querySelector('.editor textarea')?.focus()`, og der er
|
||||
// ingen textarea: editoren er et contenteditable. Det kald har ramt
|
||||
// ingenting, stille, siden editoren blev lavet om, og `?.` sørgede for,
|
||||
// at det aldrig kastede.
|
||||
//
|
||||
// Her frem for på siden, så begge veje ind er dækket: noter-sidens egen
|
||||
// opret-knap, og projektsidens, der sender videre med ?note=<id>.
|
||||
if (!body.trim()) placeCaretInTitle();
|
||||
} catch (e) {
|
||||
editor.textContent = body;
|
||||
console.error('verdande: noten kunne ikke tegnes', note.id, e);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Sætter markøren i titlen på en tom note.
|
||||
*
|
||||
* En contenteditable har ikke et felt at fokusere — den har en markør, og den
|
||||
* skal placeres. `focus()` alene giver elementet tastaturfokus og lader
|
||||
* markøren stå, hvor browseren synes, hvilket for et tomt element er starten
|
||||
* på dokumentet frem for inde i overskriften.
|
||||
*
|
||||
* Fokus FØRST og så området: at fokusere et contenteditable kan nulstille
|
||||
* markeringen, så den omvendte rækkefølge sætter et område, der straks bliver
|
||||
* kasseret igen.
|
||||
*
|
||||
* Hvad den IKKE kan, sagt her frem for opdaget på en telefon: åbne tastaturet
|
||||
* på iOS. Det sker kun, når focus() kaldes inde i den brugerhandling, der bad
|
||||
* om det — og en ny note skal først oprettes på serveren, så der ligger et
|
||||
* `await` imellem. Markøren står rigtigt; på en telefon skal man stadig trykke
|
||||
* én gang for at få tastaturet frem.
|
||||
*/
|
||||
function placeCaretInTitle() {
|
||||
const heading = editor?.querySelector('h1');
|
||||
if (!heading) return;
|
||||
editor.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(heading);
|
||||
range.collapse(true);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
const SYNTAX = [
|
||||
{ mark: '#', what: 'notes.syntaxProject' },
|
||||
{ mark: '[[', what: 'notes.syntaxNote' },
|
||||
@@ -713,6 +757,8 @@
|
||||
if (image) {
|
||||
event.preventDefault();
|
||||
showing = { src: image.currentSrc || image.src, alt: image.alt ?? '' };
|
||||
zoomed = false;
|
||||
natural = null;
|
||||
copied = false;
|
||||
copyError = '';
|
||||
return;
|
||||
@@ -726,6 +772,49 @@
|
||||
let copied = $state(false);
|
||||
let copyError = $state('');
|
||||
|
||||
// Ruden viste billedet med `max-width`/`max-height` og intet andet, så det kunne
|
||||
// blive mindre, men aldrig større. Det var mindre end i noten, det kom fra: et
|
||||
// skærmbillede fra en telefon på en skærm i bredformat stod 701 pixels bredt i
|
||||
// arket og 357 i ruden, og et lille billede blev slet ikke forstørret. Nu fylder
|
||||
// det skærmen — også når det skal forstørres for at gøre det — og et klik på
|
||||
// billedet viser det i sin egen størrelse, til at rulle rundt i.
|
||||
let zoomed = $state(false);
|
||||
/** @type {{ w: number, h: number } | null} */
|
||||
let natural = $state(null);
|
||||
/** @type {HTMLDivElement | null} */
|
||||
let lightbox = $state(null);
|
||||
|
||||
/** @param {Event & { currentTarget: HTMLImageElement }} event */
|
||||
function measured(event) {
|
||||
const img = event.currentTarget;
|
||||
natural = { w: img.naturalWidth, h: img.naturalHeight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Skift mellem "hele skærmen" og "fuld størrelse".
|
||||
*
|
||||
* Fra et klik på billedet rulles der hen til det sted, man klikkede, så man
|
||||
* ikke lander i øverste venstre hjørne og skal lede efter det, man ville se.
|
||||
*
|
||||
* @param {MouseEvent} [event]
|
||||
*/
|
||||
async function toggleZoom(event) {
|
||||
const img = lightbox?.querySelector('.shot');
|
||||
let fx = 0.5;
|
||||
let fy = 0.5;
|
||||
if (event?.clientX !== undefined && event.detail > 0 && img) {
|
||||
const r = img.getBoundingClientRect();
|
||||
fx = (event.clientX - r.left) / r.width;
|
||||
fy = (event.clientY - r.top) / r.height;
|
||||
}
|
||||
zoomed = !zoomed;
|
||||
await tick();
|
||||
if (!zoomed || !img || !lightbox) return;
|
||||
const r = img.getBoundingClientRect();
|
||||
lightbox.scrollLeft += r.left + r.width * fx - lightbox.clientWidth / 2;
|
||||
lightbox.scrollTop += r.top + r.height * fy - lightbox.clientHeight / 2;
|
||||
}
|
||||
|
||||
function closeImage() {
|
||||
showing = null;
|
||||
// Tilbage i teksten, hvor man var. Uden det står fokus på en knap, der ikke
|
||||
@@ -1250,15 +1339,35 @@
|
||||
af netop det ark, det skal lægge sig over. -->
|
||||
{#if showing}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="lightbox" onclick={closeImage} role="dialog" aria-modal="true" aria-label={t('notes.imageTitle')}>
|
||||
<div
|
||||
class="lightbox"
|
||||
class:zoomed
|
||||
bind:this={lightbox}
|
||||
onclick={closeImage}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('notes.imageTitle')}
|
||||
style:--w={natural?.w}
|
||||
style:--h={natural?.h}
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<img
|
||||
class="shot"
|
||||
class:sized={natural}
|
||||
src={showing.src}
|
||||
alt={showing.alt}
|
||||
onload={measured}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleZoom(e);
|
||||
}}
|
||||
/>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="shot" onclick={(e) => e.stopPropagation()}>
|
||||
<img src={showing.src} alt={showing.alt} />
|
||||
<div class="tools">
|
||||
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
|
||||
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
|
||||
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
|
||||
</div>
|
||||
<div class="tools" onclick={(e) => e.stopPropagation()}>
|
||||
<button onclick={copyImage}>{copied ? t('notes.imageCopied') : t('notes.imageCopy')}</button>
|
||||
<button onclick={toggleZoom}>{zoomed ? t('notes.imageZoomOut') : t('notes.imageZoomIn')}</button>
|
||||
<a href={showing.src} target="_blank" rel="noopener">{t('notes.imageOpen')}</a>
|
||||
<button class="shut" onclick={closeImage}>{t('detail.close')}</button>
|
||||
{#if copyError}<p class="bad">{copyError}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1275,43 +1384,77 @@
|
||||
|
||||
<style>
|
||||
/* Over alt andet og midt på skærmen. Baggrunden er mørk nok til at billedet
|
||||
står alene — det er hele grunden til at åbne det. */
|
||||
står alene — det er hele grunden til at åbne det.
|
||||
|
||||
Centreret med `margin: auto` på billedet og ikke med `justify-content`: i
|
||||
fuld størrelse er billedet større end ruden, og et flex-element, der er
|
||||
centreret og for stort, stikker lige meget ud til begge sider — den venstre og
|
||||
den øverste del kan man så ikke rulle hen til. */
|
||||
.lightbox {
|
||||
--pad: var(--s4);
|
||||
--bar: 4rem;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgb(0 0 0 / 0.8);
|
||||
background: rgb(0 0 0 / 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--s4);
|
||||
padding: var(--pad) var(--pad) calc(var(--bar) + var(--pad));
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* På en telefon bryder de fire knapper over i to rækker. */
|
||||
@media (max-width: 600px) {
|
||||
.lightbox {
|
||||
--pad: var(--s2);
|
||||
--bar: 6.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.shot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* `min-height: 0` hører med: uden den nægter et flex-element at blive lavere
|
||||
end sit indhold, og et højt billede vokser ud over skærmen i stedet for at
|
||||
passe sig ind i den. */
|
||||
.shot img {
|
||||
min-height: 0;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 8rem);
|
||||
object-fit: contain;
|
||||
margin: auto;
|
||||
flex: none;
|
||||
max-width: calc(100vw - 2 * var(--pad));
|
||||
max-height: calc(100dvh - 2 * var(--pad) - var(--bar));
|
||||
border-radius: var(--radius);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* Så snart billedets egne mål kendes, fylder det skærmen: den største bredde,
|
||||
der både er inden for skærmens bredde og — ved billedets form — inden for dens
|
||||
højde. Det forstørrer et lille billede, hvad `max-width` alene aldrig gjorde. */
|
||||
.shot.sized {
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
width: min(
|
||||
calc(100vw - 2 * var(--pad)),
|
||||
calc((100dvh - 2 * var(--pad) - var(--bar)) * var(--w) / var(--h))
|
||||
);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Fuld størrelse: billedets egne pixels, og ruden ruller. Aldrig smallere end
|
||||
skærmen — et lille billede ville ellers blive MINDRE af at blive forstørret, og
|
||||
et højt skærmbillede fra en telefon skal kunne læses i hele skærmens bredde. */
|
||||
.zoomed .shot.sized {
|
||||
width: max(calc(var(--w) * 1px), calc(100vw - 2 * var(--pad)));
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
/* Knapperne ligger fast nederst, så de er der, uanset hvor man har rullet hen
|
||||
i et billede i fuld størrelse. */
|
||||
.tools {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: var(--s3);
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 2 * var(--s3));
|
||||
}
|
||||
|
||||
.tools button,
|
||||
@@ -1331,6 +1474,8 @@
|
||||
}
|
||||
|
||||
.bad {
|
||||
flex-basis: 100%;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--danger);
|
||||
|
||||
@@ -16,14 +16,53 @@
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { readList } from '$lib/pastelist.js';
|
||||
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
|
||||
let {
|
||||
projectId = undefined,
|
||||
onadded = undefined,
|
||||
autofocus = false,
|
||||
/**
|
||||
* Whether this box carries the `data-quickadd` marker.
|
||||
*
|
||||
* The layout's type-anywhere shortcut finds the field with
|
||||
* `document.querySelector('[data-quickadd]')`, which returns the FIRST match
|
||||
* in the document. So a second box on the page — the dictate sheet's — has
|
||||
* to stay unmarked, or the shortcut starts typing into a box nobody can see.
|
||||
*/
|
||||
marked = true,
|
||||
/**
|
||||
* The field's accessible name.
|
||||
*
|
||||
* A prop because the dictate sheet puts a second one of these on the page,
|
||||
* and two fields answering to "Ny opgave" is a screen reader reading the
|
||||
* same name twice with no way to tell which is which. They are different
|
||||
* affordances — one is the box on the page, the other is the thing the
|
||||
* round button opens — so they say different names.
|
||||
*/
|
||||
label = undefined
|
||||
} = $props();
|
||||
|
||||
let fieldLabel = $derived(label ?? t('task.new'));
|
||||
|
||||
let text = $state('');
|
||||
let spans = $state([]);
|
||||
let submitting = $state(false);
|
||||
let input;
|
||||
let mirror;
|
||||
let focused = $state(false);
|
||||
|
||||
/**
|
||||
* Keeps the tint under the right characters when the line is longer than the
|
||||
* field.
|
||||
*
|
||||
* The input scrolls horizontally and the mirror has to scroll with it. Done on
|
||||
* both `scroll` and `input` because typing at the end of a long line moves the
|
||||
* caret — and therefore the scroll — without ever firing a scroll event in some
|
||||
* browsers.
|
||||
*/
|
||||
function syncScroll() {
|
||||
if (mirror && input) mirror.scrollLeft = input.scrollLeft;
|
||||
}
|
||||
|
||||
// Kept beside the parser's own vocabulary on purpose: a legend that promises
|
||||
// something internal/quickadd cannot read is worse than no legend. These four
|
||||
// are what Parse recognises as marks; dates it reads as prose, which is why the
|
||||
@@ -72,6 +111,20 @@
|
||||
if (autofocus && input) input.focus();
|
||||
});
|
||||
|
||||
/**
|
||||
* Focus the field from outside, synchronously.
|
||||
*
|
||||
* This exists because of iOS and nothing else. Safari opens the on-screen
|
||||
* keyboard only when `focus()` is called INSIDE the user gesture that asked for
|
||||
* it — so the `autofocus` effect above, which runs after the click handler has
|
||||
* returned, gives the field a caret and no keyboard. For the dictate button that
|
||||
* is the whole feature failing quietly: the sheet opens, nothing to dictate
|
||||
* into, and there is no error anywhere.
|
||||
*/
|
||||
export function focusField() {
|
||||
input?.focus();
|
||||
}
|
||||
|
||||
/** Splits the raw text into highlighted and plain runs, by byte offset. */
|
||||
let segments = $derived.by(() => {
|
||||
if (!spans.length) return [{ text, kind: null }];
|
||||
@@ -197,7 +250,7 @@
|
||||
<!-- The mirror sits behind the input and must match it exactly: same font,
|
||||
same padding, same wrapping. Any difference shows up as highlighting
|
||||
that drifts out of alignment as the line grows. -->
|
||||
<div class="mirror" aria-hidden="true">
|
||||
<div class="mirror" aria-hidden="true" bind:this={mirror}>
|
||||
{#each segments as segment}
|
||||
{#if segment.kind}
|
||||
<mark data-kind={segment.kind}>{segment.text}</mark>
|
||||
@@ -211,12 +264,14 @@
|
||||
bind:this={input}
|
||||
bind:value={text}
|
||||
{onkeydown}
|
||||
onscroll={syncScroll}
|
||||
oninput={syncScroll}
|
||||
onfocus={() => (focused = true)}
|
||||
onblur={() => (focused = false)}
|
||||
type="text"
|
||||
data-quickadd
|
||||
data-quickadd={marked ? '' : undefined}
|
||||
placeholder={t('task.placeholder')}
|
||||
aria-label={t('task.new')}
|
||||
aria-label={fieldLabel}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
{onpaste}
|
||||
@@ -385,8 +440,16 @@
|
||||
letter-spacing: normal;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
/* `pre`, not `pre-wrap`, and that is the whole alignment.
|
||||
|
||||
An <input type="text"> is single-line by specification and cannot wrap
|
||||
whatever CSS says. So a wrapping mirror behind a non-wrapping input does
|
||||
not drift a little — it puts the tint on a second line that the input does
|
||||
not have, and the highlight ends up under empty space. Rare on a wide
|
||||
desktop field, constant in the dictate sheet on a 390px phone, where a
|
||||
spoken sentence overflows immediately. */
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.mirror {
|
||||
@@ -394,6 +457,10 @@
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
color: transparent;
|
||||
/* Scrolled in step with the input by hand — see `syncScroll`. The overflow
|
||||
has to be hidden rather than visible, or the long tail of a line paints
|
||||
out past the field's edge. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The tint is behind the glyphs, not on them: colouring the text itself would
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { app, projectName, clockOf } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { REPEATS } from '$lib/when.js';
|
||||
import { REPEATS, reminderOffsetLabel } from '$lib/when.js';
|
||||
import { readList } from '$lib/pastelist.js';
|
||||
|
||||
let { task, onclose } = $props();
|
||||
@@ -757,7 +757,9 @@
|
||||
|
||||
{#each reminders as reminder (reminder.id)}
|
||||
<div class="reminder">
|
||||
<span>{reminder.remind_at ? stamp(reminder.remind_at) : `${reminder.offset_min} min.`}</span>
|
||||
<span>{reminder.remind_at
|
||||
? stamp(reminder.remind_at)
|
||||
: reminderOffsetLabel(reminder.offset_min)}</span>
|
||||
{#if reminder.sent}<span class="sent">{t('detail.sent')}</span>{/if}
|
||||
<button class="remove" onclick={() => removeReminder(reminder)} aria-label={t('detail.delete')}>
|
||||
×
|
||||
|
||||
@@ -177,6 +177,8 @@ export const da = {
|
||||
'notes.imageCopy': 'Kopiér billedet',
|
||||
'notes.imageCopied': 'Kopieret',
|
||||
'notes.imageOpen': 'Åbn i nyt faneblad',
|
||||
'notes.imageZoomIn': 'Fuld størrelse',
|
||||
'notes.imageZoomOut': 'Tilpas skærmen',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.code': 'Kode',
|
||||
'notes.showSource': 'Vis kilden (Markdown)',
|
||||
@@ -267,6 +269,13 @@ export const da = {
|
||||
'task.pastedAdd': 'Opret {n} opgaver',
|
||||
'task.pastedCancel': 'Fortryd',
|
||||
'task.new': 'Ny opgave',
|
||||
// Diktat, ikke tale: programmet laver ingen talegenkendelse, det stiller
|
||||
// feltet frem, så tastaturets egen mikrofon kan bruges. Hjælpelinjen siger
|
||||
// det, fordi en knap med en mikrofon på lover noget andet.
|
||||
'task.alarmNeedsATime':
|
||||
'Opgaven er oprettet. Alarmen er ikke — "en time før" skal have et klokkeslæt at regne tilbage fra.',
|
||||
'task.speak': 'Tal en opgave ind',
|
||||
'task.speakHint': 'Tryk mikrofonen på tastaturet, og sig hvad der skal gøres.',
|
||||
'task.syntaxProject': 'projekt',
|
||||
'task.syntaxSection': 'sektion',
|
||||
'task.syntaxLabel': 'etiket',
|
||||
@@ -398,6 +407,18 @@ export const da = {
|
||||
'account.recoveryLeftOne': '{n} gendannelseskode tilbage.',
|
||||
'account.recoveryLeftMany': '{n} gendannelseskoder tilbage.',
|
||||
|
||||
// --- settings: alarm as standard -----------------------------------------------
|
||||
'alarm.title': 'Alarm som standard',
|
||||
'alarm.hint':
|
||||
'Opgaver med bu00e5de en dato og et klokkeslu00e6t fu00e5r en alarm, uden at du beder om den. En opgave uden klokkeslu00e6t fu00e5r ingen — der er intet tidspunkt at ringe pu00e5. Skriv "ingen alarm" i linjen for at springe en enkelt over, eller "mind mig en time fu00f8r" for at give den sin egen.',
|
||||
'alarm.on': 'Giv opgaver med et klokkeslu00e6t en alarm',
|
||||
'alarm.when': 'Hvornu00e5r',
|
||||
'alarm.atDue': 'Pu00e5 tidspunktet',
|
||||
'alarm.minBefore': '{n} min. fu00f8r',
|
||||
'alarm.hourBefore': '1 time fu00f8r',
|
||||
'alarm.hoursBefore': '{n} timer fu00f8r',
|
||||
'alarm.dayBefore': 'Dagen fu00f8r',
|
||||
|
||||
// --- settings: notifications ---------------------------------------------------
|
||||
'push.title': 'Notifikationer på enheden',
|
||||
'push.hint':
|
||||
@@ -715,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?',
|
||||
@@ -757,6 +787,14 @@ export const da = {
|
||||
'detail.newSubtask': 'Ny undertask',
|
||||
'detail.files': 'Filer',
|
||||
'detail.delete': 'Slet',
|
||||
'detail.remindAtDue': 'På tidspunktet',
|
||||
'detail.remindBefore': '{when} før',
|
||||
'detail.remindAfter': '{when} efter',
|
||||
'when.minuteMany': '{n} min.',
|
||||
'when.hourOne': '1 time',
|
||||
'when.hourMany': '{n} timer',
|
||||
'when.dayOne': '1 dag',
|
||||
'when.dayMany': '{n} dage',
|
||||
'detail.reminders': 'Påmindelser',
|
||||
'detail.newReminder': 'Ny påmindelse',
|
||||
'detail.sent': 'sendt',
|
||||
|
||||
@@ -174,6 +174,8 @@ export const en = {
|
||||
'notes.imageCopy': 'Copy the image',
|
||||
'notes.imageCopied': 'Copied',
|
||||
'notes.imageOpen': 'Open in a new tab',
|
||||
'notes.imageZoomIn': 'Full size',
|
||||
'notes.imageZoomOut': 'Fit the screen',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.code': 'Code',
|
||||
'notes.showSource': 'Show the source (Markdown)',
|
||||
@@ -264,6 +266,10 @@ export const en = {
|
||||
'task.pastedAdd': 'Add {n} tasks',
|
||||
'task.pastedCancel': 'Cancel',
|
||||
'task.new': 'New task',
|
||||
'task.alarmNeedsATime':
|
||||
'The task is created. The alarm is not — "an hour before" needs a time to count back from.',
|
||||
'task.speak': 'Speak a task',
|
||||
'task.speakHint': 'Tap the microphone on your keyboard and say what needs doing.',
|
||||
'task.syntaxProject': 'project',
|
||||
'task.syntaxSection': 'section',
|
||||
'task.syntaxLabel': 'label',
|
||||
@@ -395,6 +401,18 @@ export const en = {
|
||||
'account.recoveryLeftOne': '{n} recovery code left.',
|
||||
'account.recoveryLeftMany': '{n} recovery codes left.',
|
||||
|
||||
// --- settings: alarm as standard -----------------------------------------------
|
||||
'alarm.title': 'Alarms by default',
|
||||
'alarm.hint':
|
||||
'Tasks with both a date and a time get an alarm without being asked. A task with no time gets none — there is no moment to ring at. Write "no alarm" in the line to skip a single one, or "remind me an hour before" to give it its own.',
|
||||
'alarm.on': 'Give tasks with a time an alarm',
|
||||
'alarm.when': 'When',
|
||||
'alarm.atDue': 'At the time',
|
||||
'alarm.minBefore': '{n} min before',
|
||||
'alarm.hourBefore': '1 hour before',
|
||||
'alarm.hoursBefore': '{n} hours before',
|
||||
'alarm.dayBefore': 'The day before',
|
||||
|
||||
// --- settings: notifications ---------------------------------------------------
|
||||
'push.title': 'Notifications on this device',
|
||||
'push.hint':
|
||||
@@ -712,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?',
|
||||
@@ -754,6 +781,14 @@ export const en = {
|
||||
'detail.newSubtask': 'New sub-task',
|
||||
'detail.files': 'Files',
|
||||
'detail.delete': 'Delete',
|
||||
'detail.remindAtDue': 'At the time',
|
||||
'detail.remindBefore': '{when} before',
|
||||
'detail.remindAfter': '{when} after',
|
||||
'when.minuteMany': '{n} min',
|
||||
'when.hourOne': '1 hour',
|
||||
'when.hourMany': '{n} hours',
|
||||
'when.dayOne': '1 day',
|
||||
'when.dayMany': '{n} days',
|
||||
'detail.reminders': 'Reminders',
|
||||
'detail.newReminder': 'New reminder',
|
||||
'detail.sent': 'sent',
|
||||
|
||||
@@ -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}**` : '';
|
||||
|
||||
@@ -38,6 +38,7 @@ export const SETTINGS = [
|
||||
// Notifikationer
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'dagens-plan', key: 'ai.plan', tab: 'settings.tab.notifications', words: 'plan morgen daily' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'push', key: 'push.title', tab: 'settings.tab.notifications', words: 'push besked notification badge' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'standard-alarm', key: 'alarm.title', tab: 'settings.tab.notifications', words: 'alarm pu00e5mindelse standard reminder default klokkeslu00e6t' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'seneste', key: 'push.recent', tab: 'settings.tab.notifications', words: 'log historik' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'version', key: 'push.version', tab: 'settings.tab.notifications', words: 'opdatering update version', admin: true },
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -86,9 +103,26 @@ class AppState {
|
||||
urdFace = $state(false);
|
||||
|
||||
enterUrd() {
|
||||
this.urdFace = true;
|
||||
this.setDoor('urd');
|
||||
}
|
||||
|
||||
leaveUrd() {
|
||||
this.setDoor('verdande');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hvilken dør dette vindue sidst kom ind ad.
|
||||
*
|
||||
* Symmetrisk med vilje: `/` er også en dør. Uden den halvdel bliver en fane,
|
||||
* der ÉN gang har set /urd, ved med at hedde urd — også på opgavesiderne, også
|
||||
* efter et link til forsiden. I den installerede app sker det aldrig, for dens
|
||||
* start_url er /urd og den anden apps er /; i en browserfane sker det hele
|
||||
* tiden.
|
||||
*/
|
||||
setDoor(door) {
|
||||
this.urdFace = door === 'urd';
|
||||
try {
|
||||
sessionStorage.setItem('verdande:door', 'urd');
|
||||
sessionStorage.setItem('verdande:door', door);
|
||||
} catch (e) {
|
||||
// Privat browsing kan nægte. Ansigtet holder så vinduet ud alligevel,
|
||||
// fordi det står i $state ovenfor — det er kun genindlæsningen, der
|
||||
@@ -212,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.
|
||||
@@ -264,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 = () => {
|
||||
@@ -670,6 +725,10 @@ class AppState {
|
||||
try {
|
||||
const task = await api.quickAdd(text, projectId, sectionId, parentId);
|
||||
this.upsert(task);
|
||||
// Sagt højt frem for tabt. En alarm, der ikke kunne sættes, er den eneste
|
||||
// slags påmindelse, hvis fejl intet bagefter afslører — der står ingen
|
||||
// række at savne, og man opdager den først, da den ikke ringede.
|
||||
if (task?.reminder_not_set) this.toast(t('task.alarmNeedsATime'));
|
||||
return task;
|
||||
} catch (e) {
|
||||
this.toast(humanMessage(e));
|
||||
|
||||
@@ -184,3 +184,37 @@ function every(interval, unit) {
|
||||
if (interval === 2) return t(`repeat.everyOther.${unit}`);
|
||||
return t(`repeat.everyN.${unit}`, { n: interval });
|
||||
}
|
||||
|
||||
/**
|
||||
* En påmindelses forskydning, skrevet som ord.
|
||||
*
|
||||
* Lageret LÆGGER tallet til forfaldstiden (`due_datetime + offset_min * 60`), så
|
||||
* en alarm før er negativ. Rå blev det vist som "-60 min.": et fortegn, læseren
|
||||
* selv skal oversætte, på netop den oplysning man skal kunne stole på uden at
|
||||
* regne — og en alarm, man har misforstået, opdager man først, da den ringede på
|
||||
* det forkerte tidspunkt.
|
||||
*
|
||||
* Retningen er skilt fra varigheden, så der kun er ét sæt enheder at vedligeholde.
|
||||
* En positiv forskydning kan ikke laves hverken i hurtig tilføjelse eller i
|
||||
* skærmbilledet — kun gennem API'et — men den har et ord her frem for at falde
|
||||
* tilbage på et tal, for det er den slags gren, der står urørt indtil den en dag
|
||||
* ikke gør.
|
||||
*/
|
||||
export function reminderOffsetLabel(minutes) {
|
||||
if (minutes === 0) return t('detail.remindAtDue');
|
||||
const span = durationLabel(Math.abs(minutes));
|
||||
return minutes < 0 ? t('detail.remindBefore', { when: span }) : t('detail.remindAfter', { when: span });
|
||||
}
|
||||
|
||||
/** Minutter som hele dage, hele timer eller minutter — den største, der går op. */
|
||||
function durationLabel(minutes) {
|
||||
if (minutes % 1440 === 0) {
|
||||
const days = minutes / 1440;
|
||||
return days === 1 ? t('when.dayOne') : t('when.dayMany', { n: days });
|
||||
}
|
||||
if (minutes % 60 === 0) {
|
||||
const hours = minutes / 60;
|
||||
return hours === 1 ? t('when.hourOne') : t('when.hourMany', { n: hours });
|
||||
}
|
||||
return t('when.minuteMany', { n: minutes });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import DictateSheet from '$lib/components/DictateSheet.svelte';
|
||||
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
import NoConnection from '$lib/components/NoConnection.svelte';
|
||||
@@ -14,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();
|
||||
|
||||
@@ -57,6 +59,31 @@
|
||||
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.
|
||||
*
|
||||
* To døre, og reglen er én sætning: den, du sidst kom ind ad, bestemmer
|
||||
* ansigtet. `/urd` sætter urd, `/` sætter verdande, og alt derimellem rører
|
||||
* det ikke — så et klik videre til en note eller en opgave beholder det navn,
|
||||
* vinduet åbnede med.
|
||||
*
|
||||
* Symmetrien er det, der gør det forudsigeligt. Uden `/`-halvdelen ville en
|
||||
* fane, der én gang havde set /urd, blive ved med at hedde urd, også på
|
||||
* opgavesiderne. I de installerede apps sker det aldrig — deres start_url er
|
||||
* hver sin dør — men en browserfane springer mellem dem hele tiden.
|
||||
*/
|
||||
$effect(() => {
|
||||
const here = $page.url.pathname;
|
||||
if (here === '/urd') app.enterUrd();
|
||||
else if (here === '/') app.leaveUrd();
|
||||
});
|
||||
|
||||
/**
|
||||
* Opgavernes sider findes ikke i noter-udgaven.
|
||||
*
|
||||
@@ -295,6 +322,15 @@
|
||||
tabindex="-1"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<!-- Tal en opgave ind. Kun på telefonen, og kun hvor der er opgaver — se
|
||||
komponenten.
|
||||
|
||||
UBETINGET monteret, og det er ikke en forglemmelse: feltet inde i den
|
||||
skal kunne få fokus i samme hug som klikket, ellers åbner iOS ikke
|
||||
tastaturet. Et `{#if}` her ville tage feltet ud af DOM'en og gøre det
|
||||
umuligt. Menuen dækker den i stedet, fordi den har højere z-index. -->
|
||||
<DictateSheet />
|
||||
</div>
|
||||
|
||||
<CommandPalette bind:open={paletteOpen} />
|
||||
|
||||
@@ -5,6 +5,39 @@
|
||||
import * as push from '$lib/push.js';
|
||||
import { t, plural } from '$lib/i18n.svelte.js';
|
||||
|
||||
// --- alarm som standard -------------------------------------------------------
|
||||
|
||||
// Minutter i forhold til forfaldstiden, negativt for fu00f8r. Samme fortegn som
|
||||
// reminders.offset_min, fordi det er den kolonne, vu00e6rdien ender i — to
|
||||
// fortegnsregler for det samme begreb viser sig kun som en alarm pu00e5 et forkert
|
||||
// tidspunkt.
|
||||
const ALARM_CHOICES = [0, -5, -10, -15, -30, -60, -120, -1440];
|
||||
|
||||
// Valgt, nu00e5r indstillingen slu00e5s til igen. Pu00e5 tidspunktet er sju00e6ldent det, man
|
||||
// mener med en alarm: der er ingen varsel i at blive mindet om noget, mens det
|
||||
// sker.
|
||||
const ALARM_DEFAULT = -10;
|
||||
|
||||
function alarmLabel(minutes) {
|
||||
if (minutes === 0) return t('alarm.atDue');
|
||||
const n = Math.abs(minutes);
|
||||
if (n === 1440) return t('alarm.dayBefore');
|
||||
if (n === 60) return t('alarm.hourBefore');
|
||||
if (n % 60 === 0) return t('alarm.hoursBefore', { n: n / 60 });
|
||||
return t('alarm.minBefore', { n });
|
||||
}
|
||||
|
||||
async function saveAlarm(minutes) {
|
||||
try {
|
||||
await api.setDefaultReminder(minutes);
|
||||
// Kontoen genlu00e6ses frem for at blive gu00e6ttet lokalt: serveren er den, der
|
||||
// afviser et positivt tal, su00e5 det, der vises, skal vu00e6re det, der blev gemt.
|
||||
app.user = await api.me();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- web push ----------------------------------------------------------------
|
||||
|
||||
let pushState = $state('loading');
|
||||
@@ -254,6 +287,37 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel" id="standard-alarm">
|
||||
<header>
|
||||
<h2>{t('alarm.title')}</h2>
|
||||
<p class="hint">{t('alarm.hint')}</p>
|
||||
</header>
|
||||
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={app.user?.default_reminder_min != null}
|
||||
onchange={(e) => saveAlarm(e.currentTarget.checked ? ALARM_DEFAULT : null)}
|
||||
/>
|
||||
<span>{t('alarm.on')}</span>
|
||||
</label>
|
||||
|
||||
{#if app.user?.default_reminder_min != null}
|
||||
<div class="field">
|
||||
<label for="alarmwhen">{t('alarm.when')}</label>
|
||||
<select
|
||||
id="alarmwhen"
|
||||
value={app.user.default_reminder_min}
|
||||
onchange={(e) => saveAlarm(Number(e.currentTarget.value))}
|
||||
>
|
||||
{#each ALARM_CHOICES as minutes (minutes)}
|
||||
<option value={minutes}>{alarmLabel(minutes)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel" id="push">
|
||||
<header>
|
||||
<h2>{t('push.title')}</h2>
|
||||
|
||||
@@ -730,8 +730,10 @@
|
||||
const note = await api.createNote({ body: '' });
|
||||
notes = [note, ...notes];
|
||||
// Svaret er hele noten, så den skal ikke hentes igen — se open().
|
||||
// Markøren sættes af editoren selv, når noten er tom — se
|
||||
// placeCaretInTitle i NoteEditor. Her stod en focus() på
|
||||
// `.editor textarea`, og editoren har ingen textarea.
|
||||
await open(note, { whole: true });
|
||||
document.querySelector('.editor textarea')?.focus();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
|
||||
@@ -7,25 +7,18 @@
|
||||
* hvad appen gør, når den så er startet: husker hvilken dør den kom ind ad, og
|
||||
* går videre til noterne.
|
||||
*
|
||||
* `sessionStorage` og ikke `localStorage`, og det er hele grunden til at de to
|
||||
* apps kan være to apps: de deler origin, så de deler localStorage, cookies og
|
||||
* service worker. sessionStorage er per vindue — og en installeret PWA er sit
|
||||
* eget vindue — så urd-appen har sit ansigt og verdande-appen sit, uden at
|
||||
* træde på hinanden. Det overlever også en genindlæsning, hvilket det skal:
|
||||
* efter et par klik står adressen på /noter, og en F5 dér går ikke gennem /urd
|
||||
* igen.
|
||||
* Ansigtet sættes ikke her, men i layoutet, hvor begge døre står ved siden af
|
||||
* hinanden — `/urd` sætter urd og `/` sætter verdande. Delt op, fordi en regel
|
||||
* med to halvdele, der ligger i hver sin fil, er en regel, hvor kun den ene
|
||||
* halvdel bliver vedligeholdt.
|
||||
*
|
||||
* `replaceState`, så telefonens tilbage-gestus ikke fører tilbage til en side,
|
||||
* der kun omdirigerer.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
|
||||
onMount(() => {
|
||||
app.enterUrd();
|
||||
goto('/noter', { replaceState: true });
|
||||
});
|
||||
onMount(() => goto('/noter', { replaceState: true }));
|
||||
</script>
|
||||
|
||||
<!-- Bevidst tom. Omdirigeringen sker i samme hug som tegningen, så alt her ville
|
||||
|
||||
@@ -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