Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b2cd0f259 | ||
|
|
3991ec9445 | ||
|
|
9d743a5d46 | ||
|
|
71a9fe63d6 | ||
|
|
eeaaf7506b | ||
|
|
dce8800f93 | ||
|
|
241c5ed37d | ||
|
|
78891bae6f | ||
|
|
4b5f3f0864 | ||
|
|
052f8a43fb | ||
|
|
dee2d5e30b |
@@ -66,3 +66,6 @@ node_modules.nosync/
|
||||
# Kept on disk because it is how a session hands over to the next one.
|
||||
HANDOFF.md
|
||||
NOTES-PLAN.md
|
||||
|
||||
# Agenternes arbejdstræer. Et git-repo inde i et git-repo, som ikke hører til her.
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="site/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
|
||||
<img src="docs/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -35,6 +35,11 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
|
||||
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
||||
views, and foldable groups over the projects in the sidebar.
|
||||
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
|
||||
lists, quotes, fenced code. A note is shared by filing it in a project;
|
||||
`#project` and `[[another note]]` are links, and a task shows the notes that
|
||||
mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
||||
the exporter in [`tools/`](tools/) — and comes back out the same shape.
|
||||
- **Drag where it means something.** Reorder a list or a board, file a project under
|
||||
a group, drop a task on another project, or drop it on another day.
|
||||
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
||||
@@ -90,6 +95,8 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| Project templates | Done, tested |
|
||||
| Nightly backups with rotation | Done, tested |
|
||||
| Comments, attachments, notifications, Web Push | Built |
|
||||
| Notes: rich text, Markdown on disk, links to projects, notes and tasks | Done, tested |
|
||||
| Notes in and out: a zip of Markdown, both directions | Done, tested |
|
||||
| Todoist CSV import and export, account export | Done, tested |
|
||||
| MCP server for Claude | Done, tested |
|
||||
| CalDAV server (two-way VTODO) | Done, tested |
|
||||
@@ -116,17 +123,20 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
| Board view — one column per section. | The light theme. |
|
||||
|  |  |
|
||||
| A week at a time, so a task can be dragged across a month boundary. | A task, opened. |
|
||||
|  |  |
|
||||
| Five themes, all measured for contrast. | What has been done on the server. |
|
||||
|  |  |
|
||||
| A week at a time, so a task can be dragged across a month boundary. | A task, opened: description, priority, labels, sub-tasks and its files. |
|
||||
|  |  |
|
||||
| Notes, filed in a project to share them. | The same note as its source — which is what the export writes to disk. |
|
||||
|  |  |
|
||||
| Five themes and four looks: how bright, and how it reads. | What has been done on the server. |
|
||||
|
||||
Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts
|
||||
the real binary, creates its own account, fills it with demo data and photographs
|
||||
eleven surfaces — nothing to set up and nothing to remember. (`go run ./tools/shots`
|
||||
still works, and is the way to photograph an instance that is already running.)
|
||||
the real binary, creates its own account and a second one to share with, fills them
|
||||
with demo data and photographs fourteen surfaces — nothing to set up and nothing to
|
||||
remember. (`go run ./tools/shots` still works, and is the way to photograph an
|
||||
instance that is already running.)
|
||||
|
||||
## Running it
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ func run() error {
|
||||
runner.SyncGmail = api.SyncGmail
|
||||
runner.SyncMailbox = api.SyncMailbox
|
||||
runner.Push = api.PushToUser
|
||||
runner.SendBeacon = api.SendBeacon
|
||||
runner.Start(jobCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -22,6 +22,9 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
||||
board and calendar views, and foldable groups over the projects in the sidebar.
|
||||
- **Notes, beside the work.** Rich text to write in, Markdown on disk. A note is
|
||||
shared by filing it in a project; `#project` and `[[another note]]` are links,
|
||||
and a task shows what has been written about it.
|
||||
- **Drag where it means something** — reorder a list or a board, file a project
|
||||
under a group, drop a task on another project, or drop it on another day.
|
||||
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
||||
@@ -31,6 +34,33 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
||||
open standards rather than private formats.
|
||||
|
||||
## What it looks like
|
||||
|
||||

|
||||
|
||||
*Upcoming, as a month. A week and a plain list are the other two, and a task can be
|
||||
dragged from one day onto another.*
|
||||
|
||||

|
||||
|
||||
*The same project as a list, where sections read as bands. As a board they are
|
||||
columns.*
|
||||
|
||||

|
||||
|
||||
*What you have handed to somebody else, grouped by whoever has it.*
|
||||
|
||||

|
||||
|
||||
*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
|
||||
[mail-to-task address](mail.md) are connected.*
|
||||
|
||||

|
||||
|
||||
*Inviting somebody to the instance. There is no open registration — everybody
|
||||
arrives through a link, and picks their own password. See
|
||||
[signing in](signing-in.md).*
|
||||
|
||||
## Getting it running
|
||||
|
||||
verdande is built to be deployed as a **Rune** in
|
||||
|
||||
@@ -1396,6 +1396,153 @@ paths:
|
||||
description: The environment variables that are not set.
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/notes/{noteID}/attachments:
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: Put a file into a note.
|
||||
description: |
|
||||
A picture pasted or dropped into a note. Until this existed the import
|
||||
could bring files in and nothing else could, so the most ordinary act in
|
||||
a notes app — paste a screenshot — did nothing at all.
|
||||
|
||||
Editor rights on the note. The same answer for "no such note" and "not
|
||||
yours", so an id cannot be probed.
|
||||
|
||||
The response carries the attachment's URL; the editor writes that into the
|
||||
note's text as a Markdown image. The file is never inlined as a data URL —
|
||||
a photograph as base64 would make the note itself megabytes of text.
|
||||
parameters:
|
||||
- name: noteID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
responses:
|
||||
"201":
|
||||
description: Stored, and hung on the note.
|
||||
"404":
|
||||
description: No such note, or not yours to write in.
|
||||
"413":
|
||||
description: Larger than 25 MB.
|
||||
|
||||
/api/v1/beacon:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Report that an installation exists. Unauthenticated.
|
||||
description: |
|
||||
The receiving end of the beacon. Open on purpose: the caller is a
|
||||
stranger's installation, which is the whole feature.
|
||||
|
||||
Answers 404 unless this instance has been told it is the collector, so an
|
||||
ordinary installation is not a spam sink that answers 200 to anything.
|
||||
Rate limited like the login routes, and the table behind it has a ceiling:
|
||||
the endpoint inserts a row keyed on an id the caller invented, so without
|
||||
one anybody could grow it until the volume is full.
|
||||
|
||||
Nothing about the request is recorded but the two values in the body. No
|
||||
source address is read or written here.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [instance_id]
|
||||
properties:
|
||||
instance_id:
|
||||
type: string
|
||||
description: A random id the installation generated for itself.
|
||||
version:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: Recorded.
|
||||
"400":
|
||||
description: The id or the version is not the expected shape.
|
||||
"404":
|
||||
description: This instance is not a collector.
|
||||
|
||||
/api/v1/beacon/count:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: How many installations reported in the last thirty days.
|
||||
description: |
|
||||
Unauthenticated and cached for a minute, and only answered when the
|
||||
collector has been told to publish it.
|
||||
|
||||
A window rather than a running total: a total only climbs, so it stops
|
||||
meaning "how many people use this" and starts meaning "how many ever
|
||||
tried it".
|
||||
responses:
|
||||
"200":
|
||||
description: The count.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
installs: { type: integer }
|
||||
window_days: { type: integer }
|
||||
"404":
|
||||
description: Not a collector, or not published.
|
||||
|
||||
/api/v1/beacon/settings:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: What this instance reports, and to whom. Administrators only.
|
||||
description: |
|
||||
Includes the exact payload that will be sent, so the settings page can
|
||||
print it in full rather than describe it.
|
||||
responses:
|
||||
"200":
|
||||
description: The current configuration, and the counts if this is the collector.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
collector_url: { type: string }
|
||||
instance_id: { type: string }
|
||||
version: { type: string }
|
||||
is_collector: { type: boolean }
|
||||
publish_count: { type: boolean }
|
||||
last_ping_at: { type: string, format: date-time }
|
||||
put:
|
||||
tags: [auth]
|
||||
summary: Turn the beacon off, or point it somewhere else. Administrators only.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
enabled: { type: boolean }
|
||||
collector_url:
|
||||
type: string
|
||||
description: |
|
||||
Where reports go. Fetched through a dialer that refuses
|
||||
loopback and private address space, so this cannot be used to
|
||||
make the server reach inside its own network.
|
||||
is_collector: { type: boolean }
|
||||
publish_count: { type: boolean }
|
||||
responses:
|
||||
"200":
|
||||
description: The configuration as it now stands.
|
||||
"422":
|
||||
description: The collector address is not an http or https URL.
|
||||
|
||||
/api/v1/panel/restart:
|
||||
post:
|
||||
tags: [auth]
|
||||
|
||||
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 180 KiB |
@@ -0,0 +1,139 @@
|
||||
// Package beacon answers one question for the project, and refuses to answer any
|
||||
// other: how many installations are out there.
|
||||
//
|
||||
// It sends two values once a day — an anonymous id the installation made up for
|
||||
// itself, and the version it is running — and nothing else. No IP is stored by
|
||||
// the collector, no hostname, no domain, no account, no usage. The payload is
|
||||
// small enough to be printed in full on the settings page, and it is, because a
|
||||
// promise about telemetry is worth exactly as much as the reader's ability to
|
||||
// check it.
|
||||
//
|
||||
// # On by default, and off in one click
|
||||
//
|
||||
// The alternative that was asked for first was mandatory, and the argument
|
||||
// against it is practical rather than principled: it cannot be enforced. The
|
||||
// domain can be blocked, the binary can be patched, and a fork removes it in its
|
||||
// first commit — so mandatory buys nothing over on-by-default, while costing the
|
||||
// trust the rest of this program works for. Secrets are sealed here, the key is
|
||||
// kept out of the database, and the backup page warns you what a copy contains.
|
||||
// Telemetry you are not allowed to refuse is the one sentence in that story that
|
||||
// does not fit.
|
||||
//
|
||||
// So: on by default, said plainly, one switch to turn it off. That collects
|
||||
// essentially the same number and leaves nobody feeling tricked.
|
||||
//
|
||||
// # Where this disagrees with the rest of the program
|
||||
//
|
||||
// `config.UpdateCheck` is off unless asked for, and its comment gives the reason:
|
||||
// "a self-hosted app that reaches out without being told to has broken the deal
|
||||
// its operator made by self-hosting." That is the same situation as this one, and
|
||||
// the two defaults do not agree.
|
||||
//
|
||||
// The disagreement is written down here rather than smoothed over, because it is
|
||||
// a real one and the reader deserves to see both halves. The case for treating
|
||||
// them differently is that the update check reveals an instance to a third party
|
||||
// (GitHub) and returns something the operator can act on, while this returns
|
||||
// nothing and goes to the project itself — and that a count nobody sends is a
|
||||
// count that does not exist, whereas an update nobody checks for is one you can
|
||||
// still go and look up. Whether that is a distinction or an excuse is a judgement
|
||||
// call, and it was made deliberately by the person whose instance this is.
|
||||
//
|
||||
// If it is ever decided the other way, the change is one word: the default in
|
||||
// `beaconSettings`. Nothing else in this package assumes which way it points.
|
||||
//
|
||||
// # What the collector can still see
|
||||
//
|
||||
// A request has a source address whether or not anybody writes it down. The
|
||||
// collector does not store or log it, and the code is arranged so that it never
|
||||
// reaches a function that could — but it passes through a reverse proxy and a
|
||||
// tunnel on the way, and those keep their own logs. Saying "we send no IP" would
|
||||
// be a half-truth; what is true is that nothing here records one.
|
||||
package beacon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultCollector is where a fresh installation reports, unless it is told
|
||||
// otherwise. Somebody running their own fleet points it at their own instance;
|
||||
// the field is on the settings page for exactly that.
|
||||
const DefaultCollector = "https://verdande.yggdrasilpanel.com/api/v1/beacon"
|
||||
|
||||
// Payload is the entire message. Two fields, and adding a third is a change to
|
||||
// what this program promises, not a change to a struct.
|
||||
type Payload struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// InstanceID and Version are checked on the way in as well as written on the way
|
||||
// out, because the collector's endpoint is unauthenticated: anything that reaches
|
||||
// it is a stranger's bytes, and it should be able to store only the two shapes it
|
||||
// expects.
|
||||
var (
|
||||
idShape = regexp.MustCompile(`^[0-9a-fA-F-]{16,64}$`)
|
||||
versionShape = regexp.MustCompile(`^v?[0-9]{1,4}(\.[0-9]{1,5}){0,3}(-[0-9A-Za-z.]{1,20})?$`)
|
||||
)
|
||||
|
||||
// ValidID reports whether an id is the shape this program writes.
|
||||
func ValidID(s string) bool { return idShape.MatchString(s) }
|
||||
|
||||
// ValidVersion allows a version number and the word this binary uses when it was
|
||||
// built outside a release.
|
||||
func ValidVersion(s string) bool { return s == "dev" || versionShape.MatchString(s) }
|
||||
|
||||
// Send delivers one ping. It is deliberately dull: a short timeout, no retry, no
|
||||
// queue, and a failure that is returned rather than made anybody's problem.
|
||||
//
|
||||
// No retry, because the next pass is tomorrow and a missed day changes a count of
|
||||
// installations by nothing. A telemetry ping that retries is a telemetry ping
|
||||
// that can hammer somebody's server on the day it is down.
|
||||
func Send(ctx context.Context, client *http.Client, collector string, p Payload) error {
|
||||
if !ValidID(p.InstanceID) {
|
||||
return fmt.Errorf("beacon: refusing to send a malformed id")
|
||||
}
|
||||
// Versionen kontrolleres også her, ikke kun hos modtageren. Kommentaren ved
|
||||
// mønstrene lovede begge veje, og gjorde det ikke — og en installation, der
|
||||
// sender noget mærkeligt, skal opdage det hos sig selv frem for at være noget,
|
||||
// en fremmed collector skal filtrere fra.
|
||||
if !ValidVersion(p.Version) {
|
||||
return fmt.Errorf("beacon: refusing to send a malformed version")
|
||||
}
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, collector, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// A plain user agent with the version already in the payload. Nothing is
|
||||
// learned from it that the body does not already say, and it makes the request
|
||||
// legible in a log rather than anonymous-looking.
|
||||
req.Header.Set("User-Agent", "verdande-beacon/1")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Read and discard, so the connection can be reused rather than dropped.
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("beacon: collector answered %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package beacon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Hvad der bliver sendt, tegn for tegn.
|
||||
//
|
||||
// Prøven er skrevet imod *hele* kroppen og ikke imod enkelte felter, fordi det er
|
||||
// den, der er løftet: siden siger "præcis det her og ikke mere", og et felt lagt
|
||||
// til senere skal vælte noget. En prøve, der kun så efter, at instance_id var med,
|
||||
// ville lade en ny nøgle glide igennem uden at nogen opdagede det.
|
||||
func TestSendsExactlyTwoFields(t *testing.T) {
|
||||
var got map[string]any
|
||||
var contentType string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
contentType = r.Header.Get("Content-Type")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Errorf("kroppen er ikke JSON: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := Send(context.Background(), srv.Client(), srv.URL, Payload{
|
||||
InstanceID: "01a01b16-4e54-7000-9eaa-2f8eb0629c3a",
|
||||
Version: "v0.26.3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Content-Type = %q", contentType)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("der blev sendt %d felter, ikke to: %v", len(got), got)
|
||||
}
|
||||
if got["instance_id"] != "01a01b16-4e54-7000-9eaa-2f8eb0629c3a" {
|
||||
t.Errorf("instance_id = %v", got["instance_id"])
|
||||
}
|
||||
if got["version"] != "v0.26.3" {
|
||||
t.Errorf("version = %v", got["version"])
|
||||
}
|
||||
}
|
||||
|
||||
// En collector, der er nede, er ikke denne installations problem.
|
||||
func TestSendReportsAFailureRatherThanRetrying(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := Send(context.Background(), srv.Client(), srv.URL, Payload{
|
||||
InstanceID: "01a01b16-4e54-7000-9eaa-2f8eb0629c3a", Version: "v1.0.0",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("en 500 skal give en fejl")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("der blev prøvet %d gange; en telemetri-ping må ikke hamre på en server, der er nede", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Et id, der ikke er id-formet, sendes slet ikke — så en fejl her ikke bliver til
|
||||
// noget, en fremmed collector skal filtrere fra.
|
||||
func TestSendRefusesAMalformedID(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("der blev sendt noget, som ikke skulle sendes")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := Send(context.Background(), srv.Client(), srv.URL, Payload{
|
||||
InstanceID: "; DROP TABLE beacon_installs", Version: "v1.0.0",
|
||||
}); err == nil {
|
||||
t.Fatal("et misformet id skal afvises før det forlader maskinen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShapes(t *testing.T) {
|
||||
ok := []string{"01a01b16-4e54-7000-9eaa-2f8eb0629c3a", "89df44d7-3742-4611-9a43-db0b07733c6d"}
|
||||
for _, s := range ok {
|
||||
if !ValidID(s) {
|
||||
t.Errorf("ValidID(%q) = false", s)
|
||||
}
|
||||
}
|
||||
bad := []string{"", "kort", "../../etc/passwd", "01a01b16-4e54-7000-9eaa-2f8eb0629c3a'; --",
|
||||
"<script>alert(1)</script>"}
|
||||
for _, s := range bad {
|
||||
if ValidID(s) {
|
||||
t.Errorf("ValidID(%q) = true", s)
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range []string{"v0.26.3", "0.26.3", "v1.2.3-rc.1", "dev"} {
|
||||
if !ValidVersion(s) {
|
||||
t.Errorf("ValidVersion(%q) = false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"", "v<script>", "v" + string(make([]byte, 200))} {
|
||||
if ValidVersion(s) {
|
||||
t.Errorf("ValidVersion(%q) = true", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/beacon"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// The beacon, from the instance's side.
|
||||
//
|
||||
// Two halves that happen to live in the same binary: the part that reports in,
|
||||
// which every installation runs, and the part that receives, which only the one
|
||||
// acting as collector turns on. See internal/beacon for what is sent and why it
|
||||
// is on by default rather than compulsory.
|
||||
|
||||
const beaconScope = "beacon"
|
||||
|
||||
// beaconConfig is what this instance has been told to do. The zero value is what
|
||||
// a fresh installation gets, which is why Enabled defaults to true elsewhere and
|
||||
// not here — a zero struct must never mean "silently on".
|
||||
type beaconConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt int64 `json:"last_ping_at,omitempty"`
|
||||
}
|
||||
|
||||
// beaconSettings reads the configuration, filling in the defaults and minting the
|
||||
// anonymous id on first use.
|
||||
//
|
||||
// The id is generated here rather than derived from anything about the machine.
|
||||
// A hash of the hostname or the base URL would be stable and convenient and would
|
||||
// also be a value the collector could compare against a guess — which is the
|
||||
// difference between anonymous and pseudonymous, and it is the whole point.
|
||||
func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
|
||||
cfg := beaconConfig{Enabled: true, Collector: beacon.DefaultCollector}
|
||||
|
||||
stored, err := s.db.InstanceSettings(ctx, beaconScope)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
if v, ok := stored["enabled"].(bool); ok {
|
||||
cfg.Enabled = v
|
||||
}
|
||||
if v, ok := stored["collector_url"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
cfg.Collector = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := stored["instance_id"].(string); ok && beacon.ValidID(v) {
|
||||
cfg.InstanceID = v
|
||||
}
|
||||
if v, ok := stored["is_collector"].(bool); ok {
|
||||
cfg.IsCollector = v
|
||||
}
|
||||
if v, ok := stored["publish_count"].(bool); ok {
|
||||
cfg.PublishCount = v
|
||||
}
|
||||
if v, ok := stored["last_ping_at"].(float64); ok {
|
||||
cfg.LastPingAt = int64(v)
|
||||
}
|
||||
|
||||
if cfg.InstanceID == "" {
|
||||
cfg.InstanceID = store.NewID()
|
||||
if err := s.saveBeaconSettings(ctx, cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Server) saveBeaconSettings(ctx context.Context, cfg beaconConfig) error {
|
||||
return s.db.SetInstanceSettings(ctx, beaconScope, map[string]any{
|
||||
"enabled": cfg.Enabled,
|
||||
"collector_url": cfg.Collector,
|
||||
"instance_id": cfg.InstanceID,
|
||||
"is_collector": cfg.IsCollector,
|
||||
"publish_count": cfg.PublishCount,
|
||||
"last_ping_at": cfg.LastPingAt,
|
||||
})
|
||||
}
|
||||
|
||||
// --- what the settings page shows ------------------------------------------------
|
||||
|
||||
type beaconStatusJSON struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Collector string `json:"collector_url"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
IsCollector bool `json:"is_collector"`
|
||||
PublishCount bool `json:"publish_count"`
|
||||
LastPingAt string `json:"last_ping_at,omitempty"`
|
||||
Stats *store.BeaconStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := beaconStatusJSON{
|
||||
Enabled: cfg.Enabled, Collector: cfg.Collector, InstanceID: cfg.InstanceID,
|
||||
Version: Version, IsCollector: cfg.IsCollector, PublishCount: cfg.PublishCount,
|
||||
}
|
||||
if cfg.LastPingAt > 0 {
|
||||
out.LastPingAt = time.Unix(cfg.LastPingAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
if cfg.IsCollector {
|
||||
stats, err := s.db.BeaconSummary(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon summary", err)
|
||||
return
|
||||
}
|
||||
out.Stats = &stats
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type beaconRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Collector *string `json:"collector_url"`
|
||||
IsCollector *bool `json:"is_collector"`
|
||||
PublishCount *bool `json:"publish_count"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
|
||||
var req beaconRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
cfg.Enabled = *req.Enabled
|
||||
}
|
||||
if req.IsCollector != nil {
|
||||
cfg.IsCollector = *req.IsCollector
|
||||
}
|
||||
if req.PublishCount != nil {
|
||||
cfg.PublishCount = *req.PublishCount
|
||||
}
|
||||
if req.Collector != nil {
|
||||
url := strings.TrimSpace(*req.Collector)
|
||||
if url == "" {
|
||||
url = beacon.DefaultCollector
|
||||
}
|
||||
// http and https only, and said as a field error rather than a 500: an
|
||||
// operator pointing this at their own collector will mistype it, and the
|
||||
// page should say which field is wrong.
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
writeFieldErrors(w, map[string]string{"collector_url": "must start with http:// or https://"})
|
||||
return
|
||||
}
|
||||
cfg.Collector = url
|
||||
}
|
||||
|
||||
if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
|
||||
s.internal(w, r, "save beacon settings", err)
|
||||
return
|
||||
}
|
||||
s.handleBeaconStatus(w, r)
|
||||
}
|
||||
|
||||
// --- the collector's side ---------------------------------------------------------
|
||||
|
||||
// handleBeaconPing receives one report. Unauthenticated, because the whole point
|
||||
// is that a stranger's installation can reach it.
|
||||
//
|
||||
// Nothing about the request is recorded except the two values in the body. The
|
||||
// source address is not read here, and there is no code path from this function
|
||||
// to anything that could write one down — which is the only version of that
|
||||
// promise worth making.
|
||||
func (s *Server) handleBeaconPing(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
// An instance that is not a collector does not quietly accept and drop pings —
|
||||
// it says there is nothing here. Otherwise every installation in the world is a
|
||||
// spam sink that answers 200 to anything.
|
||||
if !cfg.IsCollector {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "this instance is not a beacon collector")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
|
||||
var p beacon.Payload
|
||||
if err := decodeJSON(w, r, &p); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Checked, not trusted. This is the one endpoint anybody can post to, and the
|
||||
// two columns behind it should only ever hold the two shapes this program
|
||||
// writes — an id that is not id-shaped is somebody probing, not an install.
|
||||
if !beacon.ValidID(p.InstanceID) {
|
||||
writeFieldErrors(w, map[string]string{"instance_id": "malformed"})
|
||||
return
|
||||
}
|
||||
if p.Version != "" && !beacon.ValidVersion(p.Version) {
|
||||
writeFieldErrors(w, map[string]string{"version": "malformed"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.RecordBeacon(r.Context(), p.InstanceID, p.Version); err != nil {
|
||||
s.internal(w, r, "record beacon", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleBeaconCount publishes the number, when the collector has been told to.
|
||||
//
|
||||
// Thirty days rather than a running total: a total only climbs, so it stops
|
||||
// meaning "how many people use this" and starts meaning "how many ever tried it".
|
||||
func (s *Server) handleBeaconCount(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := s.beaconSettings(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon settings", err)
|
||||
return
|
||||
}
|
||||
if !cfg.IsCollector || !cfg.PublishCount {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not published")
|
||||
return
|
||||
}
|
||||
|
||||
n, err := s.db.BeaconCount(r.Context(), 30*24*time.Hour)
|
||||
if err != nil {
|
||||
s.internal(w, r, "beacon count", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"installs": n, "window_days": 30})
|
||||
}
|
||||
|
||||
// SendBeacon reports this installation, at most once a day.
|
||||
//
|
||||
// Exported and handed to the jobs runner the same way the mailbox syncs are: the
|
||||
// settings, the anonymous id and the version all live on this side, and the
|
||||
// runner should not have to know about any of them.
|
||||
//
|
||||
// The day is counted from the last successful send, not from a wall-clock hour.
|
||||
// A fleet of installations that all pinged at 03:00 would arrive in one spike;
|
||||
// counted this way they spread themselves out by when each was started.
|
||||
func (s *Server) SendBeacon(ctx context.Context) error {
|
||||
cfg, err := s.beaconSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
// The collector does not report to itself over the network. It would work, and
|
||||
// it would also mean the one instance whose count matters most depends on its
|
||||
// own tunnel being up to be counted.
|
||||
if cfg.IsCollector {
|
||||
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
|
||||
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour {
|
||||
return nil
|
||||
}
|
||||
|
||||
// safedial, ikke http.DefaultClient. Adressen kan skrives i fladen, og en
|
||||
// administrator, der peger den på 127.0.0.1 eller på 169.254.169.254, ville
|
||||
// ellers have en maskine, der henter indefra på kommando — og svaret på om den
|
||||
// svarede, er i sig selv det, sådan et forsøg leder efter.
|
||||
if err := beacon.Send(ctx, safedial.Client(15*time.Second), cfg.Collector, beacon.Payload{
|
||||
InstanceID: cfg.InstanceID,
|
||||
Version: Version,
|
||||
}); err != nil {
|
||||
// Logged at info, not error. The collector being unreachable is not this
|
||||
// installation's problem and must never look like one — it is somebody
|
||||
// else's server, and the only consequence is a number being one lower.
|
||||
s.log.Info("beacon not delivered", "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg.LastPingAt = time.Now().Unix()
|
||||
return s.saveBeaconSettings(ctx, cfg)
|
||||
}
|
||||
@@ -251,6 +251,17 @@ func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Returns how many were made. Also used by the background job, which is why it
|
||||
// takes a context and a user rather than a request.
|
||||
// maxGmailSeen bounds the list of message ids already turned into tasks.
|
||||
//
|
||||
// Gmail is read-only here — the scope is `gmail.readonly`, deliberately, because
|
||||
// asking for less is what makes the consent screen something a person can agree
|
||||
// to — so verdande cannot unstar a message the way the IMAP side unflags one. The
|
||||
// record has to live on this side, and a record on this side has to be bounded.
|
||||
//
|
||||
// A count rather than the previous rule of "whatever Gmail returned this run",
|
||||
// which was not a bound but a way of forgetting.
|
||||
const maxGmailSeen = 2000
|
||||
|
||||
func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
box, err := s.db.MailboxOfKind(ctx, user.ID, "gmail")
|
||||
if err != nil {
|
||||
@@ -362,14 +373,35 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
}
|
||||
|
||||
if created > 0 {
|
||||
// Only the ids Gmail still returns are kept, so the list cannot grow
|
||||
// without bound as messages fall out of the 30-day window.
|
||||
keep := make([]string, 0, len(ids))
|
||||
// The ids this run saw, on top of the ones already known.
|
||||
//
|
||||
// It used to keep only the ids Gmail returned *this run*, to stop the list
|
||||
// growing without bound. That threw away every id that was not on the page
|
||||
// it happened to fetch — and the query is capped, so on an account with more
|
||||
// starred mail than one page, each run forgot the messages the last one had
|
||||
// remembered. They came back unseen, and became tasks again, and again. That
|
||||
// is the duplicate.
|
||||
//
|
||||
// Bounded by a count instead. Newest first, and a ceiling far above any
|
||||
// plausible page, so an id leaves the list because it is old rather than
|
||||
// because it was not in the last answer.
|
||||
keep := make([]string, 0, len(ids)+len(box.Seen))
|
||||
added := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
if seen[id] && !added[id] {
|
||||
keep = append(keep, id)
|
||||
added[id] = true
|
||||
}
|
||||
}
|
||||
for _, id := range box.Seen {
|
||||
if !added[id] {
|
||||
keep = append(keep, id)
|
||||
added[id] = true
|
||||
}
|
||||
}
|
||||
if len(keep) > maxGmailSeen {
|
||||
keep = keep[:maxGmailSeen]
|
||||
}
|
||||
box.Seen = keep
|
||||
box.LastSyncAt = time.Now()
|
||||
if err := s.db.SaveMailbox(ctx, box); err != nil {
|
||||
|
||||
@@ -175,6 +175,10 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
|
||||
created := 0
|
||||
highest := m.LastUID
|
||||
// The uids of the mails that actually became tasks. Only those get unflagged,
|
||||
// and only after the task is committed: a flag removed from a mail whose task
|
||||
// was never written is a mail nobody will ever see again.
|
||||
done := make([]uint32, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
if ctx.Err() != nil {
|
||||
// What was made so far is already committed and counted, and the marker
|
||||
@@ -204,12 +208,27 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
continue
|
||||
}
|
||||
created++
|
||||
done = append(done, msg.UID)
|
||||
if msg.UID > highest {
|
||||
highest = msg.UID
|
||||
}
|
||||
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
||||
}
|
||||
|
||||
// The flag comes off what became a task. This is what actually stops a mail
|
||||
// arriving twice; the marker below is now a second line rather than the only
|
||||
// one. See imap.Unflag for why the record belongs in the mailbox and not here.
|
||||
//
|
||||
// Outside the request's context, like the marker: a cancelled request that
|
||||
// already created tasks must still take the flags off them, or the next run
|
||||
// makes them all again.
|
||||
if err := client.Unflag(done); err != nil {
|
||||
// Not fatal, and not the caller's problem. A folder somebody opened
|
||||
// read-only, or a server that refuses the STORE, still gets the uid marker
|
||||
// underneath — worse, but not broken.
|
||||
s.log.Warn("could not unflag imported mail", "err", err, "mailbox", m.ID, "count", len(done))
|
||||
}
|
||||
|
||||
// A run that got all the way through moves the marker to the whole of what the
|
||||
// search considered, not merely to the last message it happened to read. That
|
||||
// is what stops the same mail becoming a task again on the next run when the
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -579,3 +581,68 @@ func mimeOf(name string) string {
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// handleUploadNoteAttachment takes a file into a note.
|
||||
//
|
||||
// The import could already do this, and nothing else could: a note brought in
|
||||
// from Apple Notes arrived with its pictures, and a note written here could not
|
||||
// be given one. Pasting a screenshot into a note is the most ordinary thing there
|
||||
// is, and it did nothing at all.
|
||||
//
|
||||
// Editor rights on the note, checked the same way every other write to it is. The
|
||||
// answer for "no such note" and "not yours" is the same, so an id cannot be
|
||||
// probed by watching which one comes back.
|
||||
func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that file is too large (25 MB maximum)")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": "required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if header.Size > maxUploadBytes {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that file is too large (25 MB maximum)")
|
||||
return
|
||||
}
|
||||
|
||||
stored, size, err := s.storeUpload(file)
|
||||
if err != nil {
|
||||
s.internal(w, r, "store upload", err)
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
a := &store.Attachment{
|
||||
NoteID: n.ID,
|
||||
Filename: safeFilename(header.Filename),
|
||||
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
|
||||
Size: size,
|
||||
Path: stored,
|
||||
UploadedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||
s.internal(w, r, "record attachment", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
|
||||
}
|
||||
|
||||
@@ -143,6 +143,19 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// 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
|
||||
// whole feature. Rate limited like the login routes: it is the one endpoint
|
||||
// anybody on the internet can write to, and a row per request is a row per
|
||||
// request whether or not the request is honest.
|
||||
//
|
||||
// Refused with a 404 unless this instance has been told it is the collector,
|
||||
// so an ordinary installation is not a spam sink that answers 200 to
|
||||
// anything.
|
||||
r.Method(http.MethodPost, "/beacon",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeaconPing)))
|
||||
r.Get("/beacon/count", s.handleBeaconCount)
|
||||
|
||||
// Reachable without being signed in — these are how you become signed in.
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Get("/setup", s.handleSetupState)
|
||||
@@ -288,6 +301,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/{noteID}", s.handleGetNote)
|
||||
r.Patch("/{noteID}", s.handleUpdateNote)
|
||||
r.Delete("/{noteID}", s.handleDeleteNote)
|
||||
// A picture pasted or dropped into a note. The import could bring
|
||||
// files in and nothing else could, which made the most ordinary act
|
||||
// in a notes app — paste a screenshot — do nothing at all.
|
||||
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
|
||||
})
|
||||
|
||||
r.Route("/mailboxes", func(r chi.Router) {
|
||||
@@ -390,6 +407,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// `:latest` on the way, which is what makes a restart an update.
|
||||
r.Get("/panel", s.handlePanelStatus)
|
||||
r.Post("/panel/restart", s.handleRestartFromPanel)
|
||||
// Who this instance reports to, and whether it does. An
|
||||
// administrator's decision, not a person's: there is one
|
||||
// installation and it either phones home or it does not.
|
||||
r.Get("/beacon/settings", s.handleBeaconStatus)
|
||||
r.Put("/beacon/settings", s.handleSetBeacon)
|
||||
// A backup file is a complete copy of the database, so this whole
|
||||
// group is sessions-only as well as administrators-only: a leaked
|
||||
// token must not be able to download everybody's data.
|
||||
|
||||
@@ -310,3 +310,61 @@ func cut(text string) string {
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// Unflag takes the flag off the messages that have become tasks.
|
||||
//
|
||||
// This is what stops the same mail arriving twice, and it replaces a marker that
|
||||
// could not be made to hold. The marker was a uid: remember the highest one seen,
|
||||
// start above it next time. That is correct only if uids are what the server says
|
||||
// they are, and it broke in every way such a scheme can — a fetch reply without a
|
||||
// uid, a UIDVALIDITY that changed under a rebuilt mailbox, two mailbox rows
|
||||
// pointing at the same folder and each keeping its own marker. Every one of those
|
||||
// produced the same symptom: the same mail became a task again, and again.
|
||||
//
|
||||
// Unflagging moves the record out of our database and into the mailbox, where the
|
||||
// question is asked. "Flagged" means "turn this into a task"; once it is a task,
|
||||
// the flag has done its job and is not true any more. Nothing has to be
|
||||
// remembered, so there is nothing to get out of step — a second sync, a second
|
||||
// mailbox row, or a database restored from a backup all find the same answer,
|
||||
// because the answer is in the mailbox.
|
||||
//
|
||||
// It is destructive in the small way that matters: it changes something in
|
||||
// somebody's mail. That is the trade, and it is the right way round — a flag is a
|
||||
// note to self that this needs doing, and it has been done.
|
||||
//
|
||||
// The folder is re-selected writable. Since() opens it read-only on purpose, and
|
||||
// a search should not need write access just because a later step might.
|
||||
func (c *Client) Unflag(uids []uint32) error {
|
||||
if len(uids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := c.c.Select(c.folder, nil).Wait(); err != nil {
|
||||
return fmt.Errorf("open %s for writing: %w", c.folder, err)
|
||||
}
|
||||
|
||||
set := imap.UIDSetNum()
|
||||
for _, u := range uids {
|
||||
if u == 0 {
|
||||
// No uid means nothing to address. The message keeps its flag and will
|
||||
// be seen again — which is the failure this whole function exists to
|
||||
// prevent, so it is worth saying rather than skipping quietly.
|
||||
continue
|
||||
}
|
||||
set.AddNum(imap.UID(u))
|
||||
}
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Silent: the server has nothing to tell us that we would act on, and the
|
||||
// untagged replies would only have to be collected and thrown away.
|
||||
cmd := c.c.Store(set, &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsDel,
|
||||
Silent: true,
|
||||
Flags: []imap.Flag{imap.FlagFlagged},
|
||||
}, nil)
|
||||
if err := cmd.Close(); err != nil {
|
||||
return fmt.Errorf("unflag in %s: %w", c.folder, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -316,3 +316,81 @@ func TestNothingAtOrBelowTheMarkerComesBack(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Den samme mail må ikke kunne blive en opgave to gange.
|
||||
//
|
||||
// Prøven kører hele vejen imod en rigtig server: den finder de flagede mails,
|
||||
// tager flaget af dem, og spørger så forfra som en ny synkronisering ville — med
|
||||
// markøren sat til nul, altså det værst tænkelige tilfælde, hvor alt, hvad vi
|
||||
// selv havde husket, er væk. Der skal ikke komme noget tilbage.
|
||||
//
|
||||
// Det er det, markøren ikke kunne love. Den holdt kun, hvis uids var, hvad
|
||||
// serveren sagde, og fejlede på et fetch-svar uden uid, på en UIDVALIDITY, der
|
||||
// skiftede, og på to postkasse-rækker, der pegede samme sted og hver især førte
|
||||
// deres eget regnskab. Alle tre gav det samme: mailen blev en opgave igen.
|
||||
func TestAnUnflaggedMailDoesNotComeBack(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, []mail{
|
||||
{subject: "Faktura 4711", from: "anders@example.dk", body: "Vedhæftet.", flagged: true},
|
||||
{subject: "Nyhedsbrev", from: "noreply@example.com", body: "Læs mere", flagged: false},
|
||||
{subject: "Kontrakt", from: "jura@example.dk", body: "Til underskrift", flagged: true},
|
||||
})
|
||||
|
||||
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
msgs, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("fandt %d flagede mails, ventede 2", len(msgs))
|
||||
}
|
||||
|
||||
uids := make([]uint32, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
uids = append(uids, m.UID)
|
||||
}
|
||||
if err := client.Unflag(uids); err != nil {
|
||||
t.Fatalf("Unflag: %v", err)
|
||||
}
|
||||
|
||||
// Forfra, og fra nul: selv en instans, der intet husker, må ikke finde dem igen.
|
||||
again, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("%d mails kom igen efter at flaget var taget af — den første af dem er %q",
|
||||
len(again), again[0].Subject)
|
||||
}
|
||||
}
|
||||
|
||||
// Et tomt kald må ikke åbne mappen skrivbar for ingenting.
|
||||
func TestUnflagWithNothingToDoIsQuiet(t *testing.T) {
|
||||
addr, pool := serveMailbox(t, []mail{
|
||||
{subject: "Faktura", from: "a@example.dk", body: "x", flagged: true},
|
||||
})
|
||||
client, err := Dial(Account{Host: addr, Username: "kw", Password: "hemmelig", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Unflag(nil); err != nil {
|
||||
t.Errorf("Unflag(nil) = %v", err)
|
||||
}
|
||||
// Kun nuller: der er intet at adressere, og mailen skal beholde sit flag.
|
||||
if err := client.Unflag([]uint32{0, 0}); err != nil {
|
||||
t.Errorf("Unflag(nuller) = %v", err)
|
||||
}
|
||||
msgs, _, err := client.Since(0, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Errorf("mailen mistede sit flag, uden at nogen havde adresseret den")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ type Runner struct {
|
||||
// layer, which owns the VAPID keys and the subscription list.
|
||||
Push func(userID, title, body, projectID string)
|
||||
|
||||
// SendBeacon reports this installation to the collector, if it is switched on
|
||||
// and a day has passed. Supplied by the HTTP layer for the same reason as the
|
||||
// syncs: that is where the instance settings and the version live.
|
||||
SendBeacon func(ctx context.Context) error
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
@@ -69,6 +74,10 @@ func (r *Runner) Start(ctx context.Context) {
|
||||
// account for a to-do app. Ten minutes is fast enough for something somebody
|
||||
// starred and slow enough not to look like abuse.
|
||||
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
|
||||
// Checked hourly, sent at most daily — the same shape as the backup, and for
|
||||
// the same reason: a container asleep at the scheduled minute would otherwise
|
||||
// skip the day. SendBeacon decides whether a day has passed.
|
||||
r.every(ctx, "beacon", time.Hour, r.sendBeacon)
|
||||
}
|
||||
|
||||
func (r *Runner) Wait() { r.wg.Wait() }
|
||||
@@ -103,6 +112,17 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration,
|
||||
}()
|
||||
}
|
||||
|
||||
// sendBeacon reports that this installation exists, at most once a day.
|
||||
//
|
||||
// Every decision about what is sent, and why this is on by default rather than
|
||||
// compulsory, lives in internal/beacon. This is only the clock.
|
||||
func (r *Runner) sendBeacon(ctx context.Context) error {
|
||||
if r.SendBeacon == nil {
|
||||
return nil
|
||||
}
|
||||
return r.SendBeacon(ctx)
|
||||
}
|
||||
|
||||
// --- reminders ------------------------------------------------------------------
|
||||
|
||||
// sendDueReminders delivers everything that has come due since the last pass.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Package safedial refuses to open a connection to this machine or to the
|
||||
// network it sits on.
|
||||
//
|
||||
// The problem it solves is the same one three times: somewhere a user supplies a
|
||||
// URL or a host, and the server fetches it. An AI provider's base address, a push
|
||||
// endpoint, an IMAP host, a beacon collector. Each of them is a feature, and each
|
||||
// of them is also a way to ask the server to reach something the caller cannot —
|
||||
// the panel next door, a metadata endpoint, a database on the same bridge — and
|
||||
// to report back whether it answered. On a homelab that is the whole point of the
|
||||
// box being where it is.
|
||||
//
|
||||
// # Why the check is on the address and not on the URL
|
||||
//
|
||||
// Parsing the URL and refusing "localhost" or "127.0.0.1" is the obvious version
|
||||
// and it does not work: a name resolves to whatever its owner says, so
|
||||
// `evil.example.com` can answer 127.0.0.1 — and can answer differently the second
|
||||
// time, after a parse-time check has passed. That is DNS rebinding, and the only
|
||||
// place it can be caught is after resolution and before the connection, which is
|
||||
// exactly where net.Dialer's Control hook runs.
|
||||
//
|
||||
// # What is refused
|
||||
//
|
||||
// Loopback, link-local (including 169.254.169.254, which is where cloud metadata
|
||||
// lives), the private ranges, unique-local IPv6, and the unspecified address.
|
||||
// Everything else is allowed: this is not an allowlist of the internet, it is a
|
||||
// wall around the inside.
|
||||
package safedial
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrPrivate is returned when a connection was refused for where it pointed. It
|
||||
// deliberately does not say which address it resolved to: the answer is itself
|
||||
// the thing an attacker is fishing for.
|
||||
type ErrPrivate struct{ Host string }
|
||||
|
||||
func (e *ErrPrivate) Error() string {
|
||||
return fmt.Sprintf("refusing to connect to %s: it resolves to an address on this machine or its private network", e.Host)
|
||||
}
|
||||
|
||||
// Blocked reports whether an address must not be dialled.
|
||||
func Blocked(ip net.IP) bool {
|
||||
return ip == nil ||
|
||||
ip.IsLoopback() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsPrivate()
|
||||
}
|
||||
|
||||
// Control is a net.Dialer Control function that refuses a private destination.
|
||||
//
|
||||
// It runs after the name has been resolved and before the socket is connected,
|
||||
// which is the only moment where the address that will actually be used is known.
|
||||
func Control(network, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if Blocked(ip) {
|
||||
return &ErrPrivate{Host: host}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dialer is a net.Dialer that will not reach inside.
|
||||
func Dialer(timeout time.Duration) *net.Dialer {
|
||||
return &net.Dialer{Timeout: timeout, Control: Control}
|
||||
}
|
||||
|
||||
// Client is an http.Client for fetching something a user named.
|
||||
//
|
||||
// Redirects are followed but capped, and every hop goes through the same dialer —
|
||||
// a redirect to 127.0.0.1 is the oldest way around a check that only looked at the
|
||||
// URL somebody typed.
|
||||
func Client(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
DialContext: Dialer(timeout).DialContext,
|
||||
TLSHandshakeTimeout: timeout,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AllowPrivate is the same client without the wall, for a destination the
|
||||
// operator configured rather than a user — the panel, and nothing else. Named so
|
||||
// that using it is a decision somebody wrote down.
|
||||
func AllowPrivate(timeout time.Duration) *http.Client {
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package safedial
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBlockedCoversTheInside(t *testing.T) {
|
||||
blocked := []string{
|
||||
"127.0.0.1", "::1", "0.0.0.0",
|
||||
// Skyens metadata-endepunkt. Den ene adresse, enhver SSRF sigter efter.
|
||||
"169.254.169.254",
|
||||
"10.0.0.5", "172.16.0.1", "192.168.1.10", "fd00::1", "fe80::1",
|
||||
}
|
||||
for _, s := range blocked {
|
||||
if !Blocked(net.ParseIP(s)) {
|
||||
t.Errorf("Blocked(%s) = false", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"93.184.216.34", "8.8.8.8", "2606:2800:220:1:248:1893:25c8:1946"} {
|
||||
if Blocked(net.ParseIP(s)) {
|
||||
t.Errorf("Blocked(%s) = true, men det er en almindelig adresse", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// En httptest-server lytter på loopback, hvilket gør den til den perfekte prøve:
|
||||
// den findes, den svarer, og den må ikke kunne nås.
|
||||
func TestClientRefusesLoopback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("forbindelsen blev oprettet; den skulle være afvist")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Client(3 * time.Second).Get(srv.URL)
|
||||
if err == nil {
|
||||
t.Fatal("en forespørgsel til loopback skal afvises")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowPrivateStillReaches(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := AllowPrivate(3 * time.Second).Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("den skal kunne nå det, operatøren selv har peget på: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The collector's side of the beacon: what has reported in, and how many.
|
||||
//
|
||||
// Every method here is about counting installations, never about identifying one.
|
||||
// There is no lookup by anything but the id an installation made up for itself,
|
||||
// and there is nothing to join it against.
|
||||
|
||||
// BeaconStats is the whole of what the collector can say.
|
||||
type BeaconStats struct {
|
||||
Total int `json:"total"`
|
||||
Active7d int `json:"active_7d"`
|
||||
Active30d int `json:"active_30d"`
|
||||
ByVersion map[string]int `json:"by_version"`
|
||||
LastPingAt *time.Time `json:"last_ping_at,omitempty"`
|
||||
}
|
||||
|
||||
// maxBeaconInstalls bounds the table.
|
||||
//
|
||||
// The receiving endpoint is unauthenticated — it has to be, since the caller is a
|
||||
// stranger's installation — and it inserts a row keyed on an id the caller made
|
||||
// up. Anybody can therefore mint ids and grow this table until the volume the
|
||||
// database lives on is full. A ceiling turns that from a disk-fill into a number
|
||||
// that is merely wrong, which is the difference between an outage and an
|
||||
// annoyance. A hundred thousand is far above any plausible real count and far
|
||||
// below anything that matters on disk.
|
||||
const maxBeaconInstalls = 100_000
|
||||
|
||||
// RecordBeacon writes down that an installation is alive, and on which version.
|
||||
//
|
||||
// `first_seen` is kept from the original row on purpose: it is the only thing
|
||||
// that distinguishes an installation that has been running for a year from one
|
||||
// that appeared this morning, and it costs nothing to keep.
|
||||
func (db *DB) RecordBeacon(ctx context.Context, instanceID, version string) error {
|
||||
now := time.Now().Unix()
|
||||
|
||||
// An installation already known keeps reporting whatever the ceiling says: the
|
||||
// cap must never stop counting the real ones, only stop new rows from being
|
||||
// invented without limit.
|
||||
var known int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM beacon_installs WHERE instance_id = ?`, instanceID).Scan(&known); err != nil {
|
||||
return err
|
||||
}
|
||||
if known == 0 {
|
||||
var total int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM beacon_installs`).Scan(&total); err != nil {
|
||||
return err
|
||||
}
|
||||
if total >= maxBeaconInstalls {
|
||||
// Not an error to the caller. Their installation did nothing wrong, and
|
||||
// there is nothing for them to do about it.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO beacon_installs (instance_id, version, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (instance_id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
last_seen = excluded.last_seen`,
|
||||
instanceID, version, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// BeaconCount is how many installations have reported within the window.
|
||||
//
|
||||
// A window rather than a running total, and that is not a detail. A total only
|
||||
// ever climbs: every instance anybody ever started for ten minutes stays in it
|
||||
// forever, so the number stops meaning "how many people use this" and starts
|
||||
// meaning "how many people have ever tried it". The question being asked is the
|
||||
// first one.
|
||||
func (db *DB) BeaconCount(ctx context.Context, within time.Duration) (int, error) {
|
||||
var n int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM beacon_installs WHERE last_seen >= ?`,
|
||||
time.Now().Add(-within).Unix()).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// BeaconSummary is everything the settings page shows.
|
||||
func (db *DB) BeaconSummary(ctx context.Context) (BeaconStats, error) {
|
||||
out := BeaconStats{ByVersion: map[string]int{}}
|
||||
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM beacon_installs`).Scan(&out.Total); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
var err error
|
||||
if out.Active7d, err = db.BeaconCount(ctx, 7*24*time.Hour); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.Active30d, err = db.BeaconCount(ctx, 30*24*time.Hour); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Versions of the ones still alive, not of everything ever seen: an instance
|
||||
// that stopped reporting two years ago should not still be counted as running
|
||||
// the version it was on when it stopped.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT version, count(*) FROM beacon_installs
|
||||
WHERE last_seen >= ? AND version <> ''
|
||||
GROUP BY version`, time.Now().Add(-30*24*time.Hour).Unix())
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var v string
|
||||
var n int
|
||||
if err := rows.Scan(&v, &n); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.ByVersion[v] = n
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
var last *int64
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT max(last_seen) FROM beacon_installs`).Scan(&last); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if last != nil {
|
||||
t := time.Unix(*last, 0)
|
||||
out.LastPingAt = &t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
-- One row per installation that has ever reported in.
|
||||
--
|
||||
-- This table only exists on the instance that is acting as the collector. Every
|
||||
-- other instance has it too, empty, because the schema is the same everywhere —
|
||||
-- and because "am I the collector" is a setting, not a build.
|
||||
--
|
||||
-- What is deliberately NOT here is the point of the whole feature: there is no
|
||||
-- column for an IP address, a hostname, a domain, an account, a count of tasks,
|
||||
-- or anything else. The two things stored are the two things sent, and the table
|
||||
-- is the promise written down where it cannot drift from the prose — a column
|
||||
-- added here later is a promise broken, and it should be as awkward to add as
|
||||
-- possible.
|
||||
--
|
||||
-- `instance_id` is the primary key rather than a rowid alias, because the same
|
||||
-- installation reporting daily must update its row rather than grow a new one.
|
||||
-- What is wanted is "how many installations", not "how many pings".
|
||||
CREATE TABLE beacon_installs (
|
||||
instance_id TEXT PRIMARY KEY,
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- The count is always "seen in the last N days", so last_seen is what every query
|
||||
-- sorts and filters on.
|
||||
CREATE INDEX beacon_by_last_seen ON beacon_installs (last_seen DESC);
|
||||
@@ -621,13 +621,13 @@
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/today-light.webp"
|
||||
alt="The same Today view in the light theme."
|
||||
src="screenshots/notes.webp"
|
||||
alt="Notes: a list of notes on the left, one open on the right with headings, bullet lists, a quotation and a shell command in a code block."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>The light theme.</figcaption>
|
||||
<figcaption>Notes. Rich text on top, Markdown underneath.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
@@ -642,7 +642,7 @@
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/detail.webp"
|
||||
alt="A task open in the detail drawer, with its description, due date, labels, sub-tasks and comments."
|
||||
alt="A task open in the detail drawer, with its description, priority, due date, labels, sub-tasks and an attached file."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
@@ -652,12 +652,12 @@
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/themes.webp"
|
||||
alt="The settings page showing the five themes as swatches."
|
||||
alt="The settings page showing five themes as swatches and, below them, four looks written in the typeface each one selects."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Five themes, all of them measured for contrast.</figcaption>
|
||||
<figcaption>Five themes and four looks: how bright, and how it reads.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
@@ -689,6 +689,16 @@
|
||||
invite link. Changes appear live.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Notes, beside the work</h3>
|
||||
<p>
|
||||
Rich text to write in, Markdown on disk. File a note in a project and
|
||||
everybody in it can read it; <code>#project</code> and
|
||||
<code>[[another note]]</code> are links, and a task shows what has been
|
||||
written about it. A zip of Markdown goes in and comes back out the same
|
||||
shape, so nothing is trapped here.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>A real CalDAV server</h3>
|
||||
<p>
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
@@ -5,7 +5,7 @@
|
||||
// cookie. Kept in the repository so the images can be regenerated when the
|
||||
// interface changes, rather than being a thing somebody did once by hand.
|
||||
//
|
||||
// go run ./tools/shots -url http://localhost:8096 -cookie <session> -out site/screenshots
|
||||
// go run ./tools/shots -url http://localhost:8096 -cookie <session> -out docs/screenshots
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -28,7 +28,9 @@ var chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
func main() {
|
||||
base := flag.String("url", "http://localhost:8096", "the running instance")
|
||||
cookie := flag.String("cookie", "", "session cookie value")
|
||||
out := flag.String("out", "site/screenshots", "where to write the PNGs")
|
||||
// docs/, not site/: site/screenshots holds the WebP the landing page reads, and
|
||||
// PNGs dropped in beside them are files nothing points at.
|
||||
out := flag.String("out", "docs/screenshots", "where to write the PNGs")
|
||||
width := flag.Int("width", 1400, "viewport width")
|
||||
height := flag.Int("height", 900, "viewport height")
|
||||
flag.Parse()
|
||||
|
||||
@@ -655,7 +655,15 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
|
||||
|
||||
await page.getByLabel('Ny opgave').fill('males inden fredag #Fælleshuset');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await page.getByText('males inden fredag', { exact: true }).click();
|
||||
// Titlen alene, ikke rækken med datomærket i.
|
||||
//
|
||||
// Hurtig tilføjelse læser "fredag" som forfaldsdatoen og tager ordet ud af
|
||||
// titlen, så rækken er "males inden" plus et mærke, der siger hvornår. Mærkets
|
||||
// ord skifter med ugedagen — "fredag", når der er langt, og "I morgen", når man
|
||||
// kører prøven på en torsdag. Den her linje ledte efter hele rækken og fandt
|
||||
// den derfor ikke én dag om ugen. Prøven handler om invitationer, ikke om
|
||||
// datoer; den skal ikke vide hvilken dag det er.
|
||||
await page.getByText('males inden', { exact: true }).click();
|
||||
|
||||
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
|
||||
@@ -2388,3 +2396,106 @@ test('lister overlever en gemning — også i et citat, og også med numre', asy
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en note kan ikke smugle et script ind gennem et billedes alt-tekst', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// Undslap `alt` ikke sine anførselstegn, lukkede den første af dem attributten,
|
||||
// og resten stod som markup: et img med en onerror på. En note deles gennem et
|
||||
// projekt, så det ville køre på vores eget domæne, hos den der åbnede noten.
|
||||
const id = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/v1/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify({
|
||||
body: '# Ondsindet\n\n\n'
|
||||
})
|
||||
});
|
||||
return (await r.json()).id;
|
||||
});
|
||||
|
||||
await page.goto('/noter?note=' + id);
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(ed.locator('img')).toHaveCount(1);
|
||||
// Billedet peger ingen steder, så det fejler at indlæse — hvilket er præcis
|
||||
// den vej, en onerror ville blive kaldt ad.
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
expect(await page.evaluate(() => window.__pwned)).toBeUndefined();
|
||||
// Og hændelsen står som tekst i alt-attributten, hvor den hører hjemme.
|
||||
const alt = await ed.locator('img').getAttribute('alt');
|
||||
expect(alt).toContain('onerror');
|
||||
expect(await ed.locator('img').evaluate((el) => el.hasAttribute('onerror'))).toBe(false);
|
||||
|
||||
// Billedets egen 404 hører til prøven: adressen peger med vilje på et bilag,
|
||||
// der ikke findes, fordi det er den vej en onerror ville blive kaldt ad. Alt
|
||||
// andet skal stadig være stille.
|
||||
expect(trouble.filter((t) => !t.includes('/api/v1/attachments/'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('et billede kan indsættes og trækkes ind i en note', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
const id = await page.evaluate(async () => {
|
||||
const r = await fetch('/api/v1/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify({ body: '# Med billeder\n\nEn linje.\n' })
|
||||
});
|
||||
return (await r.json()).id;
|
||||
});
|
||||
await page.goto('/noter?note=' + id);
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(ed).toBeVisible();
|
||||
|
||||
// Et rigtigt lille PNG, bygget i browseren, så prøven ikke har brug for en fil
|
||||
// på disken — og lagt i både en paste og et drop, som er de to veje ind.
|
||||
const png =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
|
||||
for (const kind of ['paste', 'drop']) {
|
||||
await ed.click();
|
||||
await page.evaluate(
|
||||
async ({ kind, png }) => {
|
||||
const bytes = Uint8Array.from(atob(png), (c) => c.charCodeAt(0));
|
||||
const file = new File([bytes], `${kind}.png`, { type: 'image/png' });
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
const ed = document.querySelector('.page[contenteditable]');
|
||||
ed.focus();
|
||||
ed.dispatchEvent(
|
||||
kind === 'paste'
|
||||
? new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
|
||||
: new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true })
|
||||
);
|
||||
},
|
||||
{ kind, png }
|
||||
);
|
||||
await page.waitForTimeout(1200);
|
||||
}
|
||||
|
||||
// To billeder, begge hentet og tegnet — ikke to stykker tekst med ).toHaveCount(2);
|
||||
const loaded = await ed.locator('img').evaluateAll((els) =>
|
||||
els.filter((e) => e.complete && e.naturalWidth > 0).length
|
||||
);
|
||||
expect(loaded).toBe(2);
|
||||
|
||||
// Og de overlever turen til Markdown: filen skal pege på et bilag, ikke på en
|
||||
// data-URL, som ville lægge fotografiet i noteteksten.
|
||||
const body = await page.evaluate(async (noteId) => {
|
||||
const r = await fetch('/api/v1/notes/' + noteId, {
|
||||
credentials: 'include',
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' }
|
||||
});
|
||||
return (await r.json()).body;
|
||||
}, id);
|
||||
expect(body.match(/!\[\]\(\/api\/v1\/attachments\/[0-9a-f-]+\)/g)).toHaveLength(2);
|
||||
expect(body).not.toContain('data:image');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -9,17 +9,22 @@
|
||||
* It replaces the hand-driven half of `go run ./tools/shots`, which needs a
|
||||
* running instance and a session cookie pasted in by hand — and which therefore
|
||||
* ran rarely enough that the images fell many rounds behind the app they show.
|
||||
* This one starts the real binary, creates its own account, fills it with the
|
||||
* demo data, and takes the pictures. Nothing to set up and nothing to remember.
|
||||
* This one starts the real binary, creates its own two accounts, fills them with
|
||||
* the demo data, and takes the pictures. Nothing to set up and nothing to remember.
|
||||
*
|
||||
* The binary, not the dev server: the screenshots should show what ships. That
|
||||
* means building the frontend and copying it into cmd/verdande/webbuild first,
|
||||
* exactly as the smoke tests do — a capture of a stale embedded build would be a
|
||||
* picture of an app nobody is running.
|
||||
*
|
||||
* English, because the people who read the landing page and the docs read English.
|
||||
* Not by translating anything here: the account is created with an English locale
|
||||
* and the app is then in English by itself, which also means a string nobody
|
||||
* translated shows up in a picture instead of hiding until a user finds it.
|
||||
*/
|
||||
import { chromium } from '@playwright/test';
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { mkdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
@@ -31,13 +36,21 @@ const dataDir = join(web, '.screenshot-data');
|
||||
const PORT = 8098;
|
||||
const baseURL = `http://127.0.0.1:${PORT}`;
|
||||
const USER = { name: 'Kristian', email: 'kristian@example.dk', password: 'et langt kodeord' };
|
||||
// Somebody to share with. Half of what the app is for only exists once there are
|
||||
// two people in it: an assignee on a task, a name in the history, a face in the
|
||||
// members list, and "Waiting on others" as something other than an empty page.
|
||||
const COLLEAGUE = { name: 'Mette', email: 'mette@example.dk', password: 'et andet langt kodeord' };
|
||||
|
||||
// 1400×900 is what the landing page's <img> declares. A capture at another size
|
||||
// would be resampled by the browser and look soft next to the text beside it.
|
||||
const WIDTH = 1400;
|
||||
const HEIGHT = 900;
|
||||
|
||||
const OUT = [join(repo, 'site', 'screenshots'), join(repo, 'docs', 'screenshots')];
|
||||
// The docs and the README read the PNGs; the landing page reads the WebP, which is
|
||||
// a fifth of the weight over the wire. Both are written from the same capture, so
|
||||
// the two sets can never drift apart.
|
||||
const PNG_DIR = join(repo, 'docs', 'screenshots');
|
||||
const WEBP_DIR = join(repo, 'site', 'screenshots');
|
||||
|
||||
/**
|
||||
* The pictures, and why each one is here.
|
||||
@@ -54,31 +67,64 @@ const SHOTS = [
|
||||
// The same project as a list, which is where sections read as bands rather than
|
||||
// as columns — and the shape most people actually work in.
|
||||
{ name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList },
|
||||
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
|
||||
// By address rather than by clicking the first row: the drawer has to be over
|
||||
// the one task that was given a description, sub-tasks, a file, a reminder and a
|
||||
// conversation, or the picture shows an empty form and proves nothing.
|
||||
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
|
||||
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
|
||||
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
|
||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
|
||||
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
||||
{ name: 'themes', path: '/indstillinger', theme: 'dark' },
|
||||
// Scrolled, because the themes are the second panel on the page and a picture
|
||||
// captioned "five themes" that shows the profile form is a caption about
|
||||
// nothing.
|
||||
{ name: 'themes', path: '/indstillinger', theme: 'dark', prepare: showThemes },
|
||||
{ name: 'admin', path: '/indstillinger/brugere', theme: 'dark' },
|
||||
{ name: 'history', path: '/indstillinger/historik', theme: 'dark' },
|
||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
|
||||
{ name: 'history', path: '/indstillinger/historik', theme: 'dark' }
|
||||
];
|
||||
|
||||
const NOTE_TITLE = 'Launch meeting — agenda';
|
||||
|
||||
async function showList(page) {
|
||||
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'List', exact: true }).click();
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
async function showMonth(page) {
|
||||
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Month', exact: true }).click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
async function showWeek(page) {
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Week', exact: true }).click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
async function openFirstTask(page) {
|
||||
await page.locator('.row').first().click();
|
||||
async function openMeetingNote(page) {
|
||||
// By text inside the row rather than by its accessible name: a row's name is its
|
||||
// title, its date, the first line of its body and the project it is filed in,
|
||||
// all run together.
|
||||
await page.locator('button.row', { hasText: NOTE_TITLE }).first().click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
async function showNoteSource(page) {
|
||||
await openMeetingNote(page);
|
||||
await page.getByRole('button', { name: 'Show as code' }).click();
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
async function showThemes(page) {
|
||||
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
|
||||
// already a few pixels inside the viewport when the page loads, so "if needed"
|
||||
// decides nothing is needed and the picture is of the profile form above it.
|
||||
//
|
||||
// `.first()`, because the looks below the themes carry the same heading — one
|
||||
// panel, two questions, and both of them called Appearance.
|
||||
await page
|
||||
.getByRole('heading', { name: 'Appearance' })
|
||||
.first()
|
||||
.evaluate((el) => el.closest('section').scrollIntoView({ block: 'start' }));
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
@@ -114,29 +160,42 @@ let browser;
|
||||
try {
|
||||
await waitForHealth();
|
||||
|
||||
browser = await chromium.launch();
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: WIDTH, height: HEIGHT },
|
||||
// The same pinning the smoke tests use, and for the same reason: the sign-up
|
||||
// form sends navigator.language as the account's locale, and every due date
|
||||
// is resolved in the account's zone.
|
||||
locale: 'da-DK',
|
||||
timezoneId: 'Europe/Copenhagen',
|
||||
deviceScaleFactor: 2
|
||||
});
|
||||
// The browser's own language, which is not the same setting as the context's.
|
||||
// `<input type="date">` is drawn by Chromium, not by the app, and it takes its
|
||||
// order from this one — so without it the drawer shows an American date inside
|
||||
// a day-month app, which is the exact thing i18n.svelte.js warns about.
|
||||
browser = await chromium.launch({ args: ['--lang=en-GB'] });
|
||||
const context = await browser.newContext(viewing());
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(baseURL);
|
||||
// The sign-up form is the one screen that is always Danish: it is drawn before
|
||||
// there is an account to read a language off, and Danish is the fallback. So
|
||||
// these labels are Danish on purpose, and the app is English from the moment
|
||||
// the account exists — which is every screen this script photographs.
|
||||
await page.getByLabel(/Navn/).fill(USER.name);
|
||||
await page.getByLabel(/E-mail/).fill(USER.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(USER.password);
|
||||
await page.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await page.getByRole('navigation', { name: 'Hovedmenu' }).waitFor();
|
||||
await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
|
||||
|
||||
console.log('seeding…');
|
||||
const ids = await page.evaluate(seed);
|
||||
const ids = await page.evaluate(seed, { colleague: COLLEAGUE.email, noteTitle: NOTE_TITLE });
|
||||
|
||||
mkdirSync(OUT[0], { recursive: true });
|
||||
mkdirSync(OUT[1], { recursive: true });
|
||||
console.log('adding the second person…');
|
||||
const ids2 = await addColleague(browser, ids);
|
||||
Object.assign(ids, ids2);
|
||||
await page.reload();
|
||||
await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
|
||||
|
||||
mkdirSync(PNG_DIR, { recursive: true });
|
||||
mkdirSync(WEBP_DIR, { recursive: true });
|
||||
|
||||
// A page of its own for the WebP encoding, on about:blank: the app's pages send
|
||||
// a content security policy, and an encoder that borrows whichever page was last
|
||||
// photographed would be one policy change away from silently producing nothing.
|
||||
const encoder = await context.newPage();
|
||||
await encoder.goto('about:blank');
|
||||
|
||||
for (const shot of SHOTS) {
|
||||
const path = typeof shot.path === 'function' ? shot.path(ids) : shot.path;
|
||||
@@ -151,9 +210,9 @@ try {
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
const png = await page.screenshot();
|
||||
writeFileSync(join(OUT[0], `${shot.name}.png`), png);
|
||||
copyFileSync(join(OUT[0], `${shot.name}.png`), join(OUT[1], `${shot.name}.png`));
|
||||
console.log(` ${shot.name}.png`);
|
||||
writeFileSync(join(PNG_DIR, `${shot.name}.png`), png);
|
||||
writeFileSync(join(WEBP_DIR, `${shot.name}.webp`), await toWebP(encoder, png));
|
||||
console.log(` ${shot.name}`);
|
||||
}
|
||||
} finally {
|
||||
await browser?.close();
|
||||
@@ -166,6 +225,25 @@ try {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** The browser settings every context here shares. */
|
||||
function viewing() {
|
||||
return {
|
||||
viewport: { width: WIDTH, height: HEIGHT },
|
||||
// The locale is not a formatting preference: the sign-up form sends
|
||||
// navigator.language as the account's language, so this is what makes the
|
||||
// interface, the Inbox's name and every date English. The zone is pinned for
|
||||
// the same reason the smoke tests pin it — every due date is resolved in it.
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'Europe/Copenhagen',
|
||||
// Dark, because that is what the shots are forced to below. app.html reads
|
||||
// prefers-color-scheme before first paint, and Playwright's default is light
|
||||
// — which left the settings page rendering dark with the *light* theme
|
||||
// ringed as the chosen one.
|
||||
colorScheme: 'dark',
|
||||
deviceScaleFactor: 2
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForHealth() {
|
||||
for (let i = 0; i < 240; i++) {
|
||||
try {
|
||||
@@ -179,6 +257,92 @@ async function waitForHealth() {
|
||||
throw new Error('the server never came up');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a PNG as WebP, in the browser that is already open.
|
||||
*
|
||||
* Chromium encodes WebP itself, so the alternative — shelling out to `cwebp` —
|
||||
* would be something to install first, in a script whose whole point is that there
|
||||
* is nothing to install. Resized on the way through: the landing page shows these
|
||||
* 1400 CSS pixels wide, and a full-resolution WebP is twice the bytes for detail
|
||||
* no screen renders.
|
||||
*/
|
||||
async function toWebP(encoder, png) {
|
||||
const base64 = await encoder.evaluate(
|
||||
async ({ data, width, quality }) => {
|
||||
const image = new Image();
|
||||
image.src = `data:image/png;base64,${data}`;
|
||||
await image.decode();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = Math.round((image.naturalHeight / image.naturalWidth) * width);
|
||||
const ctx = canvas.getContext('2d');
|
||||
// The default resampler leaves the interface's hairlines ragged, which on a
|
||||
// screenshot of a screenshot is the first thing the eye finds.
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/webp', quality).split(',')[1];
|
||||
},
|
||||
// 0.75 is cwebp's default and where these stop getting smaller for anything
|
||||
// the eye finds: 0.85 is a fifth more bytes for a difference that survives
|
||||
// only under magnification, and the landing page's first image is the one
|
||||
// somebody waits for.
|
||||
{ data: png.toString('base64'), width: 1800, quality: 0.75 }
|
||||
);
|
||||
return Buffer.from(base64, 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs the second person up through the invite the seed left behind, has them say
|
||||
* something, and hands their work back to the first.
|
||||
*
|
||||
* A context of its own because a session is a cookie: signing up in the page the
|
||||
* screenshots are taken from would replace the account they are taken as.
|
||||
*/
|
||||
async function addColleague(browser, ids) {
|
||||
const context = await browser.newContext(viewing());
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await page.goto(ids.invite);
|
||||
// Danish labels again, and for the same reason: nobody is signed in yet.
|
||||
await page.getByLabel(/Navn/).fill(COLLEAGUE.name);
|
||||
await page.getByLabel(/Adgangskode/).fill(COLLEAGUE.password);
|
||||
await page.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
|
||||
return await page.evaluate(handOver, ids);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the second person does, from inside their own session: answer the task they
|
||||
* were opened on, and take the three they are being waited for.
|
||||
*
|
||||
* The assignments are made here rather than by the owner because an editor may
|
||||
* assign, and doing it from this side proves it. Written as one function because it
|
||||
* is passed to the browser as source.
|
||||
*/
|
||||
async function handOver(ids) {
|
||||
const api = async (path, body, method = 'POST') => {
|
||||
const r = await fetch(`/api/v1${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
|
||||
return r.status === 204 ? null : r.json();
|
||||
};
|
||||
|
||||
const me = await api('/auth/me', null, 'GET');
|
||||
for (const id of ids.delegate) await api(`/tasks/${id}`, { assignee_id: me.id }, 'PATCH');
|
||||
|
||||
await api(`/tasks/${ids.detail}/comments`, {
|
||||
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
|
||||
});
|
||||
return { colleague: me.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* The demo data, created through the app's own API from inside the page.
|
||||
*
|
||||
@@ -186,11 +350,11 @@ async function waitForHealth() {
|
||||
* header the API insists on come along without being reconstructed here. Written
|
||||
* as one function because it is passed to the browser as source.
|
||||
*
|
||||
* The content is ordinary Danish household and work tasks. Screenshots of "Task
|
||||
* 1, Task 2, Task 3" show the layout and hide whether the app is any good at the
|
||||
* thing it is for.
|
||||
* Ordinary English household and work tasks, the same shape the Danish set had.
|
||||
* Screenshots of "Task 1, Task 2, Task 3" show the layout and hide whether the app
|
||||
* is any good at the thing it is for.
|
||||
*/
|
||||
async function seed() {
|
||||
async function seed({ colleague, noteTitle }) {
|
||||
const api = async (path, body, method = 'POST') => {
|
||||
const r = await fetch(`/api/v1${path}`, {
|
||||
method,
|
||||
@@ -210,63 +374,199 @@ async function seed() {
|
||||
};
|
||||
|
||||
// Two headings over the projects, which is what the sidebar grew for.
|
||||
const arbejde = await api('/project-groups', { name: 'Arbejde' });
|
||||
const privat = await api('/project-groups', { name: 'Privat' });
|
||||
await api(`/project-groups/${arbejde.id}`, { color: 'indigo' }, 'PATCH');
|
||||
await api(`/project-groups/${privat.id}`, { color: 'green' }, 'PATCH');
|
||||
const work = await api('/project-groups', { name: 'Work' });
|
||||
const home = await api('/project-groups', { name: 'Home' });
|
||||
await api(`/project-groups/${work.id}`, { color: 'indigo' }, 'PATCH');
|
||||
await api(`/project-groups/${home.id}`, { color: 'green' }, 'PATCH');
|
||||
|
||||
const work = await api('/projects', {
|
||||
name: 'Sæsonstart',
|
||||
const launch = await api('/projects', {
|
||||
name: 'Autumn launch',
|
||||
color: 'indigo',
|
||||
view_mode: 'board',
|
||||
group_id: arbejde.id
|
||||
group_id: work.id
|
||||
});
|
||||
const house = await api('/projects', { name: 'Huset', color: 'amber', group_id: privat.id });
|
||||
const garden = await api('/projects', { name: 'Haven', color: 'green', group_id: privat.id });
|
||||
const house = await api('/projects', { name: 'House', color: 'amber', group_id: home.id });
|
||||
const garden = await api('/projects', { name: 'Garden', color: 'green', group_id: home.id });
|
||||
|
||||
const planning = await api(`/projects/${work.id}/sections`, { name: 'Planlægning' });
|
||||
const inFlight = await api(`/projects/${work.id}/sections`, { name: 'I gang' });
|
||||
const done = await api(`/projects/${work.id}/sections`, { name: 'Til gennemsyn' });
|
||||
const planning = await api(`/projects/${launch.id}/sections`, { name: 'Planning' });
|
||||
const inFlight = await api(`/projects/${launch.id}/sections`, { name: 'In progress' });
|
||||
const review = await api(`/projects/${launch.id}/sections`, { name: 'Up for review' });
|
||||
|
||||
const tasks = [
|
||||
// One thing that slipped. Today with an overdue band on it is what a Thursday
|
||||
// actually looks like, and it is the only way to photograph the band at all.
|
||||
{
|
||||
content: 'Chase the printer about the labels',
|
||||
due_date: day(-2),
|
||||
priority: 2,
|
||||
project_id: launch.id,
|
||||
section_id: inFlight.id,
|
||||
labels: ['waiting']
|
||||
},
|
||||
|
||||
// Today, in the Inbox and across the projects: the Today view is the first
|
||||
// picture and it has to look like a real day.
|
||||
{ content: 'Ring til tømreren om taget', due_date: day(0), priority: 1, project_id: house.id },
|
||||
{ content: 'Aflever cyklen til service', due_date: day(0), priority: 3, labels: ['ærinder'] },
|
||||
{ content: 'Call the roofer about the ridge tiles', due_date: day(0), priority: 1, project_id: house.id },
|
||||
{
|
||||
content: 'Skriv oplæg til sæsonmødet',
|
||||
content: 'Write the opening notes for the launch meeting',
|
||||
due_date: day(0),
|
||||
priority: 2,
|
||||
project_id: work.id,
|
||||
project_id: launch.id,
|
||||
section_id: planning.id,
|
||||
duration_min: 90
|
||||
duration_min: 90,
|
||||
labels: ['meeting']
|
||||
},
|
||||
{ content: 'Vand drivhuset', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
|
||||
{ content: 'Hent pakken på posthuset', due_date: day(0), labels: ['ærinder'] },
|
||||
{ content: 'Drop the bike off for its service', due_date: day(0), priority: 3, labels: ['errands'] },
|
||||
{ content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
|
||||
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
|
||||
|
||||
// The next couple of weeks, so Kommende and the month grid have something in
|
||||
// The next couple of weeks, so Upcoming and the month grid have something in
|
||||
// them rather than a row of empty cells.
|
||||
{ content: 'Book håndværker til badeværelset', due_date: day(1), priority: 2, project_id: house.id },
|
||||
{ content: 'Beskær den grønne hæk', due_date: day(2), project_id: garden.id },
|
||||
{ content: 'Gennemgå budgettet', due_date: day(3), priority: 1, project_id: work.id, section_id: inFlight.id },
|
||||
{ content: 'Bestil løg til efteråret', due_date: day(5), project_id: garden.id },
|
||||
{ content: 'Skift dæk', due_date: day(8), priority: 3 },
|
||||
{ content: 'Send referat ud', due_date: day(9), project_id: work.id, section_id: done.id },
|
||||
{ content: 'Male vindueskarmene', due_date: day(12), project_id: house.id, priority: 2 },
|
||||
{ content: 'Så vinterurter', due_date: day(15), project_id: garden.id },
|
||||
{ content: 'Kvartalsopfølgning', due_date: day(21), priority: 1, project_id: work.id, section_id: planning.id },
|
||||
{ content: 'Rens tagrenderne', due_date: day(26), project_id: house.id },
|
||||
{ content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
|
||||
{ content: 'Cut the beech hedge back', due_date: day(2), project_id: garden.id },
|
||||
{ content: 'Go through the budget', due_date: day(3), priority: 1, project_id: launch.id, section_id: inFlight.id },
|
||||
{ content: 'Order bulbs for the autumn planting', due_date: day(5), project_id: garden.id },
|
||||
{ content: 'Swap the winter tyres on', due_date: day(8), priority: 3 },
|
||||
{ content: 'Send the minutes round', due_date: day(9), project_id: launch.id, section_id: review.id },
|
||||
{ content: 'Paint the window sills', due_date: day(12), project_id: house.id, priority: 2 },
|
||||
{ content: 'Sow winter herbs in the cold frame', due_date: day(15), project_id: garden.id },
|
||||
{ content: 'Quarterly review', due_date: day(21), priority: 1, project_id: launch.id, section_id: planning.id },
|
||||
{ content: 'Clear the gutters before the autumn rain', due_date: day(26), project_id: house.id },
|
||||
|
||||
// A few without dates, so the board has cards that are not all deadlines.
|
||||
{ content: 'Find ny leverandør af emballage', project_id: work.id, section_id: planning.id, priority: 3 },
|
||||
{ content: 'Skriv stillingsopslag', project_id: work.id, section_id: inFlight.id },
|
||||
{ content: 'Opdater prislisten', project_id: work.id, section_id: done.id, labels: ['gennemsyn'] }
|
||||
{ content: 'Find a new packaging supplier', project_id: launch.id, section_id: planning.id, priority: 3 },
|
||||
{ content: 'Write the job advert', project_id: launch.id, section_id: inFlight.id },
|
||||
{ content: 'Update the price list', project_id: launch.id, section_id: review.id, labels: ['review'] }
|
||||
];
|
||||
for (const task of tasks) await api('/tasks', task);
|
||||
const made = {};
|
||||
for (const task of tasks) made[task.content] = await api('/tasks', task);
|
||||
|
||||
// A saved filter, because the sidebar shows them and an empty heading reads as
|
||||
// a feature that does not work.
|
||||
await api('/filters', { name: 'Vigtigt i denne uge', query: 'p1 & 7 days' });
|
||||
await api('/filters', { name: 'Important this week', query: 'p1 & 7 days' });
|
||||
|
||||
return { work: work.id, house: house.id, garden: garden.id };
|
||||
// The one task that is photographed open. Everything the drawer can hold, so the
|
||||
// picture answers "what is in there" rather than showing an empty form.
|
||||
const detail = made['Write the opening notes for the launch meeting'];
|
||||
await api(
|
||||
`/tasks/${detail.id}`,
|
||||
{
|
||||
description:
|
||||
'Fifteen minutes, no slides. What shipped over the summer, what is left, and what we are deliberately not doing before the first.'
|
||||
},
|
||||
'PATCH'
|
||||
);
|
||||
for (const content of [
|
||||
'Pull the numbers from last season',
|
||||
'Ask Mette for the packaging quotes',
|
||||
'Book the room'
|
||||
]) {
|
||||
await api('/tasks', { content, parent_id: detail.id, project_id: launch.id });
|
||||
}
|
||||
await api(`/tasks/${detail.id}/reminders`, { offset_min: 60 });
|
||||
await api(`/tasks/${detail.id}/comments`, {
|
||||
body: 'Keeping it to three points. Anything else goes on the list for the week after.'
|
||||
});
|
||||
|
||||
// A file on the task, because "comments, attachments" is a claim and an empty
|
||||
// Files heading is the opposite of proof.
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([new Uint8Array(9400)], { type: 'application/pdf' }), 'packaging-quote.pdf');
|
||||
await fetch(`/api/v1/tasks/${detail.id}/attachments`, { method: 'POST', body: form });
|
||||
|
||||
// The notes, newest last: the list opens on what was touched most recently, so
|
||||
// the one the screenshots open is the one written last.
|
||||
const notes = [
|
||||
{
|
||||
body: `# Reading list
|
||||
|
||||
- *Shape Up* — the six-week bit, not the pitch bit
|
||||
- The SQLite WAL paper, again
|
||||
- Anything at all about pricing`
|
||||
},
|
||||
{
|
||||
project_id: garden.id,
|
||||
body: `# The bulb order
|
||||
|
||||
Two hundred, split three ways, in before the first frost:
|
||||
|
||||
1. Narcissus along the south wall
|
||||
2. Alliums in the bed by the shed
|
||||
3. Crocus wherever there is room
|
||||
|
||||
The bed by the shed is heavy — grit in the hole, or they rot. #Garden`
|
||||
},
|
||||
{
|
||||
body: `# What the roofer said
|
||||
|
||||
Ridge tiles have moved, maybe a metre of them. Not urgent, not something to leave
|
||||
over a winter either.
|
||||
|
||||
- Quote by the end of the month
|
||||
- Two days' work, scaffold on the north side
|
||||
- Ask whether the gutters can be done at the same time
|
||||
|
||||
#House`
|
||||
},
|
||||
{
|
||||
project_id: launch.id,
|
||||
body: `# Price list — what changes
|
||||
|
||||
Nothing has been looked at since March.
|
||||
|
||||
- 250 g bag: **79 → 85**
|
||||
- 1 kg bag: **265 → 279**
|
||||
- Subscription: **199 → 209**
|
||||
|
||||
Subscribers keep the old price for three months. That is the whole of the goodwill
|
||||
budget and it is worth it.`
|
||||
},
|
||||
{
|
||||
pinned: true,
|
||||
project_id: launch.id,
|
||||
body: `# ${noteTitle}
|
||||
|
||||
Thursday, fifteen minutes, no slides.
|
||||
|
||||
## What shipped over the summer
|
||||
|
||||
- The new packaging, minus the labels
|
||||
- A CalDAV feed nobody asked for and everybody uses
|
||||
|
||||
## What is left
|
||||
|
||||
- Labels. Still with the printer, chased on Thursday
|
||||
- The price list: [[Price list — what changes]]
|
||||
|
||||
> If it is not on the list by Friday it is not in the launch.
|
||||
|
||||
The build we cut the release from:
|
||||
|
||||
\`\`\`bash
|
||||
cd web && npm run build && cd ..
|
||||
go build -tags embedweb -o verdande ./cmd/verdande
|
||||
\`\`\`
|
||||
|
||||
Room is booked. Coffee is **not** my job this week.
|
||||
|
||||
The opening notes are a task, so they can be ticked off: task:${detail.id}`
|
||||
}
|
||||
];
|
||||
for (const note of notes) await api('/notes', note);
|
||||
|
||||
// Shared with somebody, and an invite left standing to the instance itself: two
|
||||
// of the three states the members page has, and the one it never showed before.
|
||||
const invite = await api(`/projects/${launch.id}/invites`, { email: colleague, role: 'editor' });
|
||||
await api('/users', { email: 'anders@example.dk' });
|
||||
|
||||
return {
|
||||
work: launch.id,
|
||||
detail: detail.id,
|
||||
invite: invite.link,
|
||||
delegate: [
|
||||
made['Send the minutes round'].id,
|
||||
made['Update the price list'].id,
|
||||
made['Find a new packaging supplier'].id
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,6 +227,9 @@ export const api = {
|
||||
gmailClient: () => get('/gmail/client'),
|
||||
setGmailClient: (data) => put('/gmail/client', data),
|
||||
|
||||
beacon: () => get('/beacon/settings'),
|
||||
setBeacon: (body) => put('/beacon/settings', body),
|
||||
|
||||
listBackups: () => get('/backups'),
|
||||
runBackup: () => post('/backups'),
|
||||
// A link rather than a fetch: the browser's own download handles a file of
|
||||
@@ -334,6 +337,7 @@ export const api = {
|
||||
deleteComment: (id) => del(`/comments/${id}`),
|
||||
|
||||
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
|
||||
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
|
||||
deleteAttachment: (id) => del(`/attachments/${id}`),
|
||||
attachmentURL: (id) => `/api/v1/attachments/${id}`,
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
// The unsectioned column comes first, as it does in the list view: it is where
|
||||
// quick add puts things, so it is where new work appears.
|
||||
let columns = $derived([
|
||||
{ id: '', name: 'Uden sektion' },
|
||||
{ id: '', name: t('project.noSection') },
|
||||
...sections.map((s) => ({ id: s.id, name: s.name }))
|
||||
]);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { highlight, guessLanguage } from '$lib/highlight.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
|
||||
let { note, onchange, onsave } = $props();
|
||||
@@ -399,12 +400,92 @@
|
||||
|
||||
// Pasted text arrives as whatever the source was — a web page brings its fonts,
|
||||
// its colours and its layout with it. Only the words are wanted.
|
||||
/**
|
||||
* Filer ind i noten — indsat eller trukket.
|
||||
*
|
||||
* Importen kunne tage billeder med, og intet andet kunne. At indsætte et
|
||||
* skærmbillede i en note er det mest almindelige, der findes i et noteprogram,
|
||||
* og det gjorde ingenting overhovedet: indsætningen tog kun `text/plain` og
|
||||
* smed resten væk, og der var slet ingen håndtering af et træk.
|
||||
*
|
||||
* Filen lægges op og skrives ind som ``. Ikke som en data-URL: en note
|
||||
* er Markdown i en database, og et fotografi fra en telefon som base64 midt i
|
||||
* teksten ville gøre noten til fire megabyte tekst, som skal læses hver gang
|
||||
* listen tegnes.
|
||||
*
|
||||
* En note, der ikke er gemt endnu, kan ikke bære en fil — den har intet id at
|
||||
* hænge den på. Derfor gemmes den først, og det siges ikke til nogen, fordi
|
||||
* det er en teknisk detalje og ikke noget, brugeren har bedt om at vide.
|
||||
*/
|
||||
async function insertFiles(files) {
|
||||
const list = [...files].filter((f) => f && f.size > 0);
|
||||
if (!list.length || !note?.id) return false;
|
||||
|
||||
uploading = true;
|
||||
try {
|
||||
for (const file of list) {
|
||||
const saved = await api.uploadNoteFile(note.id, file);
|
||||
// execCommand frem for at bygge noden selv: det er det, der lægger
|
||||
// handlingen i browserens egen fortryd-historik, så ⌘Z tager billedet
|
||||
// ud igen ligesom det tager et ord ud.
|
||||
editor?.focus();
|
||||
document.execCommand('insertHTML', false,
|
||||
`<img src="${saved.url}" alt="">`);
|
||||
}
|
||||
changed();
|
||||
onsave?.();
|
||||
return true;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
return false;
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let uploading = $state(false);
|
||||
|
||||
function onpaste(event) {
|
||||
// Filer først. Et skærmbillede i udklipsholderen kommer med som en fil og
|
||||
// som ingenting andet, så hvis der er en, er det den, der skal ind.
|
||||
const files = event.clipboardData?.files ?? [];
|
||||
if (files.length) {
|
||||
event.preventDefault();
|
||||
insertFiles(files);
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData?.getData('text/plain') ?? '';
|
||||
document.execCommand('insertText', false, text);
|
||||
changed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trukket ind udefra.
|
||||
*
|
||||
* `preventDefault` på begge: uden den på dragover kommer der ingen drop, og
|
||||
* uden den på drop åbner browseren filen i stedet — man mister det, man var i
|
||||
* gang med at skrive, til en fane med et billede i.
|
||||
*
|
||||
* Kun filer. Et træk inde fra appen selv — en opgave, en note — bærer vores
|
||||
* egen nyttelast og skal falde igennem til den, der lyttede efter den.
|
||||
*/
|
||||
function ondragover(event) {
|
||||
if (!event.dataTransfer?.types?.includes('Files')) return;
|
||||
event.preventDefault();
|
||||
dropping = true;
|
||||
}
|
||||
|
||||
function ondrop(event) {
|
||||
const files = event.dataTransfer?.files ?? [];
|
||||
dropping = false;
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
insertFiles(files);
|
||||
}
|
||||
|
||||
let dropping = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="wrap" bind:this={wrap}>
|
||||
@@ -508,6 +589,10 @@
|
||||
onmouseup={readState}
|
||||
{onkeydown}
|
||||
{onpaste}
|
||||
{ondragover}
|
||||
{ondrop}
|
||||
ondragleave={() => (dropping = false)}
|
||||
class:dropping
|
||||
></div>
|
||||
|
||||
{#if suggestions.length}
|
||||
@@ -737,6 +822,14 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mens en fil holdes over noten. En ramme frem for en farvet flade: teksten
|
||||
under skal stadig kunne læses, så man kan se hvor billedet lander. */
|
||||
.page.dropping {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Kildeteksten. Monospace, fordi det er en fil — og fordi indrykningen i en
|
||||
liste er indhold her og ikke pynt: den skal kunne tælles. */
|
||||
.source {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { app, projectName } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { REPEATS } from '$lib/when.js';
|
||||
|
||||
let { task, onclose } = $props();
|
||||
|
||||
@@ -29,17 +30,6 @@
|
||||
/** Who the task can be given to. Only ever more than one on a shared project. */
|
||||
let members = $state([]);
|
||||
|
||||
// The rules worth a menu entry. Anything more specific is typed into the
|
||||
// title, where the parser understands far more than a list ever could.
|
||||
const REPEATS = [
|
||||
{ rule: '', label: 'Aldrig' },
|
||||
{ rule: 'FREQ=DAILY', label: 'Hver dag' },
|
||||
{ rule: 'FREQ=WEEKLY', label: 'Hver uge' },
|
||||
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' },
|
||||
{ rule: 'FREQ=MONTHLY', label: t('detail.monthly') },
|
||||
{ rule: 'FREQ=YEARLY', label: t('detail.yearly') }
|
||||
];
|
||||
|
||||
let subtasks = $state([]);
|
||||
let comments = $state([]);
|
||||
let attachments = $state([]);
|
||||
@@ -452,13 +442,13 @@
|
||||
<label for="repeat">{t('detail.repeats')}</label>
|
||||
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
|
||||
{#each REPEATS as option (option.rule)}
|
||||
<option value={option.rule}>{option.label}</option>
|
||||
<option value={option.rule}>{t(option.label)}</option>
|
||||
{/each}
|
||||
<!-- A rule typed as "hver anden tirsdag" has no entry in the list;
|
||||
showing it rather than snapping to the nearest preset is the
|
||||
difference between a menu and a menu that quietly edits. -->
|
||||
{#if recurrence && !REPEATS.some((o) => o.rule === recurrence)}
|
||||
<option value={recurrence}>{task.recurrence_text ?? 'Som skrevet'}</option>
|
||||
<option value={recurrence}>{task.recurrence_text ?? t('detail.asWritten')}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { repeatLabel } from '$lib/when.js';
|
||||
|
||||
let { task, onedit } = $props();
|
||||
|
||||
let repeats = $derived(repeatLabel(task.recurrence_rule, task.recurrence_text));
|
||||
|
||||
let leaving = $state(false);
|
||||
|
||||
const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p }));
|
||||
@@ -84,7 +87,7 @@
|
||||
<span class="description">{task.description}</span>
|
||||
{/if}
|
||||
|
||||
{#if assignee || due || task.labels?.length || task.recurrence_text || task.subtask_count || task.attachment_count}
|
||||
{#if assignee || due || task.labels?.length || repeats || task.subtask_count || task.attachment_count}
|
||||
<span class="meta">
|
||||
{#if assignee}
|
||||
<!-- First in the row, because "who" changes what the rest of the line
|
||||
@@ -102,15 +105,15 @@
|
||||
{due.text}{#if time} {time}{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.recurrence_text}
|
||||
<span class="repeat" title={t('task.repeats', { rule: task.recurrence_text })}>
|
||||
{#if repeats}
|
||||
<span class="repeat" title={t('task.repeats', { rule: repeats })}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M17 2l4 4-4 4" />
|
||||
<path d="M3 11V9a4 4 0 014-4h14" />
|
||||
<path d="M7 22l-4-4 4-4" />
|
||||
<path d="M21 13v2a4 4 0 01-4 4H3" />
|
||||
</svg>
|
||||
{task.recurrence_text}
|
||||
{repeats}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- What is underneath, said as a fraction rather than a total: "3" tells
|
||||
|
||||
@@ -1,40 +1,48 @@
|
||||
/**
|
||||
* What each recorded activity event is called.
|
||||
*
|
||||
* The keys are the server's and they are stable; this is the only place they
|
||||
* become Danish. It lives in its own module because two surfaces read the same log
|
||||
* now — a project's own history panel, and the administrator's instance-wide audit
|
||||
* page — and two copies of nineteen strings is two places for them to drift apart.
|
||||
* The keys are the server's and they are stable; this is the only place they get a
|
||||
* name. It lives in its own module because two surfaces read the same log now — a
|
||||
* project's own history panel, and the administrator's instance-wide audit page —
|
||||
* and two copies of twenty strings is two places for them to drift apart.
|
||||
*
|
||||
* The names themselves are in the dictionaries rather than here. They were written
|
||||
* out in Danish, which made the whole of both history pages Danish for somebody
|
||||
* reading the rest of the app in English.
|
||||
*
|
||||
* An unknown key falls through to the key itself rather than being hidden: a build
|
||||
* that records something this table has not learned yet should still show that it
|
||||
* happened.
|
||||
*/
|
||||
import { t } from './i18n.svelte.js';
|
||||
|
||||
export const EVENTS = {
|
||||
'project.created': 'oprettede projektet',
|
||||
'project.updated': 'ændrede projektet',
|
||||
'project.deleted': 'slettede projektet',
|
||||
'project.imported': 'importerede projektet',
|
||||
'section.created': 'oprettede en sektion',
|
||||
'section.updated': 'ændrede en sektion',
|
||||
'section.deleted': 'slettede en sektion',
|
||||
'member.invited': 'inviterede',
|
||||
'member.added': 'tilføjede',
|
||||
'member.removed': 'fjernede',
|
||||
'member.role_changed': 'ændrede rollen for',
|
||||
'task.created': 'oprettede en opgave',
|
||||
'task.updated': 'ændrede en opgave',
|
||||
'task.completed': 'lukkede en opgave',
|
||||
'task.reopened': 'genåbnede en opgave',
|
||||
'task.moved': 'flyttede en opgave',
|
||||
'task.deleted': 'slettede en opgave',
|
||||
'task.split': 'delte en opgave op',
|
||||
'comment.created': 'skrev en kommentar'
|
||||
'project.created': 'event.projectCreated',
|
||||
'project.updated': 'event.projectUpdated',
|
||||
'project.deleted': 'event.projectDeleted',
|
||||
'project.imported': 'event.projectImported',
|
||||
'section.created': 'event.sectionCreated',
|
||||
'section.updated': 'event.sectionUpdated',
|
||||
'section.deleted': 'event.sectionDeleted',
|
||||
'member.invited': 'event.memberInvited',
|
||||
'member.added': 'event.memberAdded',
|
||||
'member.removed': 'event.memberRemoved',
|
||||
'member.role_changed': 'event.memberRoleChanged',
|
||||
'task.created': 'event.taskCreated',
|
||||
'task.updated': 'event.taskUpdated',
|
||||
'task.completed': 'event.taskCompleted',
|
||||
'task.reopened': 'event.taskReopened',
|
||||
'task.moved': 'event.taskMoved',
|
||||
'task.deleted': 'event.taskDeleted',
|
||||
'task.split': 'event.taskSplit',
|
||||
'comment.created': 'event.commentCreated',
|
||||
'attachment.added': 'event.attachmentAdded'
|
||||
};
|
||||
|
||||
/** The name of an event, or the raw key when it is one this build does not know. */
|
||||
export function eventName(event) {
|
||||
return EVENTS[event] ?? event;
|
||||
const key = EVENTS[event];
|
||||
return key ? t(key) : event;
|
||||
}
|
||||
|
||||
/** The bit of context an entry carries, when it has one worth reading. */
|
||||
|
||||
@@ -29,6 +29,21 @@ export const da = {
|
||||
'notes.sortUpdated': 'Senest rørt',
|
||||
'notes.sortCreated': 'Oprettet',
|
||||
'notes.sortTitle': 'Navn',
|
||||
'beacon.title': 'Tælling af installationer',
|
||||
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
|
||||
'beacon.send': 'Send en anonym daglig melding',
|
||||
'beacon.exactly': 'Præcis det her — ikke mere:',
|
||||
'beacon.lastPing': 'Sidst sendt: {when}',
|
||||
'beacon.collector': 'Modtagerens adresse',
|
||||
'beacon.collectorHint': 'Hvor meldingen sendes hen. Lad den stå, medmindre du kører din egen tæller.',
|
||||
'beacon.beCollector': 'Denne instans er tælleren',
|
||||
'beacon.receiving': 'Modtager anonyme meldinger — id og version, aldrig en IP.',
|
||||
'beacon.total': 'I alt',
|
||||
'beacon.active7': 'Aktive 7 dage',
|
||||
'beacon.active30': 'Aktive 30 dage',
|
||||
'beacon.byVersion': 'Efter version (30 dage):',
|
||||
'beacon.publish': 'Offentliggør tallet på /api/v1/beacon/count',
|
||||
'beacon.publishHint': 'Uden login, mellemlagret et minut. Viser installationer set de seneste 30 dage — ikke et løbende total, som kun ville stige.',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.showSource': 'Vis som kode',
|
||||
'notes.source': 'Notens kildetekst',
|
||||
@@ -303,6 +318,9 @@ export const da = {
|
||||
'users.taskOne': 'opgave',
|
||||
'users.taskMany': 'opgaver',
|
||||
'users.never': 'aldrig set',
|
||||
'users.toProject': 'til {name}',
|
||||
'users.toInstance': 'til instansen',
|
||||
'users.invitedBy': 'inviteret af {name}',
|
||||
|
||||
// --- settings: history and errors -----------------------------------------------
|
||||
'history.title': 'Historik',
|
||||
@@ -318,6 +336,30 @@ export const da = {
|
||||
'history.noMatch': 'Ingen hændelser matcher de valgte filtre.',
|
||||
'history.nothing': 'Der er ikke registreret noget endnu.',
|
||||
'history.deletedAccount': 'En slettet konto',
|
||||
|
||||
// Hvad hver hændelse i loggen hedder. Nøglerne er serverens og ligger i
|
||||
// events.js; her står de ord, de bliver til.
|
||||
'event.projectCreated': 'oprettede projektet',
|
||||
'event.projectUpdated': 'ændrede projektet',
|
||||
'event.projectDeleted': 'slettede projektet',
|
||||
'event.projectImported': 'importerede projektet',
|
||||
'event.sectionCreated': 'oprettede en sektion',
|
||||
'event.sectionUpdated': 'ændrede en sektion',
|
||||
'event.sectionDeleted': 'slettede en sektion',
|
||||
'event.memberInvited': 'inviterede',
|
||||
'event.memberAdded': 'tilføjede',
|
||||
'event.memberRemoved': 'fjernede',
|
||||
'event.memberRoleChanged': 'ændrede rollen for',
|
||||
'event.taskCreated': 'oprettede en opgave',
|
||||
'event.taskUpdated': 'ændrede en opgave',
|
||||
'event.taskCompleted': 'lukkede en opgave',
|
||||
'event.taskReopened': 'genåbnede en opgave',
|
||||
'event.taskMoved': 'flyttede en opgave',
|
||||
'event.taskDeleted': 'slettede en opgave',
|
||||
'event.taskSplit': 'delte en opgave op',
|
||||
'event.commentCreated': 'skrev en kommentar',
|
||||
'event.attachmentAdded': 'vedhæftede en fil',
|
||||
|
||||
'errors.title': 'Serverfejl',
|
||||
'errors.hint':
|
||||
'Hver 500, API’et har svaret, med hvad handleren var i gang med og fejlen den ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra ved hver genstart, så forklaringen er som regel væk, når nogen kigger. Ryddes efter 30 dage.',
|
||||
@@ -497,8 +539,13 @@ export const da = {
|
||||
'detail.labelsPlaceholder': 'adskilt af komma',
|
||||
'detail.project': 'Projekt',
|
||||
'detail.repeats': 'Gentages',
|
||||
'detail.never': 'Aldrig',
|
||||
'detail.daily': 'Hver dag',
|
||||
'detail.weekly': 'Hver uge',
|
||||
'detail.fortnightly': 'Hver anden uge',
|
||||
'detail.monthly': 'Hver måned',
|
||||
'detail.yearly': 'Hvert år',
|
||||
'detail.asWritten': 'Som skrevet',
|
||||
'detail.assignee': 'Ansvarlig',
|
||||
'detail.subtasks': 'Undertasks',
|
||||
'detail.addSubtask': 'Tilføj en undertask',
|
||||
@@ -688,6 +735,7 @@ export const da = {
|
||||
'push.upToDate': 'Ingen nyere version fundet.',
|
||||
|
||||
'view.nothingMoreToday': 'Ikke mere i dag.',
|
||||
'view.overdue': 'Forsinket',
|
||||
'project.roleFor': 'Rolle for {name}',
|
||||
|
||||
'project.more': 'Flere handlinger',
|
||||
|
||||
@@ -25,6 +25,21 @@ export const en = {
|
||||
'notes.sortUpdated': 'Last touched',
|
||||
'notes.sortCreated': 'Created',
|
||||
'notes.sortTitle': 'Name',
|
||||
'beacon.title': 'Counting installations',
|
||||
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
|
||||
'beacon.send': 'Send an anonymous daily report',
|
||||
'beacon.exactly': 'Exactly this — nothing more:',
|
||||
'beacon.lastPing': 'Last sent: {when}',
|
||||
'beacon.collector': 'Collector address',
|
||||
'beacon.collectorHint': 'Where the report goes. Leave it unless you run your own collector.',
|
||||
'beacon.beCollector': 'This instance is the collector',
|
||||
'beacon.receiving': 'Receiving anonymous reports — id and version, never an IP.',
|
||||
'beacon.total': 'Total',
|
||||
'beacon.active7': 'Active 7 days',
|
||||
'beacon.active30': 'Active 30 days',
|
||||
'beacon.byVersion': 'By version (30 days):',
|
||||
'beacon.publish': 'Publish the count at /api/v1/beacon/count',
|
||||
'beacon.publishHint': 'No login, cached for a minute. Reports installations seen in the last 30 days — not a running total, which would only ever climb.',
|
||||
'notes.strike': 'Strikethrough',
|
||||
'notes.showSource': 'Show as code',
|
||||
'notes.source': 'Note source',
|
||||
@@ -298,6 +313,9 @@ export const en = {
|
||||
'users.taskOne': 'task',
|
||||
'users.taskMany': 'tasks',
|
||||
'users.never': 'never seen',
|
||||
'users.toProject': 'to {name}',
|
||||
'users.toInstance': 'to the instance',
|
||||
'users.invitedBy': 'invited by {name}',
|
||||
|
||||
// --- settings: history and errors -----------------------------------------------
|
||||
'history.title': 'History',
|
||||
@@ -313,6 +331,30 @@ export const en = {
|
||||
'history.noMatch': 'Nothing matches the filters.',
|
||||
'history.nothing': 'Nothing has been recorded yet.',
|
||||
'history.deletedAccount': 'A deleted account',
|
||||
|
||||
// What each recorded event is called. The keys are the server's and live in
|
||||
// events.js; these are the words they become.
|
||||
'event.projectCreated': 'created the project',
|
||||
'event.projectUpdated': 'changed the project',
|
||||
'event.projectDeleted': 'deleted the project',
|
||||
'event.projectImported': 'imported the project',
|
||||
'event.sectionCreated': 'created a section',
|
||||
'event.sectionUpdated': 'changed a section',
|
||||
'event.sectionDeleted': 'deleted a section',
|
||||
'event.memberInvited': 'invited',
|
||||
'event.memberAdded': 'added',
|
||||
'event.memberRemoved': 'removed',
|
||||
'event.memberRoleChanged': 'changed the role of',
|
||||
'event.taskCreated': 'created a task',
|
||||
'event.taskUpdated': 'changed a task',
|
||||
'event.taskCompleted': 'closed a task',
|
||||
'event.taskReopened': 'reopened a task',
|
||||
'event.taskMoved': 'moved a task',
|
||||
'event.taskDeleted': 'deleted a task',
|
||||
'event.taskSplit': 'split a task up',
|
||||
'event.commentCreated': 'wrote a comment',
|
||||
'event.attachmentAdded': 'attached a file',
|
||||
|
||||
'errors.title': 'Server errors',
|
||||
'errors.hint':
|
||||
'Every 500 the API has answered, with what the handler was attempting and the error it hit. Written to the database rather than only to the log — a container’s log starts over on every restart, so the explanation is usually gone by the time anybody looks. Kept for 30 days.',
|
||||
@@ -493,8 +535,13 @@ export const en = {
|
||||
'detail.labelsPlaceholder': 'separated by commas',
|
||||
'detail.project': 'Project',
|
||||
'detail.repeats': 'Repeats',
|
||||
'detail.never': 'Never',
|
||||
'detail.daily': 'Every day',
|
||||
'detail.weekly': 'Every week',
|
||||
'detail.fortnightly': 'Every other week',
|
||||
'detail.monthly': 'Every month',
|
||||
'detail.yearly': 'Every year',
|
||||
'detail.asWritten': 'As written',
|
||||
'detail.assignee': 'Assigned to',
|
||||
'detail.subtasks': 'Sub-tasks',
|
||||
'detail.addSubtask': 'Add a sub-task',
|
||||
@@ -683,6 +730,7 @@ export const en = {
|
||||
'push.upToDate': 'No newer version found.',
|
||||
|
||||
'view.nothingMoreToday': 'Nothing more today.',
|
||||
'view.overdue': 'Overdue',
|
||||
'project.roleFor': 'Role for {name}',
|
||||
|
||||
'project.more': 'More actions',
|
||||
|
||||
@@ -37,28 +37,61 @@ const BLOCKS = [
|
||||
const escapeHtml = (s) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
/**
|
||||
* En værdi, der skal stå inde i en attribut.
|
||||
*
|
||||
* escapeHtml er skrevet til tekst mellem to tags, hvor et anførselstegn ikke
|
||||
* betyder noget, og lader dem derfor stå. Inde i en attribut lukker de den — og
|
||||
* så står resten af strengen som markup. Det er den ene forskel, og den er hele
|
||||
* forskellen mellem tekst og et onerror.
|
||||
*/
|
||||
const attr = (s) => escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
/**
|
||||
* Billeder løftes ud af teksten, før noget andet rører den.
|
||||
*
|
||||
* De stod før først i kæden, og så kørte de fire resterende regler hen over en
|
||||
* streng, der nu indeholdt rigtig HTML — ind i den `alt`, der lige var lavet.
|
||||
* Det gik godt, så længe ingen af erstatningerne indeholdt et anførselstegn, og
|
||||
* det er en betingelse, der stod skrevet ingen steder: den dag nogen tilføjer en
|
||||
* regel for links, er den brudt, og det er præcis den vej, XSS-en kom ind ad.
|
||||
*
|
||||
* Værre endnu var undslupningen. `escapeHtml` kørte på hele teksten, og `attr`
|
||||
* kørte den igen på `alt` — så et og-tegn blev til en dobbelt undslupning, der
|
||||
* voksede ved hver eneste åbning og gemning. En note med "Kaffe & te" i et
|
||||
* billede blev længere, hver gang nogen så på den.
|
||||
*
|
||||
* En pladsholder løser begge dele. Billedet tages ud af den rå tekst, resten
|
||||
* undslippes og formateres som altid, og billedet lægges tilbage til sidst med en
|
||||
* `alt`, der er undsluppet nøjagtig én gang. Rækkefølgen af de øvrige regler kan
|
||||
* så ikke længere betyde noget.
|
||||
*
|
||||
* Nul-tegnet som mærke, fordi det er det ene tegn, en note ikke kan indeholde:
|
||||
* det overlever hverken en contenteditable eller en Markdown-fil.
|
||||
*/
|
||||
const IMAGE = /!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g;
|
||||
const MARK = "\u0000";
|
||||
|
||||
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
||||
function inlineToHtml(text) {
|
||||
return (
|
||||
escapeHtml(text)
|
||||
// Billedet først, og før alt andet.
|
||||
//
|
||||
// Der var ingen regel for det overhovedet, så en note fra Apple Noter
|
||||
// viste `` som ord. Billedet var hentet, gemt og
|
||||
// hæftet på noten — det eneste, der manglede, var at nogen tegnede det.
|
||||
//
|
||||
// Kun vores egen adresse. Et `` fra en indsat tekst ville
|
||||
// ellers hente fra en fremmed vært, når noten åbnes, og fortælle den, at
|
||||
// den er blevet læst — og en note er det sidste sted, man vil have en
|
||||
// sporingspixel. Alt andet står som den tekst, det er.
|
||||
.replace(/!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g,
|
||||
(_, alt, src) => `<img src="${src}" alt="${alt}">`)
|
||||
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
|
||||
);
|
||||
// Kun vores egen adresse bliver til et billede. Et `` fra en
|
||||
// indsat tekst ville ellers hente fra en fremmed vært, når noten åbnes, og
|
||||
// fortælle den, at den er blevet læst — og en note er det sidste sted, man vil
|
||||
// have en sporingspixel. Alt andet står som den tekst, det er.
|
||||
const images = [];
|
||||
const lifted = (text ?? '').replace(IMAGE, (_, alt, src) => {
|
||||
images.push(`<img src="${src}" alt="${attr(alt)}">`);
|
||||
return `${MARK}${images.length - 1}${MARK}`;
|
||||
});
|
||||
|
||||
const marked = escapeHtml(lifted)
|
||||
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
||||
|
||||
return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => images[Number(i)] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,7 +154,12 @@ export function markdownToHtml(markdown) {
|
||||
if (fenced) {
|
||||
closeList();
|
||||
fence = fenced[1] ?? '';
|
||||
out.push(`<pre data-lang="${escapeHtml(fence)}"><code>`);
|
||||
// attr, ikke escapeHtml: værdien står inde i en attribut. Den er sikker i
|
||||
// dag alene fordi hegn-regexen er \w*, som ikke rummer anførselstegn — og
|
||||
// den dag nogen udvider den for at tage imod "c++" eller "f#", er det en
|
||||
// XSS. Undslupningen skal passe til stedet, ikke til det, der
|
||||
// tilfældigvis kan stå der nu.
|
||||
out.push(`<pre data-lang="${attr(fence)}"><code>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,3 +49,32 @@ export function fullDate(iso) {
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The repeat rules worth a menu entry, and what to call them.
|
||||
*
|
||||
* Here rather than in the drawer that draws the menu, because the row draws the
|
||||
* same rule as a badge and the two had already disagreed: the menu said "Hver
|
||||
* dag" in both languages, and the badge said whatever the server said.
|
||||
*
|
||||
* The server sends `recurrence_text` with every task and sends it in Danish. It
|
||||
* has to: one payload goes over the WebSocket to everybody in the project at
|
||||
* once, and they do not all read the same language. So the rules that have a name
|
||||
* are named here, on the side that knows whose screen this is, and anything more
|
||||
* specific — "hver anden tirsdag", typed into the title — falls back to what the
|
||||
* server wrote.
|
||||
*/
|
||||
export const REPEATS = [
|
||||
{ rule: '', label: 'detail.never' },
|
||||
{ rule: 'FREQ=DAILY', label: 'detail.daily' },
|
||||
{ rule: 'FREQ=WEEKLY', label: 'detail.weekly' },
|
||||
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'detail.fortnightly' },
|
||||
{ rule: 'FREQ=MONTHLY', label: 'detail.monthly' },
|
||||
{ rule: 'FREQ=YEARLY', label: 'detail.yearly' }
|
||||
];
|
||||
|
||||
/** What a repeating task says on its row. */
|
||||
export function repeatLabel(rule, fallback = '') {
|
||||
const known = REPEATS.find((option) => option.rule && option.rule === rule);
|
||||
return known ? t(known.label) : fallback;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
{#if liveOverdue.length}
|
||||
<section>
|
||||
<h2 class="overdue">Forsinket</h2>
|
||||
<h2 class="overdue">{t('view.overdue')}</h2>
|
||||
{#each liveOverdue as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
{/each}
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
<section>
|
||||
{#if liveOverdue.length}
|
||||
<h2>I dag</h2>
|
||||
<h2>{t('nav.today')}</h2>
|
||||
{/if}
|
||||
{#each liveToday as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
|
||||
@@ -35,10 +35,29 @@
|
||||
* A flat list of two hundred rows says when nothing. "I dag" and "I går" are
|
||||
* how anybody looking for something they just closed would describe it.
|
||||
*/
|
||||
/**
|
||||
* Dagen en opgave blev lukket, målt hvor personen står.
|
||||
*
|
||||
* Ikke `completed_at.slice(0, 10)`. Tidsstemplet er i UTC, og de ti første tegn
|
||||
* er derfor UTC's dato — mens overskriften nedenfor sammenligner med *lokal*
|
||||
* dato. I to timer efter midnat i dansk sommertid er de to ikke den samme dag,
|
||||
* så en opgave lukket 00.30 lå under "I går", indtil klokken blev to.
|
||||
*
|
||||
* Fundet af prøven, som kørte forbi midnat for første gang. Den havde været
|
||||
* grøn hver eneste gang før, fordi de to datoer er ens tyve timer i døgnet.
|
||||
*/
|
||||
const localDay = (iso) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
};
|
||||
|
||||
let days = $derived.by(() => {
|
||||
const groups = new Map();
|
||||
for (const task of done) {
|
||||
const key = (task.completed_at ?? '').slice(0, 10);
|
||||
const key = localDay(task.completed_at);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(task);
|
||||
}
|
||||
|
||||
@@ -177,9 +177,11 @@
|
||||
<div class="what">
|
||||
<span class="primary-line">{pending.email}</span>
|
||||
<span class="secondary">
|
||||
{pending.project_name ? `til ${pending.project_name}` : 'til instansen'}
|
||||
{pending.project_name
|
||||
? t('users.toProject', { name: pending.project_name })
|
||||
: t('users.toInstance')}
|
||||
· {t('tokens.expiresOn', { when: expires(pending.expires_at) })}
|
||||
{#if pending.invited_by}· inviteret af {pending.invited_by}{/if}
|
||||
{#if pending.invited_by}· {t('users.invitedBy', { name: pending.invited_by })}{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button class="secondary" onclick={() => revoke(pending)}>{t('users.revoke')}</button>
|
||||
@@ -209,7 +211,8 @@
|
||||
</span>
|
||||
<span class="secondary">
|
||||
{user.email} · {ago(user.last_seen_at, { never: 'when.neverSignedIn' })}
|
||||
{#if user.project_count}· {count(user.project_count, 'projekt', 'projekter')}{/if}
|
||||
{#if user.project_count}·
|
||||
{count(user.project_count, 'users.projectOne', 'users.projectMany')}{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,29 @@
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ago } from '$lib/when.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
|
||||
// --- beaconet -----------------------------------------------------------------
|
||||
|
||||
// Hvad denne installation melder, og til hvem. Se internal/beacon for hvorfor
|
||||
// den er slået til som udgangspunkt frem for at være obligatorisk.
|
||||
let beacon = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!app.user?.is_admin) return;
|
||||
api.beacon().then((r) => (beacon = r)).catch(() => {});
|
||||
});
|
||||
|
||||
async function saveBeacon(change) {
|
||||
try {
|
||||
beacon = await api.setBeacon(change);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
// Læst forfra, så en afvist ændring ikke bliver stående som et flueben,
|
||||
// der ikke svarer til, hvad serveren gør.
|
||||
api.beacon().then((r) => (beacon = r)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// --- backups ---------------------------------------------------------------------
|
||||
|
||||
@@ -486,6 +508,95 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Beaconet.
|
||||
Under Data, fordi det er her alt om "hvad forlader denne instans" står — en
|
||||
sikkerhedskopi, en eksport, en import. Telemetri hører til i samme rude som
|
||||
dem, ikke gemt væk under en fane, ingen åbner. -->
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('beacon.title')}</h2>
|
||||
<p class="hint">{t('beacon.hint')}</p>
|
||||
</header>
|
||||
|
||||
{#if beacon}
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={beacon.enabled}
|
||||
onchange={(e) => saveBeacon({ enabled: e.currentTarget.checked })}
|
||||
/>
|
||||
<span>{t('beacon.send')}</span>
|
||||
</label>
|
||||
|
||||
<!-- Hele beskeden, ord for ord. Et løfte om telemetri er præcis så meget
|
||||
værd som læserens mulighed for at efterprøve det, så den står her frem
|
||||
for som en sætning om, hvad der cirka bliver sendt. -->
|
||||
<pre class="payload">{t('beacon.exactly')}
|
||||
{JSON.stringify({ instance_id: beacon.instance_id, version: beacon.version }, null, 2)}</pre>
|
||||
|
||||
{#if beacon.last_ping_at}
|
||||
<p class="hint">{t('beacon.lastPing', { when: new Date(beacon.last_ping_at).toLocaleString(tag()) })}</p>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="collector">{t('beacon.collector')}</label>
|
||||
<input
|
||||
id="collector"
|
||||
value={beacon.collector_url}
|
||||
onchange={(e) => saveBeacon({ collector_url: e.currentTarget.value })}
|
||||
/>
|
||||
<p class="hint">{t('beacon.collectorHint')}</p>
|
||||
</div>
|
||||
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={beacon.is_collector}
|
||||
onchange={(e) => saveBeacon({ is_collector: e.currentTarget.checked })}
|
||||
/>
|
||||
<span>{t('beacon.beCollector')}</span>
|
||||
</label>
|
||||
|
||||
{#if beacon.is_collector && beacon.stats}
|
||||
<p class="hint">{t('beacon.receiving')}</p>
|
||||
<div class="counts">
|
||||
<div class="count">
|
||||
<strong>{beacon.stats.total}</strong>
|
||||
<span>{t('beacon.total')}</span>
|
||||
</div>
|
||||
<div class="count">
|
||||
<strong>{beacon.stats.active_7d}</strong>
|
||||
<span>{t('beacon.active7')}</span>
|
||||
</div>
|
||||
<div class="count">
|
||||
<strong>{beacon.stats.active_30d}</strong>
|
||||
<span>{t('beacon.active30')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if Object.keys(beacon.stats.by_version ?? {}).length}
|
||||
<p class="hint">
|
||||
{t('beacon.byVersion')}
|
||||
{Object.entries(beacon.stats.by_version)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([v, n]) => `${v} ×${n}`)
|
||||
.join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={beacon.publish_count}
|
||||
onchange={(e) => saveBeacon({ publish_count: e.currentTarget.checked })}
|
||||
/>
|
||||
<span>{t('beacon.publish')}</span>
|
||||
</label>
|
||||
<p class="hint">{t('beacon.publishHint')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* The backup list, which is a list of rows with a right-hand action — the same
|
||||
shape the settings pages use elsewhere, said once here because this is the
|
||||
@@ -596,4 +707,48 @@
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Beskeden som den sendes. Monospace, fordi den skal kunne sammenlignes tegn
|
||||
for tegn med det, der står i dokumentationen. */
|
||||
.payload {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--s2) var(--s3);
|
||||
overflow-x: auto;
|
||||
margin: var(--s2) 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.counts {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
margin: var(--s2) 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.count {
|
||||
flex: 1 1 7rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.count strong {
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.count span {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -159,9 +159,12 @@
|
||||
<span class="who" class:gone={!entry.user_name}
|
||||
>{entry.user_name || t('history.deletedAccount')}</span
|
||||
>
|
||||
<!-- The space is written out. Svelte drops the whitespace that
|
||||
opens a block, so "invited" and the address ran together. -->
|
||||
<span class="event"
|
||||
>{eventName(entry.event)}{#if eventDetail(entry)}
|
||||
<span class="detail">{eventDetail(entry)}</span>{/if}</span
|
||||
>{eventName(entry.event)}{#if eventDetail(entry)}{' '}<span class="detail"
|
||||
>{eventDetail(entry)}</span
|
||||
>{/if}</span
|
||||
>
|
||||
</span>
|
||||
<span class="secondary">{entry.project_name}</span>
|
||||
|
||||
@@ -113,6 +113,10 @@
|
||||
*/
|
||||
function plain(body) {
|
||||
return (body ?? '')
|
||||
// Et billede har ingen ord i sig. Uden det her stod der
|
||||
// "]*\)/g, '')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/^\s*[-*+]\s+/gm, '')
|
||||
.replace(/^\s*>\s?/gm, '')
|
||||
@@ -447,7 +451,7 @@
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 2px var(--s1);
|
||||
max-width: 11ch;
|
||||
max-width: 13ch;
|
||||
}
|
||||
|
||||
h1 {
|
||||
|
||||
@@ -581,8 +581,9 @@
|
||||
>{entry.user_name || t('history.deletedAccount')}</span
|
||||
>
|
||||
<span class="what">
|
||||
{eventName(entry.event)}{#if eventDetail(entry)}
|
||||
<span class="detail">{eventDetail(entry)}</span>{/if}
|
||||
{eventName(entry.event)}{#if eventDetail(entry)}{' '}<span class="detail"
|
||||
>{eventDetail(entry)}</span
|
||||
>{/if}
|
||||
</span>
|
||||
<span class="when">{when(entry.created_at)}</span>
|
||||
</li>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||
import CalendarView from '$lib/components/CalendarView.svelte';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
|
||||
let days = $state([]);
|
||||
|
||||
@@ -48,9 +48,9 @@
|
||||
const today = new Date(new Date().toDateString());
|
||||
const diff = Math.round((day - today) / 86400000);
|
||||
|
||||
if (diff === 0) return 'I dag';
|
||||
if (diff === 1) return 'I morgen';
|
||||
return day.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'short' });
|
||||
if (diff === 0) return t('task.today');
|
||||
if (diff === 1) return t('task.tomorrow');
|
||||
return day.toLocaleDateString(tag(), { weekday: 'long', day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
// Reading tasks back out of the store keeps a completed row disappearing
|
||||
|
||||