Compare commits
@@ -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,21 +95,24 @@ 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 |
|
||||
| Mail-to-task | Done, tested |
|
||||
| AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured |
|
||||
| Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested |
|
||||
| Google Calendar: read-only, laid over the task calendar | Built, never run against Google |
|
||||
| Update notice for administrators | Done, tested |
|
||||
| Personal API tokens | Done, tested |
|
||||
| OpenAPI 3.1 spec, checked against the router | Done, tested |
|
||||
| End-to-end smoke tests (Playwright) | Done |
|
||||
| Documentation, landing page, licence | Done |
|
||||
|
||||
**Not finished:** Web Push, the AI providers and the Gmail API calls are
|
||||
implemented against their specifications and have only ever been exercised there,
|
||||
not against live services. Everything else has an interface: opening a task shows
|
||||
**Not finished:** Web Push, the AI providers, and the Gmail and Google Calendar
|
||||
API calls are implemented against their specifications and have only ever been
|
||||
exercised there, not against live services. Everything else has an interface: opening a task shows
|
||||
its description, sub-tasks, comments, files and reminders, and the settings pages
|
||||
cover the account, notifications, integrations, AI, API tokens, import, export and
|
||||
templates.
|
||||
@@ -116,17 +124,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
|
||||
|
||||
|
||||
@@ -10,13 +10,28 @@ reply within a week.
|
||||
|
||||
- **Passwords** are Argon2id with the parameters recorded in the hash, so they can
|
||||
be raised later without locking anyone out.
|
||||
- **Every token** — session, invite, reset, API, calendar feed, mail address — is
|
||||
stored only as a SHA-256 hash. A database dump contains nothing that can be
|
||||
presented.
|
||||
- **Session, invite, reset and API tokens** are stored only as a SHA-256 hash. A
|
||||
database dump contains nothing that can be presented.
|
||||
- **The calendar feed address and the mail-to-task address are not hashed.** They
|
||||
are looked up by their exact value, and they are shown in the interface more
|
||||
than once, which a hash cannot do. Anybody holding a backup file therefore holds
|
||||
those two URLs: the feed reads that person's tasks without a login, and the mail
|
||||
address files tasks into their inbox. Rotating either from **Settings →
|
||||
Integrations** invalidates the old one. This is written down rather than fixed
|
||||
because fixing it means the address can only ever be shown once, which is a
|
||||
decision about how the feature works and not only about how it is stored.
|
||||
- **Mailbox passwords, OAuth tokens, TOTP secrets and AI keys** are sealed with
|
||||
AES-GCM under a key kept outside the database — see `internal/secret`. That is
|
||||
what makes a stray copy of the database less than a stray copy of everything:
|
||||
the backup does not carry the key.
|
||||
- **Sessions** are `httpOnly`, `SameSite=Lax`, and `__Host-` prefixed over HTTPS.
|
||||
- **Uploads** are stored content-addressed by hash and always served as
|
||||
`application/octet-stream` with `nosniff`, so an uploaded SVG cannot run script
|
||||
on this origin.
|
||||
- **Uploads** are stored content-addressed by hash and served as
|
||||
`application/octet-stream` with `nosniff` — with one exception, added so that a
|
||||
note can show the pictures in it: a short allowlist of raster image types is
|
||||
served inline as its own type. The list is named one format at a time rather
|
||||
than matched on an `image/` prefix, because the prefix would let `image/svg+xml`
|
||||
through, and an SVG is a document that can run script. `nosniff` stays, so the
|
||||
browser must read the file as that image or not at all.
|
||||
- **The filter language** compiles to parameterised SQL. No typed text is ever
|
||||
concatenated into a statement.
|
||||
- **Permission checks** are route middleware, and anything you may not see answers
|
||||
|
||||
@@ -75,7 +75,9 @@ func run() error {
|
||||
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
|
||||
runner.SyncGmail = api.SyncGmail
|
||||
runner.SyncMailbox = api.SyncMailbox
|
||||
runner.SyncCalendars = api.SyncCalendars
|
||||
runner.Push = api.PushToUser
|
||||
runner.SendBeacon = api.SendBeacon
|
||||
runner.Start(jobCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Calendar and CalDAV
|
||||
|
||||
Two ways to get your tasks into a calendar, and they are not the same thing.
|
||||
Two ways to get your tasks into a calendar, one way to get a calendar into
|
||||
verdande, and none of the three is the same thing.
|
||||
|
||||
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) |
|
||||
|---|---|---|
|
||||
| Direction | Read only | Two-way |
|
||||
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ |
|
||||
| You can tick things off | No | Yes |
|
||||
| Setup | Paste a URL | Username and token |
|
||||
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Google Calendar](#showing-a-google-calendar-in-verdande) |
|
||||
|---|---|---|---|
|
||||
| Direction | Tasks out, read only | Tasks out, two-way | Events **in**, read only |
|
||||
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | A Google account |
|
||||
| You can tick things off | No | Yes | No — an event is not a task |
|
||||
| Setup | Paste a URL | Username and token | Connect, then pick calendars |
|
||||
|
||||
## Subscribing to a feed
|
||||
|
||||
@@ -39,6 +40,42 @@ most people actually keep.
|
||||
**Settings → Calendar feed → Rotate** issues a new one and breaks the old
|
||||
immediately.
|
||||
|
||||
## Showing a Google calendar in verdande
|
||||
|
||||
The two sections above send your tasks *out*. This is the other direction: your
|
||||
Google calendar drawn underneath them, so one screen answers "what does this day
|
||||
already have in it".
|
||||
|
||||
**Settings → Integrations → Google Calendar → Connect**, then tick the calendars
|
||||
you want and press Save. They appear in **Calendar** in the sidebar, in Google's
|
||||
own colour for each one, beside your own tasks with due dates.
|
||||
|
||||
It needs the same OAuth client Gmail uses, with the Calendar API enabled and the
|
||||
second redirect URI registered — see [Configuration](configuration.md#google).
|
||||
|
||||
!!! warning "Read only"
|
||||
verdande never writes to your Google calendar. An event cannot be dragged,
|
||||
edited or ticked off here; clicking one opens it in Google, which is where it
|
||||
can be changed. Your own tasks in the same grid still drag to another day as
|
||||
they always have.
|
||||
|
||||
Writing would need the `calendar.events` scope and an answer to what should
|
||||
happen when both sides moved the same meeting — which is a synchronisation
|
||||
model rather than a scope, and it is deliberately not built yet.
|
||||
|
||||
!!! note "The Internal client applies here too"
|
||||
An OAuth client registered as *Internal* in a Google Workspace can only be
|
||||
used by accounts in that workspace, calendars included. A personal
|
||||
`@gmail.com` address is refused with `org_internal`. For one personal account
|
||||
the shorter road is the calendar's own secret address: in Google Calendar,
|
||||
**Settings → the calendar → Integrate calendar → Secret address in iCal
|
||||
format**. Nothing in verdande subscribes to one yet — it is the next piece of
|
||||
this feature.
|
||||
|
||||
Events are kept for ninety days back and a year forward, refreshed every fifteen
|
||||
minutes and whenever you press **Fetch now**. Paging the grid past that window
|
||||
says so rather than showing an empty month.
|
||||
|
||||
## Connecting a CalDAV client
|
||||
|
||||
This is the one where ticking something off in Apple Reminders ticks it off here.
|
||||
|
||||
@@ -39,12 +39,24 @@ Any Go duration: `720h`, `30m`, `45s`.
|
||||
yourself, password resets are written to the log, and reminders arrive in the
|
||||
app and as Web Push rather than by email.
|
||||
|
||||
## Gmail
|
||||
## Google
|
||||
|
||||
Only needed if anyone will connect a mailbox. Register an OAuth client in Google
|
||||
Cloud (**APIs & Services → Credentials → OAuth client ID → Web application**) with
|
||||
the redirect URI `https://your-address/oauth/gmail/callback`, enable the Gmail API,
|
||||
and add the `gmail.readonly` scope.
|
||||
Only needed if anyone will connect a Gmail mailbox or a Google calendar. One
|
||||
registration covers both: register an OAuth client in Google Cloud
|
||||
(**APIs & Services → Credentials → OAuth client ID → Web application**) and add
|
||||
**both** redirect URIs:
|
||||
|
||||
```
|
||||
https://your-address/oauth/gmail/callback
|
||||
https://your-address/oauth/calendar/callback
|
||||
```
|
||||
|
||||
Then enable the APIs and scopes for whichever you want:
|
||||
|
||||
| For | Enable | Scope |
|
||||
|---|---|---|
|
||||
| Mailboxes | Gmail API | `gmail.readonly` |
|
||||
| Calendar | Google Calendar API | `calendar.readonly` |
|
||||
|
||||
| Variable | What it does |
|
||||
|---|---|
|
||||
@@ -52,8 +64,15 @@ and add the `gmail.readonly` scope.
|
||||
| `VERDANDE_GMAIL_CLIENT_SECRET` | The client secret. |
|
||||
|
||||
One registration serves every user on the instance; each person authorises their
|
||||
own mailbox from **Settings → Gmail**. The redirect URI is derived from
|
||||
`VERDANDE_BASE_URL`, so it cannot drift out of step with what you registered.
|
||||
own mailbox from **Settings → Integrations → Gmail** and their own calendar from
|
||||
**Settings → Integrations → Google Calendar**. Both redirect URIs are derived from
|
||||
`VERDANDE_BASE_URL`, so they cannot drift out of step with what you registered.
|
||||
|
||||
!!! note "Two connections, two grants"
|
||||
Connecting a calendar does not disturb a working Gmail connection, and
|
||||
disconnecting one does not take the other with it. Google issues a refresh
|
||||
token per authorisation and keeps up to a hundred of them live per account,
|
||||
each carrying only the scopes it was granted with.
|
||||
|
||||
## Secrets
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,7 @@ administrator who can read yours.
|
||||
**Settings → Integrations → Gmail → Connect.**
|
||||
|
||||
Gmail needs an OAuth client, which is the instance's registration with Google
|
||||
rather than anybody's account — see [Configuration](configuration.md#gmail).
|
||||
rather than anybody's account — see [Configuration](configuration.md#google).
|
||||
Once it is set up, each person connects their own mailbox through it.
|
||||
|
||||
Choose what makes a task:
|
||||
|
||||
@@ -1396,6 +1396,204 @@ paths:
|
||||
description: The environment variables that are not set.
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/notes/bulk:
|
||||
post:
|
||||
tags: [notes]
|
||||
summary: Archive, unarchive or delete a set of notes in one call.
|
||||
description: |
|
||||
One call rather than one per note, because the thing being asked is one
|
||||
thing: "put these away". Fifty separate requests would be fifty round
|
||||
trips, fifty chances for half of them to fail, and a list that redraws
|
||||
fifty times — and after an import of twelve hundred notes, selecting fifty
|
||||
is the ordinary case rather than the extreme one.
|
||||
|
||||
Each note is still checked on its own. A set is a convenience for the
|
||||
caller and never a way to touch something they could not have touched one
|
||||
at a time; anything they may not write to is counted in `skipped` rather
|
||||
than failing the whole call, so one note somebody else just deleted does
|
||||
not stop the other forty-nine.
|
||||
|
||||
Archiving is not deleting. The trash says "this was a mistake, and in
|
||||
thirty days it is gone"; the archive says "this is finished, and I want to
|
||||
keep it".
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [ids]
|
||||
properties:
|
||||
ids:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: At most 500.
|
||||
archived:
|
||||
type: boolean
|
||||
description: Put away, or bring back out.
|
||||
delete:
|
||||
type: boolean
|
||||
description: Into the trash instead. Wins over `archived`.
|
||||
responses:
|
||||
"200":
|
||||
description: How many were changed, and how many were passed over.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
done: { type: integer }
|
||||
skipped: { type: integer }
|
||||
"422":
|
||||
description: No ids, too many, or neither archived nor delete was asked for.
|
||||
|
||||
/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]
|
||||
@@ -2507,6 +2705,126 @@ paths:
|
||||
created: { type: integer }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/calendar:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: The Google Calendar connection and the calendars in it.
|
||||
description: |
|
||||
Read-only. verdande shows a Google calendar beside its own dated tasks and
|
||||
never writes one back — writing needs the `calendar.events` scope and an
|
||||
answer to what happens when both sides moved the same meeting, which is a
|
||||
synchronisation model rather than a scope.
|
||||
|
||||
The list of calendars is fetched from Google when it is empty, so an
|
||||
account that connected during a hiccup corrects itself on the next look
|
||||
rather than showing an empty panel for ever.
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/Calendar" }
|
||||
delete:
|
||||
tags: [integrations]
|
||||
summary: Disconnect the calendar and forget its tokens.
|
||||
description: >
|
||||
The cached events go too. Unlike the tasks a mailbox made, none of this is
|
||||
the person's own work — it is a copy of somebody else's calendar, and it
|
||||
means nothing without the account.
|
||||
responses:
|
||||
"204": { description: Disconnected. }
|
||||
|
||||
/api/v1/calendar/calendars:
|
||||
put:
|
||||
tags: [integrations]
|
||||
summary: Choose which calendars are drawn.
|
||||
description: >
|
||||
The whole set in one write rather than one flip at a time, for the reason
|
||||
the projects' order is written the same way: the question is "these ones",
|
||||
and a list built out of several requests can land half applied. A calendar
|
||||
turned off has its cached events removed; they come back if it is turned
|
||||
on again.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
shown:
|
||||
type: array
|
||||
items: { type: string }
|
||||
responses:
|
||||
"204": { description: Saved. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/calendar/authorize:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Begin the OAuth flow for a calendar.
|
||||
description: |
|
||||
The same registration Gmail signs in through, asking for
|
||||
`calendar.readonly` and coming back to `/oauth/calendar/callback`. Google
|
||||
issues a refresh token per authorisation, so connecting a calendar does not
|
||||
disturb a working Gmail connection.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
url: { type: string }
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
|
||||
/api/v1/calendar/sync:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Refresh the chosen calendars now.
|
||||
description: >
|
||||
Refreshes the list of calendars and replaces the cached window of events
|
||||
for the ones that are shown. `partial` means the budget ran out before the
|
||||
last calendar; what was read is kept, and the rest arrive on the next run.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
events: { type: integer }
|
||||
partial: { type: boolean }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/calendar/events:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: The events of the chosen calendars, between two days.
|
||||
description: |
|
||||
Both ends inclusive, and an event that merely overlaps the range comes
|
||||
back — a grid asks which cells an event covers, not where it begins.
|
||||
|
||||
`from` and `to` in the answer are the window verdande holds a copy of,
|
||||
which is not the window that was asked for. A grid paged past it gets no
|
||||
events, and an empty answer with no explanation is a calendar quietly
|
||||
claiming a day is clear.
|
||||
parameters:
|
||||
- { name: from, in: query, required: true, schema: { type: string, format: date } }
|
||||
- { name: to, in: query, required: true, schema: { type: string, format: date } }
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
events:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/CalendarEvent" }
|
||||
from: { type: string, format: date }
|
||||
to: { type: string, format: date }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/export/notes.zip:
|
||||
get:
|
||||
tags: [notes]
|
||||
@@ -3150,6 +3468,26 @@ paths:
|
||||
responses:
|
||||
"302": { description: "Back into the app, with the outcome as a query parameter." }
|
||||
|
||||
/oauth/calendar/callback:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: Where Google sends the browser after consent to a calendar.
|
||||
description: |
|
||||
A URI of its own rather than a reuse of Gmail's. One callback deciding from
|
||||
stored state which of the two flows had come back would put the choice of
|
||||
which token store to write behind a lookup — and when that goes wrong it
|
||||
goes wrong by overwriting a working Gmail connection with calendar tokens.
|
||||
|
||||
Answers with a redirect to `/indstillinger/integrationer`, not with JSON.
|
||||
An `org_internal` error here means the OAuth app is registered as Internal
|
||||
and the account is outside the organisation.
|
||||
parameters:
|
||||
- { name: code, in: query, schema: { type: string } }
|
||||
- { name: state, in: query, schema: { type: string } }
|
||||
- { name: error, in: query, schema: { type: string } }
|
||||
responses:
|
||||
"302": { description: "Back into the app, with the outcome as a query parameter." }
|
||||
|
||||
/mcp:
|
||||
post:
|
||||
tags: [integrations]
|
||||
@@ -3332,6 +3670,40 @@ components:
|
||||
email: { type: string }
|
||||
trigger: { type: string, enum: ["", starred, label, both] }
|
||||
label: { type: string }
|
||||
Calendar:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
connected: { type: boolean }
|
||||
account:
|
||||
type: string
|
||||
description: >
|
||||
The address of the connected account, taken from the id of its
|
||||
primary calendar — which is where Google puts it, so no profile
|
||||
scope has to be asked for to learn it.
|
||||
read_only:
|
||||
type: boolean
|
||||
description: >
|
||||
Always true today. Stated rather than assumed: an interface that
|
||||
quietly looks editable is one somebody will try to edit.
|
||||
has_client:
|
||||
type: boolean
|
||||
description: >
|
||||
Whether an OAuth client is registered on this instance at all, so
|
||||
the page can explain rather than offer a button that answers 409.
|
||||
redirect_uri:
|
||||
type: string
|
||||
description: >
|
||||
What has to be registered with Google, spelled out. Derived from
|
||||
VERDANDE_BASE_URL and matched exactly — the single most likely
|
||||
thing to be wrong, and the error Google gives names neither value.
|
||||
last_sync_at: { type: string, format: date-time }
|
||||
calendars:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Calendar" }
|
||||
ImportResult:
|
||||
description: Created
|
||||
content:
|
||||
@@ -3349,6 +3721,62 @@ components:
|
||||
items: { type: string }
|
||||
|
||||
schemas:
|
||||
Calendar:
|
||||
type: object
|
||||
description: >
|
||||
One calendar inside a connected account. Kept whether or not it is shown,
|
||||
so turning one off and on again gives back the same row.
|
||||
properties:
|
||||
id: { type: string }
|
||||
remote_id: { type: string, description: "The id Google knows it by; an address for the primary calendar." }
|
||||
name: { type: string }
|
||||
colour:
|
||||
type: string
|
||||
description: >
|
||||
Google's own hex value, not one of verdande's ten named colours. Those
|
||||
are names so the interface can restyle them per theme and they are
|
||||
verdande's palette; a Google calendar is told apart by the colour the
|
||||
person already knows it by.
|
||||
time_zone: { type: string }
|
||||
primary: { type: boolean }
|
||||
writable:
|
||||
type: boolean
|
||||
description: >
|
||||
Whether the connected account could write to it. Nothing does — the
|
||||
scope is read-only — and it is stored so the day something can, the
|
||||
interface knows which calendars could even be offered.
|
||||
shown: { type: boolean }
|
||||
|
||||
CalendarEvent:
|
||||
type: object
|
||||
description: >
|
||||
One occurrence, in the shape a grid asks about. A recurrence is expanded by
|
||||
Google into its occurrences, so what arrives here is a day, not a rule.
|
||||
properties:
|
||||
id: { type: string }
|
||||
calendar_id: { type: string }
|
||||
summary: { type: string }
|
||||
starts_at:
|
||||
type: string
|
||||
description: >
|
||||
RFC 3339 as Google wrote it, offset and all. Empty for an all-day
|
||||
event, which has a day rather than a moment — inventing midnight for it
|
||||
is how an event moves a day when a container moves time zone.
|
||||
ends_at: { type: string }
|
||||
start_day: { type: string, format: date }
|
||||
end_day:
|
||||
type: string
|
||||
format: date
|
||||
description: >
|
||||
The last day the event covers, inclusive. Google's all-day end date is
|
||||
exclusive and is corrected on the way in, once, rather than by every
|
||||
reader.
|
||||
all_day: { type: boolean }
|
||||
location: { type: string }
|
||||
url: { type: string, description: "Opens the event in Google's own interface." }
|
||||
calendar_name: { type: string }
|
||||
colour: { type: string }
|
||||
|
||||
Note:
|
||||
type: object
|
||||
description: A page of Markdown. The body is the stored form and the exported form.
|
||||
|
||||
|
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 |
@@ -19,6 +19,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -76,7 +78,16 @@ func New(cfg Config) *Client {
|
||||
// Generous: a local model on modest hardware can take a while to produce
|
||||
// its first token, and the alternative is a timeout that only ever fires
|
||||
// for the people running their own.
|
||||
http: &http.Client{Timeout: 120 * time.Second},
|
||||
// safedial, not a plain client. `base_url` is typed into a field in the
|
||||
// interface, so it is a user-supplied address — and a server that fetches a
|
||||
// user-supplied address is a way to ask it to reach things the caller
|
||||
// cannot: the panel next door, a database on the same bridge, the cloud
|
||||
// metadata endpoint. On a homelab that is most of what is worth reaching.
|
||||
//
|
||||
// The check is on the resolved address rather than on the URL, because a
|
||||
// name answers whatever its owner says — and can answer differently the
|
||||
// second time, after a parse-time check has passed.
|
||||
http: safedial.Client(120 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,11 +279,35 @@ func (c *Client) do(req *http.Request, out any) error {
|
||||
// The provider's own message is included: for a local model it is usually
|
||||
// the actual problem ("model not found"), and hiding it would leave the
|
||||
// operator with nothing to go on.
|
||||
return fmt.Errorf("ai: %s said %s: %s", req.URL.Host, resp.Status, truncate(string(raw), 300))
|
||||
// The provider's body is no longer passed back to the caller.
|
||||
//
|
||||
// It was, and combined with a free-text base URL that made a working
|
||||
// internal scanner: point it at a host, read the first three hundred bytes
|
||||
// of whatever answered. The operator still needs the message — for a local
|
||||
// model it is usually the actual problem — so it goes to the log, where it
|
||||
// is available to the person running the instance and to nobody else.
|
||||
return &upstreamError{Host: req.URL.Host, Status: resp.Status, Body: truncate(string(raw), 300)}
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
// upstreamError keeps the provider's own words for the log and away from the
|
||||
// response. Error() is what reaches a caller; Detail() is what is written down.
|
||||
type upstreamError struct {
|
||||
Host string
|
||||
Status string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *upstreamError) Error() string {
|
||||
return fmt.Sprintf("ai: %s said %s", e.Host, e.Status)
|
||||
}
|
||||
|
||||
// Detail is the whole of it, for the instance's own error log.
|
||||
func (e *upstreamError) Detail() string {
|
||||
return fmt.Sprintf("ai: %s said %s: %s", e.Host, e.Status, e.Body)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,19 @@ type Config struct {
|
||||
// without spending the real one on every run.
|
||||
GmailSyncBudget time.Duration
|
||||
|
||||
// GmailEndpoints redirects the Gmail calls at a server the tests control. Empty
|
||||
// in production, where it means Google.
|
||||
GmailTokenURL string
|
||||
GmailAPIURL string
|
||||
// CalendarSyncBudget is the same bound for one calendar refresh, and exists for
|
||||
// the same reason: a slow Google held the request open past what Cloudflare is
|
||||
// willing to wait, and the browser got Cloudflare's HTML instead of an error
|
||||
// this server could explain.
|
||||
CalendarSyncBudget time.Duration
|
||||
|
||||
// Where Google is. Empty in production; a test points them at a server it
|
||||
// controls, which is the only way to exercise a Google that is slow or refuses.
|
||||
// The token endpoint is shared — Gmail and Calendar sign in through the same
|
||||
// registration — and the two APIs are not.
|
||||
GoogleTokenURL string
|
||||
GmailAPIURL string
|
||||
CalendarAPIURL string
|
||||
|
||||
PanelURL string
|
||||
PanelToken string
|
||||
@@ -98,14 +107,15 @@ func Load() (*Config, error) {
|
||||
TrashRetention: 30 * 24 * time.Hour,
|
||||
Dev: envBool("VERDANDE_DEV", false),
|
||||
|
||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||
GmailSyncBudget: 25 * time.Second,
|
||||
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||
GmailSyncBudget: 25 * time.Second,
|
||||
CalendarSyncBudget: 25 * time.Second,
|
||||
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
SecretKey: env("VERDANDE_SECRET_KEY", ""),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
|
||||
var err error
|
||||
@@ -152,6 +162,15 @@ func (c *Config) BackupsDir() string { return filepath.Join(c.DataDir, "backups"
|
||||
// configured separately and left to drift out of step with it.
|
||||
func (c *Config) GmailRedirectURL() string { return c.BaseURL + "/oauth/gmail/callback" }
|
||||
|
||||
// CalendarRedirectURL is a second registered URI rather than a reuse of Gmail's.
|
||||
//
|
||||
// One callback that decided from server-side state which of the two flows had come
|
||||
// back would save the operator one line in Google Cloud, and would put the choice
|
||||
// of *which token store to write* behind a lookup. When that goes wrong it goes
|
||||
// wrong by overwriting a working Gmail connection with calendar tokens — a failure
|
||||
// nobody would think to look for. Two paths cannot do that.
|
||||
func (c *Config) CalendarRedirectURL() string { return c.BaseURL + "/oauth/calendar/callback" }
|
||||
|
||||
func env(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
|
||||
@@ -60,6 +60,20 @@ func Parse(query string, ctx Context) (Compiled, error) {
|
||||
if len(p.tokens) == 0 {
|
||||
return Compiled{}, fmt.Errorf("filteret er tomt")
|
||||
}
|
||||
// Refused before it is parsed, not while.
|
||||
//
|
||||
// `combine` rebuilds the whole accumulated SQL string and re-copies the
|
||||
// argument slice per operator, so the cost is quadratic in the number of
|
||||
// terms: fifty thousand of them — three hundred kilobytes, which fits in a
|
||||
// query string — took seven seconds of CPU and forty-six gigabytes of
|
||||
// allocation in one request. The preview endpoint runs this on whatever is
|
||||
// typed, and there is no write timeout on the server to cut it off.
|
||||
//
|
||||
// Two hundred terms is a filter nobody has ever written and far below where it
|
||||
// costs anything.
|
||||
if len(p.tokens) > maxTokens {
|
||||
return Compiled{}, fmt.Errorf("filteret er for langt")
|
||||
}
|
||||
|
||||
node, err := p.parseExpression()
|
||||
if err != nil {
|
||||
@@ -140,10 +154,19 @@ func isOperator(r rune) bool {
|
||||
|
||||
// --- parser -------------------------------------------------------------------
|
||||
|
||||
// The ceilings on one filter. See Parse for why.
|
||||
const (
|
||||
maxTokens = 200
|
||||
maxDepth = 32
|
||||
)
|
||||
|
||||
type parser struct {
|
||||
tokens []token
|
||||
pos int
|
||||
ctx Context
|
||||
// depth guards against a filter that is only parentheses. Each level is a
|
||||
// stack frame, and nothing else stops somebody sending a megabyte of them.
|
||||
depth int
|
||||
}
|
||||
|
||||
func (p *parser) done() bool { return p.pos >= len(p.tokens) }
|
||||
@@ -187,6 +210,13 @@ func (p *parser) parseTerm() (Compiled, error) {
|
||||
if p.done() {
|
||||
return Compiled{}, fmt.Errorf("filteret slutter midt i et udtryk")
|
||||
}
|
||||
// Nesting is stack depth. A filter made of half a million opening parentheses
|
||||
// is a megabyte of text and a stack frame for each.
|
||||
p.depth++
|
||||
defer func() { p.depth-- }()
|
||||
if p.depth > maxDepth {
|
||||
return Compiled{}, fmt.Errorf("filteret har for mange niveauer")
|
||||
}
|
||||
|
||||
switch t := p.peek(); t.kind {
|
||||
case tokNot:
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
// Package gcal reads a Google Calendar. It never writes one.
|
||||
//
|
||||
// The scope is calendar.readonly, and that is the whole of the decision: verdande
|
||||
// already is a CalDAV server, so a Google event shown here is a second calendar
|
||||
// laid over the first, not a copy Google and verdande now both have to agree
|
||||
// about. Writing needs `calendar.events`, and it needs an answer to what happens
|
||||
// when both sides moved the same meeting — which is a synchronisation model, not a
|
||||
// scope. Read first; write later and to one calendar at a time.
|
||||
//
|
||||
// What the API gives back and what this returns are deliberately different shapes.
|
||||
// Google answers with RFC 3339 instants for a timed event and bare dates for an
|
||||
// all-day one, and with an *exclusive* end date for the second. A calendar grid
|
||||
// asks one question — "which cells does this event cover" — so that is what an
|
||||
// Event answers, in days, worked out once here rather than in every caller.
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/google"
|
||||
)
|
||||
|
||||
// Scope is read-only, and only the calendar.
|
||||
//
|
||||
// Google also offers calendar.calendarlist.readonly and calendar.events.readonly,
|
||||
// which together are strictly less than this — they leave out access rules and
|
||||
// account settings, which verdande has no use for. They are the tighter pair and
|
||||
// worth moving to; they are also newer, and this one has been documented and
|
||||
// accepted unchanged for a decade. A consent screen that fails with `invalid_scope`
|
||||
// is not a place to be adventurous on somebody else's Google account.
|
||||
const Scope = "https://www.googleapis.com/auth/calendar.readonly"
|
||||
|
||||
const defaultAPIBase = "https://www.googleapis.com/calendar/v3"
|
||||
|
||||
// The OAuth flow, which is Google's rather than the calendar's — the same one
|
||||
// Gmail signs in through, asking for the scope above. Aliased so a caller reads
|
||||
// one package for one feature.
|
||||
type (
|
||||
Config = google.Config
|
||||
PKCE = google.PKCE
|
||||
Token = google.Token
|
||||
)
|
||||
|
||||
var NewPKCE = google.NewPKCE
|
||||
|
||||
// ErrUnauthorized means the tokens are no longer good — access was revoked, or the
|
||||
// password changed. Re-exported so a caller need not know the flow lives elsewhere.
|
||||
var ErrUnauthorized = google.ErrUnauthorized
|
||||
|
||||
type Client struct {
|
||||
accessToken string
|
||||
// base is where the Calendar API is. A field rather than a constant so a test
|
||||
// can point it at a server it controls; empty is Google. There is no other way
|
||||
// to exercise a calendar that is slow, empty, or refuses.
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(accessToken string) *Client {
|
||||
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
// At points the client at another server. Tests only; the zero value is Google.
|
||||
func (c *Client) At(base string) *Client {
|
||||
c.base = base
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) api() string {
|
||||
if c.base != "" {
|
||||
return c.base
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// Calendar is one entry in the person's calendar list.
|
||||
type Calendar struct {
|
||||
ID string
|
||||
// Name is the summary Google holds. A calendar somebody renamed for themselves
|
||||
// carries summaryOverride instead, and that is the name they will look for.
|
||||
Name string
|
||||
// Colour is Google's own hex value for the calendar, which is what makes two
|
||||
// calendars in one grid tellable apart. Their own colour rather than one picked
|
||||
// here: it is the colour they already know the calendar by.
|
||||
Colour string
|
||||
TimeZone string
|
||||
Primary bool
|
||||
// Owner means the connected account can write to it. Nothing writes today —
|
||||
// it is stored so the day something does, the interface knows which calendars
|
||||
// could even be offered.
|
||||
Owner bool
|
||||
}
|
||||
|
||||
// Calendars lists what the connected account can see.
|
||||
func (c *Client) Calendars(ctx context.Context) ([]Calendar, error) {
|
||||
var out []Calendar
|
||||
page := ""
|
||||
// Bounded rather than "until Google stops": a page token that never empties is
|
||||
// a loop that never ends, and the difference between a bug at Google and a
|
||||
// hung sync should not be this server hanging.
|
||||
for range 10 {
|
||||
v := url.Values{}
|
||||
v.Set("minAccessRole", "reader")
|
||||
v.Set("maxResults", "250")
|
||||
if page != "" {
|
||||
v.Set("pageToken", page)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
SummaryOverride string `json:"summaryOverride"`
|
||||
BackgroundColor string `json:"backgroundColor"`
|
||||
TimeZone string `json:"timeZone"`
|
||||
Primary bool `json:"primary"`
|
||||
AccessRole string `json:"accessRole"`
|
||||
Deleted bool `json:"deleted"`
|
||||
} `json:"items"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
if err := c.get(ctx, c.api()+"/users/me/calendarList?"+v.Encode(), &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range parsed.Items {
|
||||
if item.Deleted {
|
||||
continue
|
||||
}
|
||||
name := item.SummaryOverride
|
||||
if name == "" {
|
||||
name = item.Summary
|
||||
}
|
||||
out = append(out, Calendar{
|
||||
ID: item.ID, Name: name, Colour: item.BackgroundColor,
|
||||
TimeZone: item.TimeZone, Primary: item.Primary,
|
||||
Owner: item.AccessRole == "owner" || item.AccessRole == "writer",
|
||||
})
|
||||
}
|
||||
if parsed.NextPageToken == "" {
|
||||
break
|
||||
}
|
||||
page = parsed.NextPageToken
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Event is one occurrence, in the shape a calendar grid asks about.
|
||||
type Event struct {
|
||||
ID string
|
||||
Summary string
|
||||
// StartsAt and EndsAt are RFC 3339 for a timed event and empty for an all-day
|
||||
// one — an all-day event has no instant, only a day, and inventing midnight for
|
||||
// it is how an event moves a day every time a server changes time zone.
|
||||
StartsAt string
|
||||
EndsAt string
|
||||
// StartDay and EndDay are the first and last day the event covers, inclusive at
|
||||
// both ends. Google's all-day end date is exclusive; that is corrected here,
|
||||
// once, rather than in every reader.
|
||||
StartDay string
|
||||
EndDay string
|
||||
AllDay bool
|
||||
Location string
|
||||
// URL opens the event in Google's own interface. Same reasoning as the Gmail
|
||||
// link on a task made from a mail: what verdande shows is a pointer to the
|
||||
// event, not a second copy of it that somebody could try to edit.
|
||||
URL string
|
||||
}
|
||||
|
||||
// Events lists what falls inside a window, one calendar at a time.
|
||||
//
|
||||
// `singleEvents` expands a recurrence into its occurrences, which is the only form
|
||||
// a grid can draw: an RRULE is an instruction, and a Tuesday cell needs the Tuesday
|
||||
// it produced. It also means verdande does not have to grow a second recurrence
|
||||
// engine that agrees with Google's about Danish summer time.
|
||||
//
|
||||
// from and to are dates, inclusive. The window is the caller's business; this
|
||||
// turns it into the half-open interval Google wants.
|
||||
func (c *Client) Events(ctx context.Context, calendarID, from, to string) ([]Event, error) {
|
||||
start, err := time.Parse(dateLayout, from)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcal: from: %w", err)
|
||||
}
|
||||
end, err := time.Parse(dateLayout, to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcal: to: %w", err)
|
||||
}
|
||||
|
||||
var out []Event
|
||||
page := ""
|
||||
for range 20 {
|
||||
v := url.Values{}
|
||||
v.Set("singleEvents", "true")
|
||||
v.Set("orderBy", "startTime")
|
||||
v.Set("maxResults", "2500")
|
||||
// UTC, and a day of slack at each end. An event at 23:30 on the last day in
|
||||
// Copenhagen is 21:30 UTC that day, but one at 00:30 on the first day is
|
||||
// 22:30 UTC the day *before* — so a window cut exactly at the requested
|
||||
// dates loses the events at both edges. The extra day is thrown away again
|
||||
// by the day fields below, which are computed from the event's own offset.
|
||||
v.Set("timeMin", start.AddDate(0, 0, -1).Format(time.RFC3339))
|
||||
v.Set("timeMax", end.AddDate(0, 0, 2).Format(time.RFC3339))
|
||||
if page != "" {
|
||||
v.Set("pageToken", page)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Items []rawEvent `json:"items"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
url := c.api() + "/calendars/" + url.PathEscape(calendarID) + "/events?" + v.Encode()
|
||||
if err := c.get(ctx, url, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range parsed.Items {
|
||||
// A cancelled occurrence of a recurring event still comes back, so that
|
||||
// a client keeping its own copy knows to drop it. verdande replaces the
|
||||
// window wholesale, so it only has to not add it.
|
||||
if item.Status == "cancelled" {
|
||||
continue
|
||||
}
|
||||
event, ok := item.event()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if event.EndDay < from || event.StartDay > to {
|
||||
continue // the slack above, thrown away
|
||||
}
|
||||
out = append(out, event)
|
||||
}
|
||||
if parsed.NextPageToken == "" {
|
||||
break
|
||||
}
|
||||
page = parsed.NextPageToken
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
const dateLayout = "2006-01-02"
|
||||
|
||||
type rawEvent struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
Location string `json:"location"`
|
||||
HTMLLink string `json:"htmlLink"`
|
||||
Start when `json:"start"`
|
||||
End when `json:"end"`
|
||||
}
|
||||
|
||||
type when struct {
|
||||
Date string `json:"date"`
|
||||
DateTime string `json:"dateTime"`
|
||||
}
|
||||
|
||||
// event turns Google's two shapes into the one a grid can read.
|
||||
//
|
||||
// The second return is false for an event with neither a date nor a dateTime —
|
||||
// which Google does return, for an event somebody is still being asked about. It
|
||||
// belongs on no day, so there is no cell to draw it in.
|
||||
func (r rawEvent) event() (Event, bool) {
|
||||
e := Event{
|
||||
ID: r.ID, Summary: strings.TrimSpace(r.Summary),
|
||||
Location: r.Location, URL: r.HTMLLink,
|
||||
}
|
||||
if e.Summary == "" {
|
||||
// Google's own interface says this for an event with no title, and a blank
|
||||
// chip in a grid reads as a rendering fault rather than as an empty title.
|
||||
e.Summary = "(uden titel)"
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.Start.Date != "":
|
||||
e.AllDay = true
|
||||
e.StartDay = r.Start.Date
|
||||
// Google's all-day end is the day *after* the last one, so a one-day event
|
||||
// ends tomorrow. Subtracting a day is what makes "covers this cell" a
|
||||
// comparison rather than a special case at every call site.
|
||||
e.EndDay = r.Start.Date
|
||||
if day, err := time.Parse(dateLayout, r.End.Date); err == nil {
|
||||
if last := day.AddDate(0, 0, -1); !last.Before(mustDay(r.Start.Date)) {
|
||||
e.EndDay = last.Format(dateLayout)
|
||||
}
|
||||
}
|
||||
case r.Start.DateTime != "":
|
||||
e.StartsAt = r.Start.DateTime
|
||||
e.EndsAt = r.End.DateTime
|
||||
// The day is the date part of the timestamp Google returned, which carries
|
||||
// the event's own offset. Parsing it into a time.Time and formatting it
|
||||
// back would ask *this process* what day it is — and a container running in
|
||||
// UTC would then move a 23:30 meeting in Copenhagen to the day before.
|
||||
e.StartDay = dayOf(r.Start.DateTime)
|
||||
e.EndDay = dayOf(r.End.DateTime)
|
||||
if e.EndDay == "" {
|
||||
e.EndDay = e.StartDay
|
||||
}
|
||||
// An event ending at midnight ends the evening before, as far as a grid is
|
||||
// concerned. Without this, every meeting that runs to 24:00 puts a chip in
|
||||
// the following day as well.
|
||||
if e.EndDay > e.StartDay && strings.Contains(r.End.DateTime, "T00:00:00") {
|
||||
if day, err := time.Parse(dateLayout, e.EndDay); err == nil {
|
||||
e.EndDay = day.AddDate(0, 0, -1).Format(dateLayout)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return Event{}, false
|
||||
}
|
||||
if e.EndDay < e.StartDay {
|
||||
e.EndDay = e.StartDay
|
||||
}
|
||||
return e, true
|
||||
}
|
||||
|
||||
// dayOf takes the date out of an RFC 3339 timestamp without interpreting it.
|
||||
func dayOf(stamp string) string {
|
||||
if len(stamp) < len(dateLayout) {
|
||||
return ""
|
||||
}
|
||||
day := stamp[:len(dateLayout)]
|
||||
if _, err := time.Parse(dateLayout, day); err != nil {
|
||||
return ""
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
func mustDay(s string) time.Time {
|
||||
day, _ := time.Parse(dateLayout, s)
|
||||
return day
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, url string, out any) error {
|
||||
return google.Get(ctx, c.http, c.accessToken, url, out)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeGoogle answers the two calls this package makes, out of whatever the test
|
||||
// hands it. There is no other way to exercise this: the real Calendar needs an
|
||||
// account, a registered client and somebody's consent, and none of the three can be
|
||||
// had inside a test — so the shapes Google returns are written down here and the
|
||||
// parsing is checked against them.
|
||||
func fakeGoogle(t *testing.T, calendarList any, events map[string]any) *Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"):
|
||||
_ = json.NewEncoder(w).Encode(calendarList)
|
||||
case strings.HasPrefix(r.URL.Path, "/calendars/"):
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events")
|
||||
body, ok := events[id]
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
default:
|
||||
http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return NewClient("token").At(srv.URL)
|
||||
}
|
||||
|
||||
func TestCalendarsPrefersTheNameThePersonGaveIt(t *testing.T) {
|
||||
client := fakeGoogle(t, map[string]any{
|
||||
"items": []map[string]any{
|
||||
{"id": "primary", "summary": "[email protected]", "backgroundColor": "#3f51b5",
|
||||
"timeZone": "Europe/Copenhagen", "primary": true, "accessRole": "owner"},
|
||||
{"id": "fam", "summary": "Family", "summaryOverride": "Familien",
|
||||
"backgroundColor": "#0b8043", "accessRole": "reader"},
|
||||
// A calendar removed from the list still arrives, flagged.
|
||||
{"id": "gone", "summary": "Væk", "deleted": true, "accessRole": "reader"},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
got, err := client.Calendars(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d calendars, want 2 — a deleted one is not one you can show", len(got))
|
||||
}
|
||||
if got[1].Name != "Familien" {
|
||||
t.Errorf("name = %q; summaryOverride is the name the person will look for", got[1].Name)
|
||||
}
|
||||
if !got[0].Primary || !got[0].Owner {
|
||||
t.Errorf("the primary calendar came back as %+v", got[0])
|
||||
}
|
||||
if got[1].Owner {
|
||||
t.Error("a reader was reported as an owner; nothing may offer to write to it")
|
||||
}
|
||||
if got[0].Colour != "#3f51b5" {
|
||||
t.Errorf("colour = %q; two calendars in one grid are told apart by it", got[0].Colour)
|
||||
}
|
||||
}
|
||||
|
||||
// The three shapes an event arrives in, and the one shape a grid can read.
|
||||
func TestEventsBecomeDaysAGridCanAskAbout(t *testing.T) {
|
||||
client := fakeGoogle(t, nil, map[string]any{
|
||||
"primary": map[string]any{
|
||||
"items": []map[string]any{
|
||||
{
|
||||
"id": "timed", "summary": "Bestyrelsesmøde", "htmlLink": "https://cal/1",
|
||||
"start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"},
|
||||
"end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"},
|
||||
},
|
||||
{
|
||||
// All-day, one day. Google's end is the day after.
|
||||
"id": "allday", "summary": "Fridag",
|
||||
"start": map[string]string{"date": "2026-08-21"},
|
||||
"end": map[string]string{"date": "2026-08-22"},
|
||||
},
|
||||
{
|
||||
// All-day, three days.
|
||||
"id": "trip", "summary": "Aarhus",
|
||||
"start": map[string]string{"date": "2026-08-24"},
|
||||
"end": map[string]string{"date": "2026-08-27"},
|
||||
},
|
||||
{
|
||||
// Runs to midnight, which is the evening before as far as a
|
||||
// grid is concerned.
|
||||
"id": "late", "summary": "Nattevagt",
|
||||
"start": map[string]string{"dateTime": "2026-08-22T20:00:00+02:00"},
|
||||
"end": map[string]string{"dateTime": "2026-08-23T00:00:00+02:00"},
|
||||
},
|
||||
{
|
||||
// A cancelled occurrence of a recurring event.
|
||||
"id": "off", "status": "cancelled", "summary": "Aflyst",
|
||||
"start": map[string]string{"date": "2026-08-25"},
|
||||
"end": map[string]string{"date": "2026-08-26"},
|
||||
},
|
||||
{
|
||||
// Neither a date nor a dateTime: belongs on no day at all.
|
||||
"id": "nowhere", "summary": "Uden tid",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
got, err := client.Events(t.Context(), "primary", "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
byID := map[string]Event{}
|
||||
for _, e := range got {
|
||||
byID[e.ID] = e
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("got %d events, want 4: %+v", len(got), got)
|
||||
}
|
||||
if _, ok := byID["off"]; ok {
|
||||
t.Error("a cancelled occurrence was kept; it is an event that is not happening")
|
||||
}
|
||||
if _, ok := byID["nowhere"]; ok {
|
||||
t.Error("an event with no time was kept; there is no cell to draw it in")
|
||||
}
|
||||
|
||||
timed := byID["timed"]
|
||||
if timed.AllDay || timed.StartDay != "2026-08-20" || timed.EndDay != "2026-08-20" {
|
||||
t.Errorf("timed = %+v", timed)
|
||||
}
|
||||
if timed.StartsAt != "2026-08-20T14:00:00+02:00" {
|
||||
t.Errorf("the instant was rewritten: %q", timed.StartsAt)
|
||||
}
|
||||
|
||||
allday := byID["allday"]
|
||||
if !allday.AllDay || allday.StartDay != "2026-08-21" || allday.EndDay != "2026-08-21" {
|
||||
t.Errorf("allday = %+v; Google's end date is exclusive and has to be corrected here",
|
||||
allday)
|
||||
}
|
||||
if allday.StartsAt != "" {
|
||||
t.Errorf("an all-day event was given an instant: %q — it has a day, not a moment",
|
||||
allday.StartsAt)
|
||||
}
|
||||
|
||||
if trip := byID["trip"]; trip.StartDay != "2026-08-24" || trip.EndDay != "2026-08-26" {
|
||||
t.Errorf("trip = %+v, want 24th to 26th inclusive", trip)
|
||||
}
|
||||
if late := byID["late"]; late.EndDay != "2026-08-22" {
|
||||
t.Errorf("late ends on %q; an event running to midnight ends the evening before, "+
|
||||
"or every one of them puts a chip in the following day", late.EndDay)
|
||||
}
|
||||
}
|
||||
|
||||
// An event with no title has to say so. A blank chip in a grid reads as a
|
||||
// rendering fault rather than as an empty title.
|
||||
func TestAnEventWithNoTitleStillSaysSomething(t *testing.T) {
|
||||
client := fakeGoogle(t, nil, map[string]any{
|
||||
"c": map[string]any{"items": []map[string]any{{
|
||||
"id": "x",
|
||||
"start": map[string]string{"date": "2026-08-20"},
|
||||
"end": map[string]string{"date": "2026-08-21"},
|
||||
}}},
|
||||
})
|
||||
got, err := client.Events(t.Context(), "c", "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Summary == "" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The window is asked for with a day of slack at each end, because an event late
|
||||
// in the evening in Copenhagen is the next day in UTC. The slack must not leak
|
||||
// out: an event on the day before the window is not in the window.
|
||||
func TestTheSlackAtTheEdgesIsThrownAwayAgain(t *testing.T) {
|
||||
client := fakeGoogle(t, nil, map[string]any{
|
||||
"c": map[string]any{"items": []map[string]any{
|
||||
{"id": "before", "summary": "For tidligt",
|
||||
"start": map[string]string{"date": "2026-08-09"},
|
||||
"end": map[string]string{"date": "2026-08-10"}},
|
||||
{"id": "inside", "summary": "Med",
|
||||
"start": map[string]string{"date": "2026-08-10"},
|
||||
"end": map[string]string{"date": "2026-08-11"}},
|
||||
{"id": "after", "summary": "For sent",
|
||||
"start": map[string]string{"date": "2026-08-21"},
|
||||
"end": map[string]string{"date": "2026-08-22"}},
|
||||
}},
|
||||
})
|
||||
got, err := client.Events(t.Context(), "c", "2026-08-10", "2026-08-20")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "inside" {
|
||||
t.Fatalf("got %+v, want only the one inside the window", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A page token has to be followed, or a busy calendar silently shows its first
|
||||
// page and nothing else — which is a calendar that lies about a day being clear.
|
||||
func TestEveryPageIsRead(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
body := map[string]any{
|
||||
"items": []map[string]any{{"id": "a", "summary": "Første",
|
||||
"start": map[string]string{"date": "2026-08-11"},
|
||||
"end": map[string]string{"date": "2026-08-12"}}},
|
||||
"nextPageToken": "more",
|
||||
}
|
||||
if r.URL.Query().Get("pageToken") == "more" {
|
||||
body = map[string]any{"items": []map[string]any{{"id": "b", "summary": "Anden",
|
||||
"start": map[string]string{"date": "2026-08-12"},
|
||||
"end": map[string]string{"date": "2026-08-13"}}}}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := NewClient("t").At(srv.URL).Events(t.Context(), "c", "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d events, want both pages: %+v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// A 401 is not a failure to retry; it is a connection that has ended. Telling the
|
||||
// two apart is what stops a revoked account being polled every ten minutes for ever.
|
||||
func TestARefusedTokenIsItsOwnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := NewClient("spent").At(srv.URL).Calendars(t.Context())
|
||||
if err != ErrUnauthorized {
|
||||
t.Fatalf("err = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Read-only, and only the calendar. The consent screen is what somebody agrees to,
|
||||
// and a scope that quietly grew is a promise quietly broken.
|
||||
func TestTheScopeIsReadOnly(t *testing.T) {
|
||||
if !strings.HasSuffix(Scope, "calendar.readonly") {
|
||||
t.Errorf("scope = %q; nothing here writes a calendar", Scope)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package gmail implements the OAuth2 authorisation-code flow with PKCE against
|
||||
// Google, and the small slice of the Gmail API verdande needs.
|
||||
// Package gmail is the small slice of the Gmail API verdande needs, and the scope
|
||||
// it asks for.
|
||||
//
|
||||
// The connection is one-way on purpose: a starred or labelled message becomes a
|
||||
// task, and nothing verdande does afterwards touches the mailbox. Unstarring the
|
||||
@@ -9,200 +9,51 @@
|
||||
//
|
||||
// Scope is gmail.readonly. verdande never needs to send, modify or delete, and
|
||||
// asking for less is what makes the consent screen something a person can agree to.
|
||||
//
|
||||
// The OAuth flow itself moved to internal/google when Calendar needed the same one.
|
||||
// The names below are aliases so that every call site — and this package's own
|
||||
// tests — still read as Gmail's, which is what they are: one registration, one
|
||||
// flow, one scope each.
|
||||
package gmail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/google"
|
||||
)
|
||||
|
||||
const (
|
||||
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||
const Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
|
||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||
Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||
|
||||
// The OAuth flow, which is Google's rather than Gmail's.
|
||||
type (
|
||||
Config = google.Config
|
||||
PKCE = google.PKCE
|
||||
Token = google.Token
|
||||
)
|
||||
|
||||
// Endpoints is where Google is.
|
||||
//
|
||||
// A field rather than a constant so a test can point it at a server it controls.
|
||||
// There is no other way to exercise what happens when Gmail is slow or refuses —
|
||||
// and "the sync comes back before whatever sits in front of this server gives up
|
||||
// on it" is exactly the kind of promise worth a test and impossible to make
|
||||
// against the real thing.
|
||||
//
|
||||
// The zero value is Google, so nothing outside a test has to know it exists.
|
||||
type Endpoints struct {
|
||||
Token string
|
||||
API string
|
||||
}
|
||||
|
||||
func (e Endpoints) token() string {
|
||||
if e.Token != "" {
|
||||
return e.Token
|
||||
}
|
||||
return defaultTokenEndpoint
|
||||
}
|
||||
|
||||
func (e Endpoints) api() string {
|
||||
if e.API != "" {
|
||||
return e.API
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
|
||||
// the instance rather than to a person: one registration, any number of users.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
// Endpoints is Google unless a test says otherwise.
|
||||
Endpoints Endpoints
|
||||
}
|
||||
|
||||
func (c Config) Configured() bool {
|
||||
return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
|
||||
}
|
||||
|
||||
// PKCE is the proof-key pair for one authorisation attempt.
|
||||
//
|
||||
// PKCE on a server-side client is not strictly required — the client secret already
|
||||
// authenticates the token exchange — but it costs nothing and closes the window in
|
||||
// which an authorisation code intercepted from the redirect could be replayed
|
||||
// before verdande gets to it.
|
||||
type PKCE struct {
|
||||
Verifier string
|
||||
Challenge string
|
||||
State string
|
||||
}
|
||||
|
||||
func NewPKCE() (PKCE, error) {
|
||||
verifier, err := randomString(64)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
state, err := randomString(32)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return PKCE{
|
||||
Verifier: verifier,
|
||||
Challenge: base64.RawURLEncoding.EncodeToString(sum[:]),
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AuthURL is where the browser is sent to ask the person for consent.
|
||||
func (c Config) AuthURL(p PKCE) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", c.ClientID)
|
||||
v.Set("redirect_uri", c.RedirectURL)
|
||||
v.Set("response_type", "code")
|
||||
v.Set("scope", Scope)
|
||||
v.Set("state", p.State)
|
||||
v.Set("code_challenge", p.Challenge)
|
||||
v.Set("code_challenge_method", "S256")
|
||||
// Without both of these Google returns no refresh token on a re-authorisation,
|
||||
// and the connection silently stops working an hour later.
|
||||
v.Set("access_type", "offline")
|
||||
v.Set("prompt", "consent")
|
||||
return authEndpoint + "?" + v.Encode()
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Exchange trades the authorisation code for tokens.
|
||||
func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("code", code)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("redirect_uri", c.RedirectURL)
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code_verifier", p.Verifier)
|
||||
|
||||
return c.token(ctx, form)
|
||||
}
|
||||
|
||||
// Refresh gets a fresh access token. Google does not return a new refresh token
|
||||
// here, so the caller keeps the one it has.
|
||||
func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("refresh_token", refreshToken)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("grant_type", "refresh_token")
|
||||
|
||||
token, err := c.token(ctx, form)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
token.RefreshToken = refreshToken
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoints.token(),
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var parsed struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return Token{}, fmt.Errorf("gmail: reading token response: %w", err)
|
||||
}
|
||||
if parsed.Error != "" {
|
||||
return Token{}, fmt.Errorf("gmail: %s: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
|
||||
return Token{}, fmt.Errorf("gmail: token exchange failed with %s", resp.Status)
|
||||
}
|
||||
|
||||
return Token{
|
||||
AccessToken: parsed.AccessToken,
|
||||
RefreshToken: parsed.RefreshToken,
|
||||
// A minute of slack, so a token that is about to expire is refreshed
|
||||
// rather than used and rejected.
|
||||
ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
var (
|
||||
NewPKCE = google.NewPKCE
|
||||
ErrUnauthorized = google.ErrUnauthorized
|
||||
)
|
||||
|
||||
// --- the API ---------------------------------------------------------------------
|
||||
|
||||
type Client struct {
|
||||
accessToken string
|
||||
endpoints Endpoints
|
||||
http *http.Client
|
||||
// base is where the Gmail API is. A field rather than a constant so a test can
|
||||
// point it at a server it controls — and "the sync comes back before whatever
|
||||
// sits in front of this server gives up on it" is exactly the kind of promise
|
||||
// worth a test and impossible to make against the real thing. Empty is Google.
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(accessToken string) *Client {
|
||||
@@ -210,11 +61,18 @@ func NewClient(accessToken string) *Client {
|
||||
}
|
||||
|
||||
// At points the client at another server. Tests only; the zero value is Google.
|
||||
func (c *Client) At(e Endpoints) *Client {
|
||||
c.endpoints = e
|
||||
func (c *Client) At(base string) *Client {
|
||||
c.base = base
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) api() string {
|
||||
if c.base != "" {
|
||||
return c.base
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// Message is the part of a Gmail message a task is made from.
|
||||
type Message struct {
|
||||
ID string
|
||||
@@ -233,7 +91,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) {
|
||||
var parsed struct {
|
||||
EmailAddress string `json:"emailAddress"`
|
||||
}
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/profile", &parsed); err != nil {
|
||||
if err := c.get(ctx, c.api()+"/users/me/profile", &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parsed.EmailAddress, nil
|
||||
@@ -257,7 +115,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err
|
||||
ID string `json:"id"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
||||
if err := c.get(ctx, c.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -288,7 +146,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
||||
} `json:"headers"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
||||
if err := c.get(ctx, c.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
|
||||
@@ -308,31 +166,9 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, url string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("gmail: %s", resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
return google.Get(ctx, c.http, c.accessToken, url, out)
|
||||
}
|
||||
|
||||
// ErrUnauthorized means the tokens are no longer good — the person revoked access,
|
||||
// or changed their password. The caller disconnects rather than retrying.
|
||||
var ErrUnauthorized = fmt.Errorf("gmail: authorisation was refused")
|
||||
|
||||
// SenderName pulls "Anders Jensen" out of "Anders Jensen <[email protected]>",
|
||||
// falling back to the address when there is no display name.
|
||||
func SenderName(from string) string {
|
||||
@@ -375,11 +211,3 @@ func Query(trigger, label string) string {
|
||||
terms = append(terms, "newer_than:30d")
|
||||
return strings.Join(terms, " ")
|
||||
}
|
||||
|
||||
func randomString(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
|
||||
}
|
||||
|
||||
@@ -1,113 +1,19 @@
|
||||
package gmail
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The challenge has to be the SHA-256 of the verifier, base64url without padding.
|
||||
// Getting this wrong means Google refuses every exchange with a message that does
|
||||
// not say which end is at fault.
|
||||
func TestPKCEChallengeMatchesItsVerifier(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(p.Verifier))
|
||||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if p.Challenge != want {
|
||||
t.Errorf("challenge = %q, want %q", p.Challenge, want)
|
||||
}
|
||||
|
||||
// RFC 7636 requires 43–128 characters, and no padding.
|
||||
if n := len(p.Verifier); n < 43 || n > 128 {
|
||||
t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n)
|
||||
}
|
||||
if strings.ContainsAny(p.Verifier, "=+/") {
|
||||
t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier)
|
||||
}
|
||||
if p.State == "" {
|
||||
t.Error("no state was generated")
|
||||
}
|
||||
|
||||
// Two attempts must not share anything.
|
||||
other, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other.Verifier == p.Verifier || other.State == p.State {
|
||||
t.Error("two authorisation attempts produced the same secrets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthURL(t *testing.T) {
|
||||
cfg := Config{
|
||||
ClientID: "client-123",
|
||||
ClientSecret: "secret",
|
||||
RedirectURL: "https://todo.example.dk/oauth/gmail/callback",
|
||||
}
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw := cfg.AuthURL(p)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the URL does not parse: %v", err)
|
||||
}
|
||||
q := parsed.Query()
|
||||
|
||||
checks := map[string]string{
|
||||
"client_id": cfg.ClientID,
|
||||
"redirect_uri": cfg.RedirectURL,
|
||||
"response_type": "code",
|
||||
"scope": Scope,
|
||||
"code_challenge": p.Challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": p.State,
|
||||
// Without both of these Google returns no refresh token on a
|
||||
// re-authorisation, and the connection dies silently an hour later.
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
}
|
||||
for key, want := range checks {
|
||||
if got := q.Get(key); got != want {
|
||||
t.Errorf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The verifier itself must never leave the server.
|
||||
if strings.Contains(raw, p.Verifier) {
|
||||
t.Error("the code verifier was put in the authorisation URL")
|
||||
}
|
||||
// Read-only, and only mail.
|
||||
// The scope is the one thing verdande chooses about the connection, and it is the
|
||||
// one the person reads on the consent screen. The flow itself is exercised in
|
||||
// internal/google; what belongs here is that Gmail still asks for read-only mail.
|
||||
func TestTheScopeIsReadOnlyMail(t *testing.T) {
|
||||
if !strings.HasSuffix(Scope, "gmail.readonly") {
|
||||
t.Errorf("scope = %q; verdande never needs to send or modify", Scope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigured(t *testing.T) {
|
||||
full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"}
|
||||
if !full.Configured() {
|
||||
t.Error("a complete config is not reported as configured")
|
||||
}
|
||||
for _, partial := range []Config{
|
||||
{},
|
||||
{ClientID: "a"},
|
||||
{ClientID: "a", ClientSecret: "b"},
|
||||
{ClientSecret: "b", RedirectURL: "c"},
|
||||
} {
|
||||
if partial.Configured() {
|
||||
t.Errorf("%+v was reported as configured", partial)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
trigger, label string
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// Package google implements the OAuth2 authorisation-code flow with PKCE against
|
||||
// Google.
|
||||
//
|
||||
// It was written inside internal/gmail, because Gmail was the only thing that
|
||||
// needed it. Calendar needs the same flow — the same endpoints, the same PKCE, the
|
||||
// same refresh — and the only thing that differs between them is one string: the
|
||||
// scope. A second copy would be a second place for "access_type=offline" to be
|
||||
// forgotten, which is the mistake that makes a connection die silently an hour
|
||||
// after it was made.
|
||||
//
|
||||
// What is *not* here is the API calls. Those differ completely, so gmail and gcal
|
||||
// keep their own clients and share only the way in.
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
)
|
||||
|
||||
// Config is the OAuth client the operator registered in Google Cloud, plus the
|
||||
// scope one feature is asking for.
|
||||
//
|
||||
// The client belongs to the instance rather than to a person: one registration,
|
||||
// any number of users. The scope belongs to the feature: Gmail and Calendar sign
|
||||
// in through the same registration and ask for different things, and Google issues
|
||||
// a refresh token per authorisation — so connecting one does not disturb the other.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
Scope string
|
||||
|
||||
// TokenURL is where Google is. A field rather than a constant so a test can
|
||||
// point it at a server it controls: there is no other way to exercise what
|
||||
// happens when the token endpoint is slow or refuses. Empty is Google, so
|
||||
// nothing outside a test has to know it exists.
|
||||
TokenURL string
|
||||
}
|
||||
|
||||
func (c Config) Configured() bool {
|
||||
return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
|
||||
}
|
||||
|
||||
func (c Config) tokenURL() string {
|
||||
if c.TokenURL != "" {
|
||||
return c.TokenURL
|
||||
}
|
||||
return defaultTokenEndpoint
|
||||
}
|
||||
|
||||
// PKCE is the proof-key pair for one authorisation attempt.
|
||||
//
|
||||
// PKCE on a server-side client is not strictly required — the client secret already
|
||||
// authenticates the token exchange — but it costs nothing and closes the window in
|
||||
// which an authorisation code intercepted from the redirect could be replayed
|
||||
// before verdande gets to it.
|
||||
type PKCE struct {
|
||||
Verifier string
|
||||
Challenge string
|
||||
State string
|
||||
}
|
||||
|
||||
func NewPKCE() (PKCE, error) {
|
||||
verifier, err := randomString(64)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
state, err := randomString(32)
|
||||
if err != nil {
|
||||
return PKCE{}, err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return PKCE{
|
||||
Verifier: verifier,
|
||||
Challenge: base64.RawURLEncoding.EncodeToString(sum[:]),
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AuthURL is where the browser is sent to ask the person for consent.
|
||||
func (c Config) AuthURL(p PKCE) string {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", c.ClientID)
|
||||
v.Set("redirect_uri", c.RedirectURL)
|
||||
v.Set("response_type", "code")
|
||||
v.Set("scope", c.Scope)
|
||||
v.Set("state", p.State)
|
||||
v.Set("code_challenge", p.Challenge)
|
||||
v.Set("code_challenge_method", "S256")
|
||||
// Without both of these Google returns no refresh token on a re-authorisation,
|
||||
// and the connection silently stops working an hour later.
|
||||
v.Set("access_type", "offline")
|
||||
v.Set("prompt", "consent")
|
||||
return authEndpoint + "?" + v.Encode()
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Exchange trades the authorisation code for tokens.
|
||||
func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("code", code)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("redirect_uri", c.RedirectURL)
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code_verifier", p.Verifier)
|
||||
|
||||
return c.token(ctx, form)
|
||||
}
|
||||
|
||||
// Refresh gets a fresh access token. Google does not return a new refresh token
|
||||
// here, so the caller keeps the one it has.
|
||||
func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("refresh_token", refreshToken)
|
||||
form.Set("client_id", c.ClientID)
|
||||
form.Set("client_secret", c.ClientSecret)
|
||||
form.Set("grant_type", "refresh_token")
|
||||
|
||||
token, err := c.token(ctx, form)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
token.RefreshToken = refreshToken
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL(),
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var parsed struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return Token{}, fmt.Errorf("google: reading token response: %w", err)
|
||||
}
|
||||
if parsed.Error != "" {
|
||||
return Token{}, fmt.Errorf("google: %s: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" {
|
||||
return Token{}, fmt.Errorf("google: token exchange failed with %s", resp.Status)
|
||||
}
|
||||
|
||||
return Token{
|
||||
AccessToken: parsed.AccessToken,
|
||||
RefreshToken: parsed.RefreshToken,
|
||||
// A minute of slack, so a token that is about to expire is refreshed
|
||||
// rather than used and rejected.
|
||||
ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ErrUnauthorized means the tokens are no longer good — the person revoked access,
|
||||
// or changed their password. The caller disconnects rather than retrying.
|
||||
var ErrUnauthorized = fmt.Errorf("google: authorisation was refused")
|
||||
|
||||
// Get performs an authorised GET and decodes the JSON body into out.
|
||||
//
|
||||
// Shared by the two API clients because the interesting part is the same in both:
|
||||
// a 401 is not a failure to retry, it is a connection that has ended, and telling
|
||||
// those apart is what stops a revoked account being polled every ten minutes for
|
||||
// ever.
|
||||
func Get(ctx context.Context, client *http.Client, accessToken, url string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("google: %s", resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func randomString(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The challenge has to be the SHA-256 of the verifier, base64url without padding.
|
||||
// Getting this wrong means Google refuses every exchange with a message that does
|
||||
// not say which end is at fault.
|
||||
func TestPKCEChallengeMatchesItsVerifier(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(p.Verifier))
|
||||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if p.Challenge != want {
|
||||
t.Errorf("challenge = %q, want %q", p.Challenge, want)
|
||||
}
|
||||
|
||||
// RFC 7636 requires 43–128 characters, and no padding.
|
||||
if n := len(p.Verifier); n < 43 || n > 128 {
|
||||
t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n)
|
||||
}
|
||||
if strings.ContainsAny(p.Verifier, "=+/") {
|
||||
t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier)
|
||||
}
|
||||
if p.State == "" {
|
||||
t.Error("no state was generated")
|
||||
}
|
||||
|
||||
// Two attempts must not share anything.
|
||||
other, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other.Verifier == p.Verifier || other.State == p.State {
|
||||
t.Error("two authorisation attempts produced the same secrets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthURL(t *testing.T) {
|
||||
cfg := Config{
|
||||
ClientID: "client-123",
|
||||
ClientSecret: "secret",
|
||||
RedirectURL: "https://todo.example.dk/oauth/gmail/callback",
|
||||
Scope: "https://www.googleapis.com/auth/gmail.readonly",
|
||||
}
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw := cfg.AuthURL(p)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the URL does not parse: %v", err)
|
||||
}
|
||||
q := parsed.Query()
|
||||
|
||||
checks := map[string]string{
|
||||
"client_id": cfg.ClientID,
|
||||
"redirect_uri": cfg.RedirectURL,
|
||||
"response_type": "code",
|
||||
"scope": cfg.Scope,
|
||||
"code_challenge": p.Challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": p.State,
|
||||
// Without both of these Google returns no refresh token on a
|
||||
// re-authorisation, and the connection dies silently an hour later.
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
}
|
||||
for key, want := range checks {
|
||||
if got := q.Get(key); got != want {
|
||||
t.Errorf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The verifier itself must never leave the server.
|
||||
if strings.Contains(raw, p.Verifier) {
|
||||
t.Error("the code verifier was put in the authorisation URL")
|
||||
}
|
||||
}
|
||||
|
||||
// The scope is the one thing that differs between the two features signing in
|
||||
// through this, so it has to come out of the config rather than out of a constant
|
||||
// somebody could forget to change.
|
||||
func TestTheScopeComesFromTheConfig(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calendar := "https://www.googleapis.com/auth/calendar.readonly"
|
||||
raw := Config{ClientID: "a", Scope: calendar}.AuthURL(p)
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := parsed.Query().Get("scope"); got != calendar {
|
||||
t.Errorf("scope = %q, want %q — asking for the wrong one is a consent screen "+
|
||||
"that grants the wrong thing", got, calendar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigured(t *testing.T) {
|
||||
full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"}
|
||||
if !full.Configured() {
|
||||
t.Error("a complete config is not reported as configured")
|
||||
}
|
||||
for _, partial := range []Config{
|
||||
{},
|
||||
{ClientID: "a"},
|
||||
{ClientID: "a", ClientSecret: "b"},
|
||||
{ClientSecret: "b", RedirectURL: "c"},
|
||||
} {
|
||||
if partial.Configured() {
|
||||
t.Errorf("%+v was reported as configured", partial)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
// The bucket is cleared only when the login is *finished*.
|
||||
//
|
||||
// It used to be cleared here, on the strength of the password alone — before
|
||||
// the second factor had been asked for at all. That handed somebody who
|
||||
// already had the password an unlimited supply of ten-guess batches: log in,
|
||||
// get a fresh bucket and a fresh pending session, spend ten guesses on the
|
||||
// code, log in again. At three valid codes in any moment and twenty requests a
|
||||
// second, that is even odds inside an afternoon — against the one control that
|
||||
// exists precisely for when the password is already gone.
|
||||
//
|
||||
// So an account with a second factor clears nothing yet. handleLoginTOTP does
|
||||
// it, once the code is right.
|
||||
if !user.TOTPEnabled {
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
}
|
||||
|
||||
// A session that still needs a code gets a short life of its own: long enough
|
||||
// to fetch a phone, not long enough to be worth stealing.
|
||||
@@ -191,6 +205,19 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
session := sessionFrom(r.Context())
|
||||
|
||||
// Counted per account as well as per address.
|
||||
//
|
||||
// The address bucket alone is defeated by anybody with more than one, which is
|
||||
// the ordinary case — a phone and a laptop is already two. A code is six
|
||||
// digits: the only thing that makes it safe is that guesses are scarce, and
|
||||
// scarcity has to be counted against the thing being attacked.
|
||||
if ok, retry := s.totpLimiter.allow(user.ID); !ok {
|
||||
w.Header().Set("Retry-After", retryAfterSeconds(retry))
|
||||
writeError(w, http.StatusTooManyRequests, CodeRateLimited,
|
||||
"too many verification codes; wait a moment and try again")
|
||||
return
|
||||
}
|
||||
|
||||
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
|
||||
if err != nil {
|
||||
// A recovery code is accepted in the same field. Making somebody find a
|
||||
@@ -198,6 +225,15 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
// introduce a second concept.
|
||||
used, rerr := s.db.UseRecoveryCode(r.Context(), user.ID, req.Code)
|
||||
if rerr != nil || !used {
|
||||
// The pending session is left alive on purpose.
|
||||
//
|
||||
// Ending it on the first wrong digit was considered and rejected: it
|
||||
// would make somebody who mistyped one number type their password again,
|
||||
// and it buys almost nothing. What makes a six-digit code safe is that
|
||||
// guesses are scarce, and the per-account bucket above is what makes
|
||||
// them scarce — ten an hour, however many times the password is
|
||||
// re-entered. The attack this closes was never "one session, many
|
||||
// guesses"; it was "a fresh bucket for every password check".
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"that verification code is not valid")
|
||||
return
|
||||
@@ -205,6 +241,11 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.log.Info("recovery code used", "user", user.ID)
|
||||
}
|
||||
|
||||
// Finished. Now the buckets may be cleared — both of them, so somebody who
|
||||
// fumbled the code twice is not still waiting once they get it right.
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
s.totpLimiter.reset(user.ID)
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
|
||||
@@ -301,7 +301,11 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
|
||||
|
||||
// Begin: a secret exists, but 2FA is not on yet — closing the tab here must
|
||||
// not lock the account.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
||||
// Med adgangskoden: at slå andenfaktoren til er mindst lige så indgribende som
|
||||
// at slå den fra, og den har altid spurgt.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
@@ -368,7 +372,9 @@ func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
secret := body["secret"].(string)
|
||||
_, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
|
||||
"code": codeFor(t, secret),
|
||||
@@ -897,3 +903,65 @@ func TestUserAgentsAreSummarisedForAPerson(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Andenfaktoren kan ikke slås til uden adgangskoden.
|
||||
//
|
||||
// Døren åbnede før med et token og lukkede kun med et kodeord: `disable` har
|
||||
// altid spurgt, `setup` spurgte om ingenting. Et stjålet token kunne derfor hente
|
||||
// hemmeligheden, lægge den i sin egen godkender, bekræfte den og beholde den
|
||||
// eneste kopi af gendannelseskoderne — hvorefter ejerens næste login stoppede ved
|
||||
// en kode, de ikke havde, og de ikke kunne slå den fra uden at logge ind først.
|
||||
func TestTOTPCannotBeSwitchedOnWithoutThePassword(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "det forkerte kodeord",
|
||||
})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status %d, ventede at en forkert adgangskode blev afvist", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{})
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatal("en tom adgangskode blev accepteret")
|
||||
}
|
||||
}
|
||||
|
||||
// Ratebegrænseren må ikke nulstilles af adgangskoden alene.
|
||||
//
|
||||
// Den blev det, og det gav én, der havde kodeordet, ubegrænsede portioner på ti
|
||||
// gæt: log ind, få en frisk spand og en frisk ventende session, brug ti gæt på
|
||||
// koden, log ind igen. Andenfaktoren findes præcis til det tilfælde, hvor
|
||||
// kodeordet allerede er tabt, så den portion skal betales for hver gang.
|
||||
func TestAPasswordAloneDoesNotRefillTheCodeAttempts(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
|
||||
"password": "et langt kodeord",
|
||||
})
|
||||
secret := body["secret"].(string)
|
||||
ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{"code": codeFor(t, secret)})
|
||||
ts.do(t, "POST", "/api/v1/auth/logout", nil)
|
||||
|
||||
// Kodeordet er rigtigt hver gang; kun koden er forkert. Uden loftet ville det
|
||||
// her kunne blive ved i det uendelige.
|
||||
refused := 0
|
||||
for i := 0; i < 14; i++ {
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||
"email": "[email protected]", "password": "et langt kodeord",
|
||||
})
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
refused++
|
||||
continue
|
||||
}
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": "000000"})
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
if refused == 0 {
|
||||
t.Fatal("fjorten runder med forkerte koder blev aldrig afvist; spanden fyldes stadig af adgangskoden")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/gcal"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// defaultCalendarSyncBudget bounds one "fetch now" when nothing else says
|
||||
// otherwise. Well under the hundred seconds a proxy is willing to wait, so this
|
||||
// server is the one that answers a slow Google rather than Cloudflare's own HTML
|
||||
// page — which is how a failure arrives with no code, no message and nothing in
|
||||
// the error log.
|
||||
const defaultCalendarSyncBudget = 25 * time.Second
|
||||
|
||||
// The window verdande keeps a copy of.
|
||||
//
|
||||
// A month grid pages, so asking Google on every arrow press would mean a round trip
|
||||
// for a question answered ninety seconds ago — a calendar slower than the calendar
|
||||
// it is showing. A cached window is the alternative, and a window has to end
|
||||
// somewhere. Ninety days back is roughly "when was that", a year forward is past
|
||||
// anything anybody has booked; between them they cover every screen the grid can
|
||||
// reach without a person noticing an edge.
|
||||
//
|
||||
// The edge is still real, and the events endpoint says where it is rather than
|
||||
// answering an empty list for a month outside it. A calendar that quietly shows no
|
||||
// events is a calendar that lies about a day being clear.
|
||||
const (
|
||||
calendarWindowBack = 90
|
||||
calendarWindowAhead = 365
|
||||
calendarDateLayout = "2006-01-02"
|
||||
calendarProviderName = "google"
|
||||
)
|
||||
|
||||
func (s *Server) calendarClient(accessToken string) *gcal.Client {
|
||||
return gcal.NewClient(accessToken).At(s.cfg.CalendarAPIURL)
|
||||
}
|
||||
|
||||
// calendarConfig is the same registration Gmail signs in through, asking for a
|
||||
// different scope and coming back to a different URI.
|
||||
//
|
||||
// One OAuth client, two features. Google issues a refresh token per authorisation
|
||||
// and keeps up to a hundred of them live per account, each carrying the scopes it
|
||||
// was granted with — so connecting the calendar does not disturb a working Gmail,
|
||||
// and disconnecting one does not take the other with it.
|
||||
func (s *Server) calendarConfig(ctx context.Context) gcal.Config {
|
||||
cfg := s.gmailConfig(ctx)
|
||||
cfg.Scope = gcal.Scope
|
||||
cfg.RedirectURL = s.cfg.CalendarRedirectURL()
|
||||
return cfg
|
||||
}
|
||||
|
||||
// --- connecting ------------------------------------------------------------------
|
||||
|
||||
// handleCalendarAuthorize starts the flow. Same shape as Gmail's, and the same
|
||||
// reason for keeping the verifier server-side: the callback arrives as a top-level
|
||||
// navigation from Google, and a SameSite=Lax cookie is the kind of thing that works
|
||||
// until a browser tightens its defaults.
|
||||
func (s *Server) handleCalendarAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
cfg := s.calendarConfig(r.Context())
|
||||
|
||||
if !cfg.Configured() {
|
||||
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
|
||||
"no Google OAuth client is registered on this server")
|
||||
return
|
||||
}
|
||||
|
||||
pkce, err := gcal.NewPKCE()
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar pkce", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Its own settings scope, not Gmail's. Two handshakes in one row would mean a
|
||||
// person who started both flows had one verifier, and whichever came back
|
||||
// second would be refused for a reason nothing on the screen could explain.
|
||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "calendar")
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar settings", err)
|
||||
return
|
||||
}
|
||||
settings["pkce_verifier"] = pkce.Verifier
|
||||
settings["pkce_state"] = pkce.State
|
||||
// The attempt expires: a stored verifier that lives forever is a replay window
|
||||
// that lives forever.
|
||||
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "calendar", settings); err != nil {
|
||||
s.internal(w, r, "calendar settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
|
||||
}
|
||||
|
||||
// handleCalendarCallback is where Google sends the browser back.
|
||||
//
|
||||
// It answers with a redirect into the app rather than with JSON: the person is
|
||||
// looking at a browser tab, not at a fetch response.
|
||||
func (s *Server) handleCalendarCallback(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
settingsURL := s.cfg.BaseURL + "/indstillinger/integrationer"
|
||||
|
||||
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
||||
// Somebody clicked "cancel" on the consent screen — or, on an Internal
|
||||
// OAuth app, Google refused an account outside the organisation with
|
||||
// `org_internal`. Both arrive here, and the page names what it was given.
|
||||
http.Redirect(w, r, settingsURL+"?calendar="+errParam, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
if code == "" || state == "" {
|
||||
http.Redirect(w, r, settingsURL+"?calendar=invalid", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "calendar")
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar settings", err)
|
||||
return
|
||||
}
|
||||
verifier, _ := settings["pkce_verifier"].(string)
|
||||
expected, _ := settings["pkce_state"].(string)
|
||||
expires, _ := settings["pkce_expires"].(float64)
|
||||
|
||||
// The state has to match, or this endpoint accepts a code from anywhere.
|
||||
if verifier == "" || expected == "" || state != expected {
|
||||
http.Redirect(w, r, settingsURL+"?calendar=state", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if int64(expires) < time.Now().Unix() {
|
||||
http.Redirect(w, r, settingsURL+"?calendar=expired", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.calendarConfig(r.Context()).Exchange(r.Context(), code,
|
||||
gcal.PKCE{Verifier: verifier})
|
||||
if err != nil {
|
||||
s.log.Error("calendar exchange", "err", err, "user", user.ID)
|
||||
http.Redirect(w, r, settingsURL+"?calendar=failed", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if token.RefreshToken == "" {
|
||||
// Without one the connection dies in an hour. Google only withholds it when
|
||||
// the consent prompt was skipped, which access_type and prompt are meant to
|
||||
// prevent — so this means something is wrong with the client registration
|
||||
// rather than with this request.
|
||||
s.log.Error("calendar returned no refresh token", "user", user.ID)
|
||||
http.Redirect(w, r, settingsURL+"?calendar=norefresh", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// The one-time values are cleared: keeping a spent verifier serves nothing and
|
||||
// is one more secret at rest.
|
||||
delete(settings, "pkce_verifier")
|
||||
delete(settings, "pkce_state")
|
||||
delete(settings, "pkce_expires")
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "calendar", settings); err != nil {
|
||||
s.internal(w, r, "clear calendar handshake", err)
|
||||
return
|
||||
}
|
||||
|
||||
account := &store.CalendarAccount{
|
||||
UserID: user.ID, Provider: calendarProviderName,
|
||||
RefreshToken: token.RefreshToken, AccessToken: token.AccessToken,
|
||||
ExpiresAt: token.ExpiresAt,
|
||||
}
|
||||
if err := s.db.SaveCalendarAccount(r.Context(), account); err != nil {
|
||||
s.internal(w, r, "save calendar tokens", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The list of calendars, fetched now so the page that opens next has something
|
||||
// to tick rather than an empty panel and a button. Best effort: an account that
|
||||
// connects but cannot list itself is still connected, and the status endpoint
|
||||
// asks again when it finds nothing.
|
||||
if _, err := s.refreshCalendarList(r.Context(), account); err != nil {
|
||||
s.log.Warn("calendar list", "err", err, "user", user.ID)
|
||||
}
|
||||
|
||||
s.log.Info("calendar connected", "user", user.ID, "account", account.Account)
|
||||
http.Redirect(w, r, settingsURL+"?calendar=connected", http.StatusFound)
|
||||
}
|
||||
|
||||
// --- what the settings page reads --------------------------------------------------
|
||||
|
||||
type calendarStatusJSON struct {
|
||||
Connected bool `json:"connected"`
|
||||
Account string `json:"account,omitempty"`
|
||||
Calendars []store.Calendar `json:"calendars"`
|
||||
// RedirectURI is what has to be registered with Google, spelled out. Derived
|
||||
// from VERDANDE_BASE_URL and matched exactly — the single most likely thing to
|
||||
// be wrong, and the error Google gives for it names neither value.
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
// HasClient says whether there is an OAuth registration to connect through at
|
||||
// all, so the page can explain rather than offer a button that answers 409.
|
||||
HasClient bool `json:"has_client"`
|
||||
LastSyncAt string `json:"last_sync_at,omitempty"`
|
||||
// ReadOnly is stated rather than assumed. Nothing writes a Google calendar, and
|
||||
// an interface that quietly looks editable is one somebody will try to edit.
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
out := calendarStatusJSON{
|
||||
Calendars: []store.Calendar{},
|
||||
RedirectURI: s.cfg.CalendarRedirectURL(),
|
||||
HasClient: s.calendarConfig(r.Context()).Configured(),
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar account", err)
|
||||
return
|
||||
}
|
||||
if account == nil || account.RefreshToken == "" {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
|
||||
out.Connected = true
|
||||
out.Account = account.Account
|
||||
if !account.LastSyncAt.IsZero() {
|
||||
out.LastSyncAt = account.LastSyncAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
calendars, err := s.db.Calendars(r.Context(), account.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendars", err)
|
||||
return
|
||||
}
|
||||
// An account connected during a Google hiccup has no calendars and no way back:
|
||||
// the person sees an empty list and nothing to press. Asking again here costs
|
||||
// one request on a page nobody opens often, and means the wrong answer lasts
|
||||
// until the next look rather than for ever. Same reasoning as the Gmail address.
|
||||
if len(calendars) == 0 {
|
||||
if fetched, err := s.refreshCalendarList(r.Context(), account); err == nil {
|
||||
calendars = fetched
|
||||
out.Account = account.Account
|
||||
} else {
|
||||
s.log.Warn("calendar list", "err", err, "user", user.ID)
|
||||
}
|
||||
}
|
||||
if calendars != nil {
|
||||
out.Calendars = calendars
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type showCalendarsRequest struct {
|
||||
// Shown is the whole set, not one flip. Same reasoning as the projects' order:
|
||||
// a person has a handful of calendars, the question is "these ones", and a list
|
||||
// that can only be built by several requests can land half applied.
|
||||
Shown []string `json:"shown"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetCalendars(w http.ResponseWriter, r *http.Request) {
|
||||
var req showCalendarsRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar account", err)
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
// A 404 rather than a 409: everything the caller may not see answers 404,
|
||||
// and "there is no such connection" is the true answer either way.
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "no calendar is connected")
|
||||
return
|
||||
}
|
||||
if err := s.db.ShowCalendars(r.Context(), account.ID, req.Shown); err != nil {
|
||||
s.internal(w, r, "show calendars", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleDisconnectCalendar forgets the connection, and the copy of the calendars
|
||||
// with it. Unlike the tasks a mailbox made, none of this is the person's own work.
|
||||
func (s *Server) handleDisconnectCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar account", err)
|
||||
return
|
||||
}
|
||||
if account != nil {
|
||||
if err := s.db.DeleteCalendarAccount(r.Context(), user.ID, account.ID); err != nil {
|
||||
s.internal(w, r, "disconnect calendar", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- what the grid reads -----------------------------------------------------------
|
||||
|
||||
type calendarEventsJSON struct {
|
||||
Events []store.CalendarEvent `json:"events"`
|
||||
// From and To are the window verdande holds a copy of, not the window that was
|
||||
// asked for. A grid that has paged past it gets no events, and an empty answer
|
||||
// with no explanation is a calendar quietly claiming a day is clear.
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCalendarEvents(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
from := r.URL.Query().Get("from")
|
||||
to := r.URL.Query().Get("to")
|
||||
|
||||
fields := map[string]string{}
|
||||
if !validDay(from) {
|
||||
fields["from"] = "must be a date, as 2026-08-20"
|
||||
}
|
||||
if !validDay(to) {
|
||||
fields["to"] = "must be a date, as 2026-08-20"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
writeFieldErrors(w, fields)
|
||||
return
|
||||
}
|
||||
|
||||
events, err := s.db.CalendarEvents(r.Context(), user.ID, from, to)
|
||||
if err != nil {
|
||||
s.internal(w, r, "calendar events", err)
|
||||
return
|
||||
}
|
||||
if events == nil {
|
||||
events = []store.CalendarEvent{}
|
||||
}
|
||||
windowFrom, windowTo := calendarWindow(time.Now())
|
||||
writeJSON(w, http.StatusOK, calendarEventsJSON{
|
||||
Events: events, From: windowFrom, To: windowTo,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCalendarSyncNow refreshes immediately, so somebody who has just connected
|
||||
// can see it work rather than wondering whether it did.
|
||||
func (s *Server) handleCalendarSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
// A budget of its own, well under what sits in front of this server. An unset
|
||||
// budget is not a budget of nothing: a zero here would expire the context
|
||||
// before the first call and make every sync a silent no-op.
|
||||
budget := s.cfg.CalendarSyncBudget
|
||||
if budget <= 0 {
|
||||
budget = defaultCalendarSyncBudget
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), budget)
|
||||
defer cancel()
|
||||
|
||||
count, err := s.SyncCalendars(ctx, user)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
// Not a failure: Google is slow or busy, some calendars were read, and
|
||||
// the rest arrive on the next run. Every calendar is committed as it is
|
||||
// read, so a short budget is a shorter run rather than a lost one.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"events": count, "partial": true})
|
||||
return
|
||||
}
|
||||
s.upstream(w, r, CodeCalendarFailed, "sync calendar", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"events": count})
|
||||
}
|
||||
|
||||
// SyncCalendars refreshes every calendar this person has chosen to see.
|
||||
//
|
||||
// Returns how many events were written. Also used by the background job, which is
|
||||
// why it takes a context and a user rather than a request.
|
||||
func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, error) {
|
||||
account, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if account == nil || account.RefreshToken == "" {
|
||||
return 0, nil // not connected; nothing to do and nothing to complain about
|
||||
}
|
||||
|
||||
// The same guard the mailboxes have, for the same reason: a press of "fetch
|
||||
// now" during the sweep would otherwise have two runs replacing the same window
|
||||
// at once, and the loser's delete would land after the winner's insert.
|
||||
unlock := s.lockSync("calendar:" + account.ID)
|
||||
defer unlock()
|
||||
if fresh, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName); err == nil && fresh != nil {
|
||||
account = fresh
|
||||
}
|
||||
|
||||
access, err := s.calendarAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
client := s.calendarClient(access)
|
||||
|
||||
// The list first, so a calendar added or renamed since the last run is drawn
|
||||
// under its new name and one that has gone stops being drawn at all.
|
||||
calendars, err := s.calendarList(ctx, client, account)
|
||||
if err != nil {
|
||||
return 0, s.forgetIfRevoked(ctx, user, account, err)
|
||||
}
|
||||
|
||||
from, to := calendarWindow(time.Now())
|
||||
written := 0
|
||||
for _, calendar := range calendars {
|
||||
if !calendar.Shown {
|
||||
continue
|
||||
}
|
||||
// A spent budget ends the run rather than turning into a failed fetch per
|
||||
// calendar, logged one at a time.
|
||||
if ctx.Err() != nil {
|
||||
return written, ctx.Err()
|
||||
}
|
||||
events, err := client.Events(ctx, calendar.RemoteID, from, to)
|
||||
if err != nil {
|
||||
if errors.Is(err, gcal.ErrUnauthorized) {
|
||||
return written, s.forgetIfRevoked(ctx, user, account, err)
|
||||
}
|
||||
// One calendar somebody was unshared from must not stop the others.
|
||||
s.log.Warn("calendar events", "err", err, "calendar", calendar.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
rows := make([]store.CalendarEvent, 0, len(events))
|
||||
for _, e := range events {
|
||||
rows = append(rows, store.CalendarEvent{
|
||||
RemoteID: e.ID, Summary: e.Summary,
|
||||
StartsAt: e.StartsAt, EndsAt: e.EndsAt,
|
||||
StartDay: e.StartDay, EndDay: e.EndDay,
|
||||
AllDay: e.AllDay, Location: e.Location, URL: e.URL,
|
||||
})
|
||||
}
|
||||
if err := s.db.ReplaceCalendarEvents(ctx, calendar.ID, from, to, rows); err != nil {
|
||||
return written, err
|
||||
}
|
||||
written += len(rows)
|
||||
}
|
||||
|
||||
if err := s.db.MarkCalendarSynced(ctx, account.ID, time.Now()); err != nil {
|
||||
return written, err
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// calendarAccessToken returns a usable access token, refreshing only when the one
|
||||
// held has actually expired.
|
||||
func (s *Server) calendarAccessToken(ctx context.Context, account *store.CalendarAccount) (string, error) {
|
||||
if account.AccessToken != "" && account.ExpiresAt.After(time.Now()) {
|
||||
return account.AccessToken, nil
|
||||
}
|
||||
token, err := s.calendarConfig(ctx).Refresh(ctx, account.RefreshToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
account.AccessToken = token.AccessToken
|
||||
account.ExpiresAt = token.ExpiresAt
|
||||
if err := s.db.SaveCalendarAccount(ctx, account); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return account.AccessToken, nil
|
||||
}
|
||||
|
||||
// refreshCalendarList fetches the calendar list with a token it obtains itself.
|
||||
// Used by the paths that have an account but no client yet — connecting, and the
|
||||
// status endpoint correcting an empty list.
|
||||
func (s *Server) refreshCalendarList(ctx context.Context, account *store.CalendarAccount) ([]store.Calendar, error) {
|
||||
access, err := s.calendarAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.calendarList(ctx, s.calendarClient(access), account)
|
||||
}
|
||||
|
||||
// calendarList writes what the provider says and returns what is stored, which is
|
||||
// not the same list: the provider says what exists, and only verdande knows which
|
||||
// of them somebody chose to look at.
|
||||
func (s *Server) calendarList(ctx context.Context, client *gcal.Client, account *store.CalendarAccount) ([]store.Calendar, error) {
|
||||
remote, err := client.Calendars(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := make([]store.Calendar, 0, len(remote))
|
||||
for _, c := range remote {
|
||||
list = append(list, store.Calendar{
|
||||
RemoteID: c.ID, Name: c.Name, Colour: c.Colour,
|
||||
TimeZone: c.TimeZone, Primary: c.Primary, Writable: c.Owner,
|
||||
})
|
||||
// The primary calendar is the account, and its id is the address. It is the
|
||||
// only place the connected account names itself without a second scope —
|
||||
// asking for a profile scope to learn an address already in the answer would
|
||||
// be one more thing on the consent screen for nothing.
|
||||
if c.Primary && account.Account == "" {
|
||||
account.Account = c.ID
|
||||
if err := s.db.SaveCalendarAccount(ctx, account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.db.ReplaceCalendars(ctx, account.ID, list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.db.Calendars(ctx, account.ID)
|
||||
}
|
||||
|
||||
// forgetIfRevoked disconnects when Google says the grant is gone, and passes every
|
||||
// other failure through untouched.
|
||||
//
|
||||
// The distinction is the point. A revoked account polled every ten minutes for ever
|
||||
// is a log line a week long and a connection the person believes they removed; a
|
||||
// Google that is merely down is one that will answer next time.
|
||||
func (s *Server) forgetIfRevoked(ctx context.Context, user *store.User, account *store.CalendarAccount, err error) error {
|
||||
if errors.Is(err, gcal.ErrUnauthorized) {
|
||||
s.log.Info("calendar access was revoked; disconnecting", "user", user.ID)
|
||||
_ = s.db.DeleteCalendarAccount(ctx, user.ID, account.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// calendarWindow is the span verdande keeps a copy of, measured from today.
|
||||
//
|
||||
// Built from the calendar date rather than by truncating the instant: Truncate
|
||||
// rounds against the Unix epoch, which is midnight UTC, and in Copenhagen that is
|
||||
// two in the morning — so for two hours of every summer day the window would start
|
||||
// and end a day out.
|
||||
func calendarWindow(now time.Time) (string, string) {
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
return day.AddDate(0, 0, -calendarWindowBack).Format(calendarDateLayout),
|
||||
day.AddDate(0, 0, calendarWindowAhead).Format(calendarDateLayout)
|
||||
}
|
||||
|
||||
func validDay(s string) bool {
|
||||
_, err := time.Parse(calendarDateLayout, s)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// fakeCalendarGoogle stands in for the parts of Google this feature touches: the
|
||||
// token endpoint, the calendar list and one calendar's events.
|
||||
//
|
||||
// There is no other way to exercise any of it. The real thing needs a registered
|
||||
// OAuth client, an account and somebody's consent, and none of the three can be had
|
||||
// inside a test — so the shapes Google returns are written down and the behaviour
|
||||
// is checked against them, the way internal/imap is checked against go-imap's own
|
||||
// in-process server.
|
||||
type fakeCalendarGoogle struct {
|
||||
*httptest.Server
|
||||
// refreshes counts token refreshes, so a test can prove one is not made for a
|
||||
// token that has not expired.
|
||||
refreshes int
|
||||
// refuse makes every API call answer 401, which is what a revoked grant does.
|
||||
refuse bool
|
||||
events map[string][]map[string]any
|
||||
}
|
||||
|
||||
func newFakeCalendarGoogle(t *testing.T) *fakeCalendarGoogle {
|
||||
t.Helper()
|
||||
g := &fakeCalendarGoogle{events: map[string][]map[string]any{}}
|
||||
g.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.URL.Path == "/token":
|
||||
g.refreshes++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "ya29.fresh", "refresh_token": "1//0-refresh",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
case g.refuse:
|
||||
http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized)
|
||||
case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{
|
||||
{"id": "[email protected]", "summary": "[email protected]", "primary": true,
|
||||
"accessRole": "owner", "backgroundColor": "#3f51b5",
|
||||
"timeZone": "Europe/Copenhagen"},
|
||||
{"id": "fam", "summary": "Familien", "accessRole": "reader",
|
||||
"backgroundColor": "#0b8043"},
|
||||
}})
|
||||
case strings.HasPrefix(r.URL.Path, "/calendars/"):
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events")
|
||||
items := g.events[id]
|
||||
if items == nil {
|
||||
items = []map[string]any{}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": items})
|
||||
default:
|
||||
http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(g.Close)
|
||||
return g
|
||||
}
|
||||
|
||||
// connectedCalendar returns a server with a calendar account already connected —
|
||||
// the state the OAuth callback leaves behind, without the consent screen nothing
|
||||
// can automate.
|
||||
func connectedCalendar(t *testing.T, g *fakeCalendarGoogle, tune func(*config.Config)) (*testServer, *store.User) {
|
||||
t.Helper()
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GoogleTokenURL = g.URL + "/token"
|
||||
cfg.CalendarAPIURL = g.URL
|
||||
if tune != nil {
|
||||
tune(cfg)
|
||||
}
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account := &store.CalendarAccount{
|
||||
UserID: user.ID, Provider: "google", RefreshToken: "1//0-refresh",
|
||||
}
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ts, user
|
||||
}
|
||||
|
||||
// The scope on the consent screen is what somebody agrees to. It has to be the
|
||||
// read-only one, and the redirect has to be the calendar's own — sharing Gmail's
|
||||
// would put "which token store do I write" behind a lookup.
|
||||
func TestAuthorizeAsksForReadOnlyCalendar(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, _ := connectedCalendar(t, g, nil)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
url, _ := body["url"].(string)
|
||||
for _, want := range []string{
|
||||
"scope=" + "https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly",
|
||||
"%2Foauth%2Fcalendar%2Fcallback",
|
||||
"access_type=offline",
|
||||
"prompt=consent",
|
||||
} {
|
||||
if !strings.Contains(url, want) {
|
||||
t.Errorf("the authorisation URL is missing %q:\n%s", want, url)
|
||||
}
|
||||
}
|
||||
if strings.Contains(url, "calendar.events&") || strings.Contains(url, "auth%2Fcalendar&") {
|
||||
t.Errorf("a writing scope was asked for:\n%s", url)
|
||||
}
|
||||
}
|
||||
|
||||
// Without a registered client there is nothing to connect through, and the answer
|
||||
// has to say that rather than "that already exists".
|
||||
func TestAuthorizeWithoutAClientSaysSo(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
if body["code"] != CodeGmailNotConfigured {
|
||||
t.Errorf("code = %v, want %q", body["code"], CodeGmailNotConfigured)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole path, minus the consent screen: sync fetches the list, refreshes the
|
||||
// events of what was chosen, and the grid endpoint answers with them.
|
||||
func TestSyncFetchesTheChosenCalendarsAndTheGridReadsThem(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
g.events["fam"] = []map[string]any{
|
||||
{"id": "e1", "summary": "Bestyrelsesmøde", "htmlLink": "https://calendar.google.com/e1",
|
||||
"start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"},
|
||||
"end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"}},
|
||||
}
|
||||
g.events["[email protected]"] = []map[string]any{
|
||||
{"id": "e2", "summary": "Skal ikke med",
|
||||
"start": map[string]string{"date": "2026-08-21"},
|
||||
"end": map[string]string{"date": "2026-08-22"}},
|
||||
}
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
// The list arrives on the first look at the settings page, which is what
|
||||
// somebody would be doing at this point.
|
||||
resp, body := ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["connected"] != true {
|
||||
t.Fatalf("not reported as connected: %v", body)
|
||||
}
|
||||
if body["read_only"] != true {
|
||||
t.Error("the connection did not say it is read-only; an interface that looks " +
|
||||
"editable is one somebody will try to edit")
|
||||
}
|
||||
// The primary calendar's id is the address, which is how the account names
|
||||
// itself without asking for a profile scope as well.
|
||||
if body["account"] != "[email protected]" {
|
||||
t.Errorf("account = %v", body["account"])
|
||||
}
|
||||
|
||||
list, _ := body["calendars"].([]any)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("got %d calendars, want 2: %v", len(list), body["calendars"])
|
||||
}
|
||||
var famID string
|
||||
for _, raw := range list {
|
||||
c := raw.(map[string]any)
|
||||
if c["shown"] == true {
|
||||
t.Errorf("%v was shown without anybody choosing it", c["name"])
|
||||
}
|
||||
if c["remote_id"] == "fam" {
|
||||
famID = c["id"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is chosen, so a sync writes nothing: an account with a dozen shared
|
||||
// calendars must not fill somebody's grid the moment it is connected.
|
||||
resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
if resp.StatusCode != http.StatusOK || body["events"] != float64(0) {
|
||||
t.Fatalf("an unchosen calendar was fetched: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "PUT", "/api/v1/calendar/calendars",
|
||||
map[string]any{"shown": []string{famID}})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("choosing a calendar: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["events"] != float64(1) {
|
||||
t.Fatalf("events = %v, want 1 — only the chosen calendar", body["events"])
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "GET",
|
||||
"/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("events: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
events, _ := body["events"].([]any)
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("got %d events, want 1: %v", len(events), body["events"])
|
||||
}
|
||||
event := events[0].(map[string]any)
|
||||
if event["summary"] != "Bestyrelsesmøde" || event["start_day"] != "2026-08-20" {
|
||||
t.Errorf("the event came back as %v", event)
|
||||
}
|
||||
// The calendar's colour rides along, or the chip has to ask for it per row.
|
||||
if event["colour"] != "#0b8043" {
|
||||
t.Errorf("colour = %v; two calendars in one grid are told apart by it", event["colour"])
|
||||
}
|
||||
// The window verdande holds is stated, so a grid paged past it can say so
|
||||
// rather than showing an empty month it cannot explain.
|
||||
if body["from"] == "" || body["to"] == "" {
|
||||
t.Error("the answer does not say which window it covers")
|
||||
}
|
||||
|
||||
// And it is one person's calendar. Somebody else on the same instance sees
|
||||
// nothing of it.
|
||||
other := ts.newUser(t, "[email protected]", "Andreas")
|
||||
_, body = other.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if events, _ := body["events"].([]any); len(events) != 0 {
|
||||
t.Errorf("somebody else's calendar was visible: %v", body["events"])
|
||||
}
|
||||
_ = user
|
||||
}
|
||||
|
||||
// A token that has not expired must not be traded in for another. Refreshing on
|
||||
// every run burns the quota and makes the failure mode of a bad refresh token
|
||||
// arrive on every sweep rather than once an hour.
|
||||
func TestAFreshTokenIsNotRefreshed(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
account, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account.AccessToken = "ya29.still-good"
|
||||
account.ExpiresAt = time.Now().Add(time.Hour)
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if g.refreshes != 0 {
|
||||
t.Errorf("the token was refreshed %d times while it was still good", g.refreshes)
|
||||
}
|
||||
|
||||
// And an expired one is.
|
||||
account.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if g.refreshes != 1 {
|
||||
t.Errorf("an expired token was refreshed %d times, want 1", g.refreshes)
|
||||
}
|
||||
}
|
||||
|
||||
// Access revoked at Google's end is a connection that has ended, not a failure to
|
||||
// retry. Polling it every fifteen minutes for ever is a week of log lines and a
|
||||
// connection the person believes they removed.
|
||||
func TestRevokedAccessDisconnects(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
account.AccessToken = "ya29.revoked"
|
||||
account.ExpiresAt = time.Now().Add(time.Hour)
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.refuse = true
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
if resp.StatusCode != StatusUpstreamRefused {
|
||||
t.Fatalf("status %d, want %d — Google saying no is not this server breaking",
|
||||
resp.StatusCode, StatusUpstreamRefused)
|
||||
}
|
||||
if body["code"] != CodeCalendarFailed {
|
||||
t.Errorf("code = %v, want %q — `internal_error` is shown as a generic sentence, "+
|
||||
"which throws the diagnosis away", body["code"], CodeCalendarFailed)
|
||||
}
|
||||
|
||||
gone, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gone != nil {
|
||||
t.Error("the connection is still there after Google refused the token")
|
||||
}
|
||||
|
||||
// And the failure is in the error log, which is the point of having one: a
|
||||
// sync that has been failing for a week is invisible otherwise.
|
||||
_, logged := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||
rows, _ := logged["errors"].([]any)
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("nothing was recorded")
|
||||
}
|
||||
if row := rows[0].(map[string]any); row["what"] != "sync calendar" {
|
||||
t.Errorf("what = %v, want \"sync calendar\"", row["what"])
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnecting takes the copy with it. None of it is the person's own work, and
|
||||
// a calendar left behind after the account is gone is a frozen lie about a day.
|
||||
func TestDisconnectingRemovesTheEvents(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
g.events["fam"] = []map[string]any{
|
||||
{"id": "e1", "summary": "Møde",
|
||||
"start": map[string]string{"date": "2026-08-20"},
|
||||
"end": map[string]string{"date": "2026-08-21"}},
|
||||
}
|
||||
ts, user := connectedCalendar(t, g, nil)
|
||||
|
||||
ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
||||
calendars, _ := ts.db.Calendars(t.Context(), account.ID)
|
||||
var famID string
|
||||
for _, c := range calendars {
|
||||
if c.RemoteID == "fam" {
|
||||
famID = c.ID
|
||||
}
|
||||
}
|
||||
ts.do(t, "PUT", "/api/v1/calendar/calendars", map[string]any{"shown": []string{famID}})
|
||||
ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
||||
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/calendar", nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("disconnect: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if events, _ := body["events"].([]any); len(events) != 0 {
|
||||
t.Errorf("events survived the disconnect: %v", body["events"])
|
||||
}
|
||||
_, body = ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
if body["connected"] != false {
|
||||
t.Error("still reported as connected")
|
||||
}
|
||||
}
|
||||
|
||||
// A range that is not a date has to be refused, not guessed at. An empty `from`
|
||||
// compared as a string matches everything before it, which is a grid quietly
|
||||
// answering a question nobody asked.
|
||||
func TestEventsRefuseARangeThatIsNotADate(t *testing.T) {
|
||||
g := newFakeCalendarGoogle(t)
|
||||
ts, _ := connectedCalendar(t, g, nil)
|
||||
|
||||
for _, query := range []string{
|
||||
"", "?from=2026-08-01", "?from=&to=2026-08-31",
|
||||
"?from=i+morgen&to=2026-08-31", "?from=2026-08-01&to=2026-13-40",
|
||||
} {
|
||||
resp, body := ts.do(t, "GET", "/api/v1/calendar/events"+query, nil)
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Errorf("%q: status %d, want 422 (%v)", query, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A person with no calendar connected still opens the view. It has to answer, and
|
||||
// answer emptily, rather than fail — the grid is also verdande's own tasks, and
|
||||
// they must not disappear because Google is not set up.
|
||||
func TestTheGridAnswersWithNoCalendarConnected(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if events, ok := body["events"].([]any); !ok || len(events) != 0 {
|
||||
t.Errorf("events = %v, want an empty list rather than null", body["events"])
|
||||
}
|
||||
|
||||
resp, body = ts.do(t, "GET", "/api/v1/calendar", nil)
|
||||
if resp.StatusCode != http.StatusOK || body["connected"] != false {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["has_client"] != false {
|
||||
t.Error("an instance with no OAuth registration claimed to have one; the page " +
|
||||
"would offer a button that answers 409")
|
||||
}
|
||||
// Spelled out even when nothing is connected: it is what has to be registered
|
||||
// with Google, and it is the single most likely thing to be wrong.
|
||||
if uri, _ := body["redirect_uri"].(string); !strings.HasSuffix(uri, "/oauth/calendar/callback") {
|
||||
t.Errorf("redirect_uri = %q", uri)
|
||||
}
|
||||
}
|
||||
|
||||
// A slow Google must not hold the request open until something in front of this
|
||||
// server gives up on it and answers with its own HTML.
|
||||
func TestASlowCalendarDoesNotHangTheRequest(t *testing.T) {
|
||||
blocked := make(chan struct{})
|
||||
defer close(blocked)
|
||||
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-blocked:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
defer slow.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GoogleTokenURL = slow.URL + "/token"
|
||||
cfg.CalendarAPIURL = slow.URL
|
||||
// A second rather than the real twenty-five: what is being tested is that a
|
||||
// budget is applied at all, and spending the production one on every run
|
||||
// would put half a minute into the suite to learn the same thing.
|
||||
cfg.CalendarSyncBudget = time.Second
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ts.db.SaveCalendarAccount(t.Context(), &store.CalendarAccount{
|
||||
UserID: user.ID, Provider: "google", RefreshToken: "r",
|
||||
AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Its own client, because the shared one gives up after ten seconds — which is
|
||||
// shorter than the budget being tested and would prove nothing either way.
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/calendar/sync", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("the request never came back (%v after %s); the sync has no budget of its "+
|
||||
"own, and whatever sits in front of this server answers for it",
|
||||
err, time.Since(started).Round(time.Second))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if took := time.Since(started); took > 10*time.Second {
|
||||
t.Errorf("the sync took %s; the budget is not being applied", took.Round(time.Second))
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
t.Errorf("status %d — a slow Google is not this server breaking", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -225,17 +225,32 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
a := &store.Attachment{
|
||||
TaskID: taskID,
|
||||
CommentID: r.FormValue("comment_id"),
|
||||
Filename: safeFilename(header.Filename),
|
||||
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
|
||||
Size: size,
|
||||
Path: stored,
|
||||
UploadedBy: user.ID,
|
||||
}
|
||||
|
||||
// An attachment hangs on a task or on a comment, never both — the schema says
|
||||
// so, and the form may have supplied a comment id.
|
||||
if a.CommentID != "" {
|
||||
a.TaskID = ""
|
||||
//
|
||||
// The comment is resolved and checked against the task in the URL. It was
|
||||
// taken on trust, which meant the permission check above guarded the *task
|
||||
// somebody named* while the file landed on *whatever comment they asked for*.
|
||||
// A viewer on a shared project can list its comments, so she could read an id
|
||||
// there and then post to a task in her own project with that id attached: the
|
||||
// file appears under somebody else's comment, in a project she may only read,
|
||||
// and is served to everyone in it from this origin under a name she chose.
|
||||
// Somebody removed from a project could do the same with ids they kept.
|
||||
if id := r.FormValue("comment_id"); id != "" {
|
||||
owner, err := s.db.CommentTask(r.Context(), id)
|
||||
if err != nil || owner != taskID {
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
a.CommentID, a.TaskID = id, ""
|
||||
}
|
||||
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
@@ -394,12 +409,30 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Everything is served as a download rather than rendered. An uploaded SVG or
|
||||
// HTML file displayed inline would run its own script on this origin, with the
|
||||
// session cookie attached — attachments are the one place a user supplies
|
||||
// content that another user opens.
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
|
||||
// Almost everything is served as a download rather than rendered. An uploaded
|
||||
// SVG or HTML file displayed inline would run its own script on this origin,
|
||||
// with the session cookie attached — attachments are the one place a user
|
||||
// supplies content that another user opens.
|
||||
//
|
||||
// The exception is a short list of raster images, and it is not a softening of
|
||||
// that rule but what makes a note work: a note carries its pictures in the text
|
||||
// as ``, and served as octet-stream every one of them is a broken image.
|
||||
// The whole Apple Notes import exists to bring those pictures along.
|
||||
//
|
||||
// Safe because it is an allowlist of formats that cannot carry script, sent as
|
||||
// the exact type with `nosniff` — so the browser must treat it as that image or
|
||||
// not at all, and can never be talked into reading it as HTML. SVG is
|
||||
// deliberately absent: it is a document, not a picture, and it is the one image
|
||||
// type that can run script.
|
||||
disposition := "attachment"
|
||||
contentType := "application/octet-stream"
|
||||
if inlineImage[a.MimeType] {
|
||||
disposition = "inline"
|
||||
contentType = a.MimeType
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+
|
||||
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
|
||||
@@ -408,6 +441,24 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
|
||||
}
|
||||
|
||||
// inlineImage is what may be shown rather than downloaded.
|
||||
//
|
||||
// Raster formats only, and named one by one rather than matched on an "image/"
|
||||
// prefix: the prefix would let image/svg+xml through, which is a document that
|
||||
// can run script, and a list that grows by accident is how that happens.
|
||||
var inlineImage = map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"image/avif": true,
|
||||
// Sendes med, selv om ingen browser viser den endnu. Den er lige så ufarlig
|
||||
// som de andre, og en iPhone-note er fuld af dem — den dag Safari viser dem,
|
||||
// virker de, uden at nogen skal huske hvorfor de ikke gjorde.
|
||||
"image/heic": true,
|
||||
"image/heif": true,
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "attachmentID")
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Politikken skal stå i svaret, og den skal stadig være stram.
|
||||
//
|
||||
// Den er dét, der gjorde en XSS i noteeditoren til indsat HTML frem for kørt
|
||||
// script, dengang en `alt` slap uden om sin undslupning — og der var ingen prøve
|
||||
// på, at headeren overhovedet blev sendt. En sikkerhedsforanstaltning, ingen
|
||||
// prøve rører, er en, der kan forsvinde i en oprydning uden at nogen opdager det.
|
||||
func TestEveryResponseCarriesAStrictCSP(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil)
|
||||
policy := resp.Header.Get("Content-Security-Policy")
|
||||
if policy == "" {
|
||||
t.Fatal("der er ingen Content-Security-Policy på svaret")
|
||||
}
|
||||
|
||||
// Målt på script-src alene, og ikke på hele politikken.
|
||||
//
|
||||
// `style-src 'unsafe-inline'` står der med vilje og er forklaret i csp.go:
|
||||
// Svelte skriver stilarter ind i dokumentet, og en indsat stil kan gøre en
|
||||
// side grim, ikke køre kode. Det er script-src, der afgør, om indsat HTML
|
||||
// bliver til et kørende script, og det er derfor den, prøven ser på.
|
||||
scriptSrc := ""
|
||||
for _, part := range strings.Split(policy, ";") {
|
||||
if strings.HasPrefix(strings.TrimSpace(part), "script-src") {
|
||||
scriptSrc = part
|
||||
}
|
||||
}
|
||||
if scriptSrc == "" {
|
||||
t.Fatal("der er ingen script-src i politikken")
|
||||
}
|
||||
for _, forbidden := range []string{"'unsafe-inline'", "'unsafe-eval'", "'unsafe-hashes'", "'strict-dynamic'"} {
|
||||
if strings.Contains(scriptSrc, forbidden) {
|
||||
t.Errorf("script-src indeholder %s: %q", forbidden, strings.TrimSpace(scriptSrc))
|
||||
}
|
||||
}
|
||||
|
||||
// Og de fire, der skal være der.
|
||||
for _, want := range []string{"script-src", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"} {
|
||||
if !strings.Contains(policy, want) {
|
||||
t.Errorf("politikken mangler %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Et bilag må ikke kunne blive læst som et dokument.
|
||||
func TestAttachmentsAreNotServedAsDocuments(t *testing.T) {
|
||||
for _, mime := range []string{"image/svg+xml", "text/html", "application/xhtml+xml", "text/xml"} {
|
||||
if inlineImage[mime] {
|
||||
t.Errorf("%s står på listen over det, der vises inline", mime)
|
||||
}
|
||||
}
|
||||
// Og listen skal faktisk kunne noget, ellers virker billeder i noter ikke.
|
||||
for _, mime := range []string{"image/png", "image/jpeg"} {
|
||||
if !inlineImage[mime] {
|
||||
t.Errorf("%s mangler; billeder i noter kan ikke vises", mime)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,11 @@ const (
|
||||
// diagnosis — a wrong app password, a host that is not listening, a folder that
|
||||
// is not there — and it says it in its own words.
|
||||
CodeMailboxFailed = "mailbox_failed"
|
||||
|
||||
// And for a calendar read from Google. Its own code rather than the Gmail one,
|
||||
// because the sentence the interface wraps it in names where it came from — and
|
||||
// "Gmail said no" about a calendar sends somebody to the wrong panel.
|
||||
CodeCalendarFailed = "calendar_failed"
|
||||
)
|
||||
|
||||
// errLastAdmin is returned to the caller that already wrote the response, so it
|
||||
|
||||
@@ -25,18 +25,16 @@ const defaultGmailSyncBudget = 25 * time.Second
|
||||
// to redeploy a container to paste a client id.
|
||||
// gmailClient is the API client, pointed wherever the config says Google is.
|
||||
func (s *Server) gmailClient(accessToken string) *gmail.Client {
|
||||
return gmail.NewClient(accessToken).At(gmail.Endpoints{
|
||||
Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL,
|
||||
})
|
||||
return gmail.NewClient(accessToken).At(s.cfg.GmailAPIURL)
|
||||
}
|
||||
|
||||
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
||||
endpoints := gmail.Endpoints{Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL}
|
||||
cfg := gmail.Config{
|
||||
ClientID: s.cfg.GmailClientID,
|
||||
ClientSecret: s.cfg.GmailClientSecret,
|
||||
RedirectURL: s.cfg.GmailRedirectURL(),
|
||||
Endpoints: endpoints,
|
||||
Scope: gmail.Scope,
|
||||
TokenURL: s.cfg.GoogleTokenURL,
|
||||
}
|
||||
if cfg.ClientID != "" && cfg.ClientSecret != "" {
|
||||
return cfg
|
||||
@@ -251,6 +249,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 {
|
||||
@@ -260,7 +269,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
// The same guard as the mailboxes read over IMAP, for the same reason: a
|
||||
// press of "Fetch now" during the sweep would otherwise make two runs from
|
||||
// one marker.
|
||||
unlock := s.lockMailbox(box.ID)
|
||||
unlock := s.lockSync(box.ID)
|
||||
defer unlock()
|
||||
if fresh, err := s.db.MailboxOfKind(ctx, user.ID, "gmail"); err == nil && fresh != nil {
|
||||
box = fresh
|
||||
@@ -362,14 +371,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 {
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GmailTokenURL = google.URL + "/token"
|
||||
cfg.GoogleTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
@@ -97,7 +97,7 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GmailTokenURL = google.URL + "/token"
|
||||
cfg.GoogleTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
// A second rather than the real twenty-five: what is being tested is that a
|
||||
// budget is applied at all, and spending the production one on every run
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/kristianwind/verdande/internal/ai"
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
@@ -214,6 +215,24 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The address is checked where it is typed, as well as when it is dialled.
|
||||
//
|
||||
// safedial refuses a private destination at connect time and is the actual
|
||||
// wall; this is so the person entering it hears about it now rather than
|
||||
// discovering it later as a failed summary. A scheme other than http or https
|
||||
// is refused outright — file:// and gopher:// are not providers.
|
||||
if url := strings.TrimSpace(req.BaseURL); url != "" {
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"})
|
||||
return
|
||||
}
|
||||
if reason := safedial.CheckURL(url); reason != "" {
|
||||
writeFieldErrors(w, map[string]string{"base_url": reason})
|
||||
return
|
||||
}
|
||||
req.BaseURL = url
|
||||
}
|
||||
|
||||
existing, err := s.aiConfig(r, user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "ai settings", err)
|
||||
|
||||
@@ -143,7 +143,7 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
// One reader of this mailbox at a time. Held for the whole run, marker and all:
|
||||
// releasing before the marker is written would leave exactly the gap this is
|
||||
// here to close.
|
||||
unlock := s.lockMailbox(m.ID)
|
||||
unlock := s.lockSync(m.ID)
|
||||
defer unlock()
|
||||
|
||||
// Re-read under the lock. The row that came in may have been fetched before the
|
||||
@@ -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
|
||||
@@ -231,8 +250,14 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// lockMailbox serialises one mailbox against itself and returns the release.
|
||||
func (s *Server) lockMailbox(id string) func() {
|
||||
// lockSync serialises one thing that polls against itself and returns the release.
|
||||
//
|
||||
// A mailbox to begin with; a calendar connection now takes the same lock under a
|
||||
// key of its own. Two runs at once — somebody pressing "fetch now" while the sweep
|
||||
// is in the middle of the same thing — both start from the state as it was, and
|
||||
// the loser writes last. For a mailbox that made forty copies of one mail in a
|
||||
// second; for a calendar it would be a window deleted after it was refilled.
|
||||
func (s *Server) lockSync(id string) func() {
|
||||
value, _ := s.syncing.LoadOrStore(id, &sync.Mutex{})
|
||||
mu := value.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -45,7 +48,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "search notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(s.readable(r, found))})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -59,7 +62,20 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "notes in project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
return
|
||||
}
|
||||
|
||||
// The archive is the same list asked the other way round, so it is the same
|
||||
// endpoint with a flag rather than a second one that would have to be kept in
|
||||
// step with this.
|
||||
if r.URL.Query().Get("archived") != "" {
|
||||
found, err := s.db.ArchivedNotes(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list archived notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,7 +87,41 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "list notes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": found})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
|
||||
}
|
||||
|
||||
// briefly replaces each note's body with the first of it.
|
||||
//
|
||||
// The list shows a title and about ninety characters. It was being sent whole
|
||||
// notes to do that: with twelve hundred of them the response was 845 KB, of which
|
||||
// ninety-eight per cent was body text nobody would read on that screen — fetched,
|
||||
// parsed and held in memory on every visit.
|
||||
//
|
||||
// Body is emptied rather than shortened, and that is the important half. A field
|
||||
// called `body` that holds a *piece* of the body is a trap: the editor would open
|
||||
// it, the debounce would save it, and the note would be cut down to its own
|
||||
// preview by nothing more than being looked at. Empty is a shape the client can
|
||||
// check, and it does — see the notes page, which fetches the whole note when it
|
||||
// opens one.
|
||||
//
|
||||
// Four hundred characters rather than ninety: the client strips Markdown before
|
||||
// it shows anything, and a heading marker and a bold word are characters that
|
||||
// vanish. Cheap insurance against a preview that ends up shorter than the line it
|
||||
// is meant to fill.
|
||||
const previewChars = 400
|
||||
|
||||
func briefly(notes []store.Note) []store.Note {
|
||||
out := make([]store.Note, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
body := []rune(n.Body)
|
||||
if len(body) > previewChars {
|
||||
body = body[:previewChars]
|
||||
}
|
||||
n.Preview = string(body)
|
||||
n.Body = ""
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readable drops what this person may not see.
|
||||
@@ -291,6 +341,14 @@ func noteFilename(title string) string {
|
||||
// a few megabytes — and small enough that a wrong file is refused rather than read.
|
||||
const maxNoteImportBytes = 64 << 20
|
||||
|
||||
// What the archive may become once it is unpacked, and how many entries it may
|
||||
// hold. See handleImportNotes: the upload cap bounds what arrives, and says
|
||||
// nothing at all about what it expands to.
|
||||
const (
|
||||
maxNoteImportUnpacked = 2 << 30
|
||||
maxNoteImportFiles = 20_000
|
||||
)
|
||||
|
||||
// handleImportNotes reads a zip of Markdown files and makes a note of each.
|
||||
//
|
||||
// The mirror of the export, and deliberately the same shape: whatever comes out of
|
||||
@@ -329,30 +387,72 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
// note is read its pictures already have addresses to be rewritten to — a note
|
||||
// and its images arrive in whatever order the archive happens to hold them, and
|
||||
// one pass would leave half the links pointing at nothing.
|
||||
// The archive as a whole, not just the upload.
|
||||
//
|
||||
// The 64 MB cap is on what arrives; deflate reaches about 1032:1, so those 64
|
||||
// MB can become sixty-six gigabytes on the way out — written into the same
|
||||
// volume the database lives on. The upload limit says nothing about that, and
|
||||
// nothing else did either.
|
||||
//
|
||||
// Two ceilings: one per file, and one across the archive. A single enormous
|
||||
// photograph and ten thousand small ones are different attacks with the same
|
||||
// ending.
|
||||
if len(zr.File) > maxNoteImportFiles {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that archive holds too many files")
|
||||
return
|
||||
}
|
||||
budget := int64(maxNoteImportUnpacked)
|
||||
|
||||
stored := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() || skipFile(name) || strings.EqualFold(path.Ext(name), ".md") {
|
||||
continue
|
||||
}
|
||||
// The header's own claim first, which costs nothing to check and rejects
|
||||
// the obvious case before a byte is written. It is only a claim, so the
|
||||
// reader below is limited as well.
|
||||
if f.UncompressedSize64 > maxUploadBytes {
|
||||
s.log.Warn("import: a file in the archive is too large", "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hashed, size, err := s.storeUpload(rc)
|
||||
hashed, size, err := s.storeUpload(io.LimitReader(rc, min(budget, maxUploadBytes)+1))
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
s.log.Warn("import note file", "err", err, "file", name)
|
||||
continue
|
||||
}
|
||||
if size > maxUploadBytes || size > budget {
|
||||
// Over the line once it was actually read: remove what was written
|
||||
// rather than keeping a truncated file that would open as nothing.
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), hashed))
|
||||
s.log.Warn("import: archive is larger unpacked than allowed", "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
budget -= size
|
||||
stored[name] = hashed
|
||||
_ = size
|
||||
files++
|
||||
}
|
||||
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
if f.FileInfo().IsDir() || skipFile(name) || !strings.EqualFold(path.Ext(name), ".md") {
|
||||
if f.FileInfo().IsDir() || !strings.EqualFold(path.Ext(name), ".md") {
|
||||
continue
|
||||
}
|
||||
// Counted, not passed over in silence. A Markdown file the filter refuses is
|
||||
// a note that did not arrive, and the caller is told the same number either
|
||||
// way unless it is said here — which is how one note went missing without
|
||||
// anything looking wrong.
|
||||
if skipFile(name) {
|
||||
s.log.Warn("import skipped a note file", "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -368,13 +468,24 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
n := &store.Note{CreatedBy: user.ID, Body: string(body)}
|
||||
text, wrote, changed := readFrontMatter(string(body))
|
||||
|
||||
n := &store.Note{CreatedBy: user.ID, Body: text}
|
||||
if err := s.db.SaveNote(r.Context(), n); err != nil {
|
||||
s.log.Warn("import note", "err", err, "file", name)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// After the save, not as part of it: SaveNote owns `updated_at` and is right
|
||||
// to, so the dates a note had before this program existed are written over
|
||||
// the top rather than passed through it.
|
||||
if !wrote.IsZero() || !changed.IsZero() {
|
||||
if err := s.db.SetNoteTimes(r.Context(), n.ID, wrote, changed); err != nil {
|
||||
s.log.Warn("import note dates", "err", err, "file", name)
|
||||
}
|
||||
}
|
||||
|
||||
// The pictures the note referred to, now that it has an id to hang them on.
|
||||
// The text is rewritten to the addresses they were given, so an image that
|
||||
// sat in the middle of a paragraph is still in the middle of it.
|
||||
@@ -397,10 +508,34 @@ func (s *Server) handleImportNotes(w http.ResponseWriter, r *http.Request) {
|
||||
// A zip made on a Mac carries a __MACOSX copy of every file, and importing those
|
||||
// would double a whole library with each copy full of binary rubbish.
|
||||
func skipFile(name string) bool {
|
||||
return strings.HasPrefix(name, "__MACOSX/") ||
|
||||
if strings.HasPrefix(name, "__MACOSX/") ||
|
||||
strings.HasPrefix(path.Base(name), "._") ||
|
||||
strings.HasPrefix(path.Base(name), ".") ||
|
||||
strings.Contains(name, "..")
|
||||
strings.HasPrefix(path.Base(name), ".") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Traversal, read as path segments rather than as a substring.
|
||||
//
|
||||
// This was `strings.Contains(name, "..")`, which is the right idea aimed at the
|
||||
// wrong thing: it does not describe a path that climbs out of the archive, it
|
||||
// describes any name with two dots next to each other anywhere in it. A note
|
||||
// called "Så blev det endelig jul! Stay tuned... ☕️" was therefore dropped —
|
||||
// silently, and without even counting as skipped, so the number the import
|
||||
// reported still looked right. One note in twelve hundred, found only by
|
||||
// counting what came out.
|
||||
//
|
||||
// An ellipsis in a title is ordinary. A segment that *is* "..", or a path that
|
||||
// starts at the root, is not — and those are what a zip uses to write outside
|
||||
// the folder it was unpacked into.
|
||||
if strings.HasPrefix(name, "/") {
|
||||
return true
|
||||
}
|
||||
for _, part := range strings.Split(name, "/") {
|
||||
if part == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// attachToNote records the files a note's text points at and rewrites the text to
|
||||
@@ -421,10 +556,20 @@ func (s *Server) attachToNote(
|
||||
continue
|
||||
}
|
||||
base := path.Base(f.Name)
|
||||
// The two ways a Markdown file names a neighbouring image: by the path it
|
||||
// was written with, and by its bare name.
|
||||
// The three ways a Markdown file names a neighbouring image: by its path
|
||||
// inside the archive, by the path relative to the note, and by its bare name.
|
||||
//
|
||||
// The archive path was missing, and it is the one an Apple Notes export
|
||||
// actually writes — `vedhaeftninger/751-2-IMG_0615.heic`, with the note at the
|
||||
// root beside the folder. Only the bare name matched, so only the bare name
|
||||
// was replaced, and every picture in every imported note ended up pointing at
|
||||
// `vedhaeftninger//api/v1/attachments/…`, which is nothing. The images were
|
||||
// stored and attached correctly; it was the link that was wrong.
|
||||
relative := strings.TrimPrefix(path.Join(dir, base), "./")
|
||||
if !strings.Contains(body, base) && !strings.Contains(body, relative) {
|
||||
refs := []string{f.Name, relative, base}
|
||||
if !strings.Contains(body, f.Name) &&
|
||||
!strings.Contains(body, relative) &&
|
||||
!strings.Contains(body, base) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -441,14 +586,81 @@ func (s *Server) attachToNote(
|
||||
continue
|
||||
}
|
||||
|
||||
// Længste først. Erstattes det bare navn inde i en længere sti, bliver mappen
|
||||
// stående foran adressen, og linket peger på ingenting.
|
||||
url := "/api/v1/attachments/" + a.ID
|
||||
body = strings.ReplaceAll(body, relative, url)
|
||||
body = strings.ReplaceAll(body, base, url)
|
||||
sort.Slice(refs, func(i, j int) bool { return len(refs[i]) > len(refs[j]) })
|
||||
for _, ref := range refs {
|
||||
if ref != "" {
|
||||
body = strings.ReplaceAll(body, ref, url)
|
||||
}
|
||||
}
|
||||
attached++
|
||||
}
|
||||
return body, attached
|
||||
}
|
||||
|
||||
// readFrontMatter takes the YAML block off the top of a Markdown file and reads
|
||||
// the two dates out of it, returning the rest of the text.
|
||||
//
|
||||
// Not a YAML parser, and deliberately not: the block this reads is the one this
|
||||
// program writes, plus whatever Obsidian and Bear write, and all of them use the
|
||||
// same handful of `key: value` lines. Pulling in a parser to read two dates would
|
||||
// be a dependency that can fail in ways nobody here would recognise.
|
||||
//
|
||||
// A file with no front matter comes back untouched — that is the normal case, and
|
||||
// it must never lose its first line to a rule about a block that is not there.
|
||||
func readFrontMatter(body string) (text string, created, modified time.Time) {
|
||||
if !strings.HasPrefix(body, "---\n") && !strings.HasPrefix(body, "---\r\n") {
|
||||
return body, time.Time{}, time.Time{}
|
||||
}
|
||||
rest := body[strings.Index(body, "\n")+1:]
|
||||
|
||||
end := strings.Index(rest, "\n---")
|
||||
if end < 0 {
|
||||
// An opening fence with no close is not front matter; it is a horizontal
|
||||
// rule somebody wrote at the top of their note.
|
||||
return body, time.Time{}, time.Time{}
|
||||
}
|
||||
block := rest[:end]
|
||||
after := rest[end+len("\n---"):]
|
||||
after = strings.TrimPrefix(after, "\r")
|
||||
after = strings.TrimPrefix(after, "\n")
|
||||
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'`))
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "created", "created_at", "date":
|
||||
created = parseNoteTime(value)
|
||||
case "modified", "updated", "updated_at":
|
||||
modified = parseNoteTime(value)
|
||||
}
|
||||
}
|
||||
return strings.TrimLeft(after, "\n"), created, modified
|
||||
}
|
||||
|
||||
// parseNoteTime accepts the shapes these files actually carry. A date without a
|
||||
// time is that day at midnight, local — the note was written on that day, and
|
||||
// pretending to know the hour would be worse than not having one.
|
||||
func parseNoteTime(v string) time.Time {
|
||||
for _, layout := range []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02",
|
||||
} {
|
||||
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// mimeOf guesses from the extension. The archive does not carry a type, and
|
||||
// sniffing the bytes would mean reading every file twice.
|
||||
func mimeOf(name string) string {
|
||||
@@ -457,3 +669,139 @@ 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))
|
||||
}
|
||||
|
||||
// --- putting notes away, one or many ---------------------------------------------
|
||||
|
||||
type noteBulkRequest struct {
|
||||
IDs []string `json:"ids"`
|
||||
Archived *bool `json:"archived"`
|
||||
Delete bool `json:"delete"`
|
||||
}
|
||||
|
||||
// maxNoteBulk bounds one call. Enough to select a screenful several times over,
|
||||
// and few enough that the loop below cannot become somebody's afternoon.
|
||||
const maxNoteBulk = 500
|
||||
|
||||
// handleNoteBulk archives, unarchives or deletes a set of notes in one request.
|
||||
//
|
||||
// One call rather than one per note, because the thing being asked is one thing:
|
||||
// "put these away". Fifty separate requests would be fifty round trips, fifty
|
||||
// chances for half of them to fail, and a list that redraws fifty times — and
|
||||
// after an import of twelve hundred notes, selecting fifty is the ordinary case,
|
||||
// not the extreme one.
|
||||
//
|
||||
// Each note is still checked on its own. A set is a convenience for the caller
|
||||
// and must never become a way to touch something they could not have touched one
|
||||
// at a time.
|
||||
func (s *Server) handleNoteBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var req noteBulkRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
writeFieldErrors(w, map[string]string{"ids": "required"})
|
||||
return
|
||||
}
|
||||
if len(req.IDs) > maxNoteBulk {
|
||||
writeFieldErrors(w, map[string]string{"ids": "too many at once"})
|
||||
return
|
||||
}
|
||||
|
||||
done, skipped := 0, 0
|
||||
for _, id := range req.IDs {
|
||||
n, err := s.db.Note(r.Context(), id)
|
||||
if err != nil {
|
||||
s.internal(w, r, "get note", err)
|
||||
return
|
||||
}
|
||||
// Not an error, and not a 404 for the whole call: a set can contain a note
|
||||
// somebody else deleted a second ago, and the other forty-nine should still
|
||||
// be put away. Counted so the answer is honest about it.
|
||||
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case req.Delete:
|
||||
err = s.db.DeleteNote(r.Context(), id)
|
||||
case req.Archived != nil:
|
||||
err = s.db.SetNoteArchived(r.Context(), id, *req.Archived)
|
||||
default:
|
||||
writeFieldErrors(w, map[string]string{"archived": "say archived or delete"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, "note bulk", err)
|
||||
return
|
||||
}
|
||||
done++
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"done": done, "skipped": skipped})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package httpapi
|
||||
|
||||
import "testing"
|
||||
|
||||
// En titel med tre prikker i er ikke et forsøg på at skrive uden for mappen.
|
||||
//
|
||||
// `strings.Contains(name, "..")` var den rigtige idé rettet mod det forkerte:
|
||||
// den beskriver ikke en sti, der klatrer ud af arkivet, men ethvert navn med to
|
||||
// prikker ved siden af hinanden. Noten "Så blev det endelig jul! Stay tuned... ☕️"
|
||||
// blev derfor droppet lydløst — én ud af tolv hundrede, og kun fundet ved at
|
||||
// tælle efter, hvor mange der kom ud.
|
||||
func TestSkipFileTakesEllipsesButRefusesTraversal(t *testing.T) {
|
||||
keep := []string{
|
||||
"Så blev det endelig jul! Stay tuned... ☕️.md",
|
||||
"Møde 12.8..md",
|
||||
"vedhaeftninger/1-2-Pasted Graphic.png",
|
||||
"noter/Referat....md",
|
||||
}
|
||||
for _, name := range keep {
|
||||
if skipFile(name) {
|
||||
t.Errorf("skipFile(%q) = true, men det er et almindeligt filnavn", name)
|
||||
}
|
||||
}
|
||||
|
||||
drop := []string{
|
||||
"../uden for.md",
|
||||
"noter/../../etc/passwd",
|
||||
"/etc/passwd",
|
||||
"__MACOSX/._noget",
|
||||
".skjult.md",
|
||||
"noter/._AppleDouble.md",
|
||||
}
|
||||
for _, name := range drop {
|
||||
if !skipFile(name) {
|
||||
t.Errorf("skipFile(%q) = false, men den skal afvises", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// En zip-bombe skal ikke kunne skrive gigabyte ned på den disk, databasen ligger
|
||||
// på.
|
||||
//
|
||||
// Uploaden er begrænset til 64 MB, og det siger ingenting om, hvad de 64 MB
|
||||
// bliver til: deflate når omkring 1032:1, så de kan pakkes ud til seksogtres
|
||||
// gigabyte. Prøven bygger en rigtig lille bombe — fire megabyte nuller, som
|
||||
// pakker til nogle få kilobyte — og ser efter, at loftet på det udpakkede holder.
|
||||
//
|
||||
// Skrevet imod tallene frem for imod endepunktet, fordi det, der skal holde, er
|
||||
// forholdet: en fil, hvis udpakkede størrelse overstiger loftet, må ikke læses
|
||||
// færdig.
|
||||
func TestAZipBombIsRefusedByItsUnpackedSize(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, err := zw.Create("bombe.bin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Nuller pakker næsten helt væk. Fire megabyte er nok til at vise forholdet
|
||||
// uden at prøven selv bruger en gigabyte.
|
||||
if _, err := w.Write(make([]byte, 4<<20)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
packed := int64(buf.Len())
|
||||
if packed > 64<<10 {
|
||||
t.Fatalf("prøvens egen bombe pakkede kun til %d bytes; den viser ikke noget", packed)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), packed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := zr.File[0]
|
||||
if f.UncompressedSize64 != 4<<20 {
|
||||
t.Fatalf("udpakket størrelse = %d", f.UncompressedSize64)
|
||||
}
|
||||
|
||||
// Det, importen gør: læs højst det, budgettet tillader, plus én byte — så en
|
||||
// fil, der er præcis på grænsen, kan skelnes fra en, der er over.
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rc.Close()
|
||||
budget := int64(1 << 20)
|
||||
n, err := io.Copy(io.Discard, io.LimitReader(rc, budget+1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n <= budget {
|
||||
t.Fatalf("læste %d bytes af en fil på 4 MB med et budget på %d — loftet fanger den ikke", n, budget)
|
||||
}
|
||||
}
|
||||
|
||||
// Tallene skal stå i et forhold, der giver mening: en enkelt fil må aldrig kunne
|
||||
// bruge hele arkivets budget, og arkivet må ikke være mindre end én fil.
|
||||
func TestTheImportCeilingsAgreeWithEachOther(t *testing.T) {
|
||||
if maxNoteImportUnpacked <= maxUploadBytes {
|
||||
t.Error("arkivets budget er ikke større end én enkelt fil")
|
||||
}
|
||||
if maxNoteImportFiles < 2000 {
|
||||
t.Error("filloftet er under det, en rigtig Apple Noter-eksport indeholder")
|
||||
}
|
||||
}
|
||||
@@ -328,12 +328,19 @@ func TestAnImportedNoteKeepsItsPictures(t *testing.T) {
|
||||
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
|
||||
}
|
||||
|
||||
// The list carries a preview, not the whole note — see briefly(). The body is
|
||||
// fetched by id, which is what the interface does when it opens one.
|
||||
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
|
||||
notes := listed["notes"].([]any)
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("found %d notes", len(notes))
|
||||
}
|
||||
body := notes[0].(map[string]any)["body"].(string)
|
||||
noteID := notes[0].(map[string]any)["id"].(string)
|
||||
if got := notes[0].(map[string]any)["body"]; got != nil && got != "" {
|
||||
t.Errorf("the list carried a body: %v — an editor handed that would save the truncation", got)
|
||||
}
|
||||
_, whole := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
|
||||
body := whole["body"].(string)
|
||||
|
||||
// The link points at the file where it now lives, not at a path inside an
|
||||
// archive nobody kept.
|
||||
|
||||
@@ -2,11 +2,13 @@ package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/push"
|
||||
"github.com/kristianwind/verdande/internal/safedial"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
@@ -170,6 +172,23 @@ func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The endpoint is an address this server will POST to, on a schedule the
|
||||
// caller chooses — set a reminder a minute out and the push job fetches it,
|
||||
// again every minute. Unchecked, that is a way to make the instance reach
|
||||
// anything it can route to, on demand.
|
||||
//
|
||||
// https only: a push service is on the public internet and speaks TLS. Nothing
|
||||
// legitimate needs http here, and allowing it is the difference between a
|
||||
// blind request and a readable one.
|
||||
if !strings.HasPrefix(req.Endpoint, "https://") {
|
||||
writeFieldErrors(w, map[string]string{"endpoint": "must be an https address"})
|
||||
return
|
||||
}
|
||||
if reason := safedial.CheckURL(req.Endpoint); reason != "" {
|
||||
writeFieldErrors(w, map[string]string{"endpoint": reason})
|
||||
return
|
||||
}
|
||||
|
||||
err := s.db.SavePushSubscription(r.Context(), &store.PushSubscription{
|
||||
UserID: userFrom(r.Context()).ID,
|
||||
Endpoint: req.Endpoint,
|
||||
@@ -189,7 +208,10 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
|
||||
// Scoped to the caller. It was not, so anybody who learned somebody else's
|
||||
// endpoint could unsubscribe their device — a small thing to do to somebody
|
||||
// and no reason at all to be able to.
|
||||
if err := s.db.DeletePushSubscription(r.Context(), userFrom(r.Context()).ID, req.Endpoint); err != nil {
|
||||
s.internal(w, r, "delete push subscription", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) {
|
||||
// Stands in for a run: takes the lock, notes how many are inside, and holds it
|
||||
// until told to let go.
|
||||
run := func(done chan<- struct{}) {
|
||||
unlock := srv.lockMailbox("box-1")
|
||||
unlock := srv.lockSync("box-1")
|
||||
mu.Lock()
|
||||
inside++
|
||||
if inside > most {
|
||||
@@ -136,10 +136,10 @@ func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) {
|
||||
|
||||
// And two different mailboxes must not wait for each other: one slow host is
|
||||
// not everybody's problem.
|
||||
a := srv.lockMailbox("box-a")
|
||||
a := srv.lockSync("box-a")
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
srv.lockMailbox("box-b")()
|
||||
srv.lockSync("box-b")()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
|
||||
@@ -64,6 +64,11 @@ type Server struct {
|
||||
// than the rest of the API.
|
||||
loginLimiter *limiter
|
||||
resetLimiter *limiter
|
||||
// Second factors are counted against the *account*, not the address. An
|
||||
// address bucket is defeated by anybody with more than one address, which is
|
||||
// the ordinary case — and a six-digit code is only safe while guesses are
|
||||
// scarce, so the scarcity has to be counted against the thing being attacked.
|
||||
totpLimiter *limiter
|
||||
|
||||
// Computed once from the built index.html; see csp.go.
|
||||
csp string
|
||||
@@ -81,6 +86,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
hub: realtime.NewHub(log),
|
||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||
resetLimiter: newLimiter(5, time.Hour),
|
||||
// Tighter than the password bucket, and for a whole hour. Somebody typing
|
||||
// their own code gets it right on the first or second try; ten in an hour is
|
||||
// generous for a person and hopeless for a search through a million.
|
||||
totpLimiter: newLimiter(10, time.Hour),
|
||||
}
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
||||
@@ -133,6 +142,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireAuth)
|
||||
r.Get("/oauth/gmail/callback", s.handleGmailCallback)
|
||||
// The calendar comes back on a URI of its own rather than sharing Gmail's.
|
||||
// One callback deciding from stored state which of the two flows had
|
||||
// returned would put the choice of *which token store to write* behind a
|
||||
// lookup — and when that goes wrong it goes wrong by overwriting a working
|
||||
// Gmail connection with calendar tokens.
|
||||
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
|
||||
})
|
||||
r.Route("/caldav", s.caldavRoutes)
|
||||
|
||||
@@ -143,6 +158,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)
|
||||
@@ -181,11 +209,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Put("/nav-order", s.handleSetNavOrder)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Post("/password/change", s.handleChangePassword)
|
||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||
r.Post("/totp/confirm", s.handleTOTPConfirm)
|
||||
r.Post("/totp/disable", s.handleTOTPDisable)
|
||||
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
|
||||
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
||||
|
||||
// Behind requireSession, like the API tokens and for the same
|
||||
// reason: a leaked token must not be able to end the sessions of
|
||||
@@ -195,6 +219,23 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/sessions", s.handleListSessions)
|
||||
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
|
||||
|
||||
// Turning the second factor on, and the codes that get you back
|
||||
// in. Sessions only, and setup asks for the password.
|
||||
//
|
||||
// These sat outside this group, which meant the door opened with
|
||||
// a token and closed only with a password: `disable` has always
|
||||
// asked for the password, `setup` asked for nothing. A stolen
|
||||
// token could therefore fetch the secret, load it into its own
|
||||
// authenticator, confirm it, and keep the only copy of the
|
||||
// recovery codes. The owner's next login would stop at a code
|
||||
// they do not have, and they could not turn it off without
|
||||
// logging in first. A theft becomes a tenancy — the same
|
||||
// sentence written next door about passkeys, and the same rule.
|
||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||
r.Post("/totp/confirm", s.handleTOTPConfirm)
|
||||
r.Post("/totp/disable", s.handleTOTPDisable)
|
||||
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
||||
|
||||
// Registering a key, and the list of them. Sessions only, for
|
||||
// the same reason: a leaked token must not be able to add its
|
||||
// own way in — that would turn a theft into a tenancy.
|
||||
@@ -288,6 +329,13 @@ 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)
|
||||
// Mange på én gang. Efter en import af tolv hundrede noter er det at
|
||||
// vælge halvtreds det almindelige tilfælde, ikke det yderste.
|
||||
r.Post("/bulk", s.handleNoteBulk)
|
||||
})
|
||||
|
||||
r.Route("/mailboxes", func(r chi.Router) {
|
||||
@@ -316,6 +364,19 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
})
|
||||
})
|
||||
|
||||
// A calendar read from somewhere else, laid under verdande's own dated
|
||||
// tasks. Read-only, so there is no write here to gate: the events are a
|
||||
// copy of somebody else's calendar, and the only thing a person decides
|
||||
// is which of them to look at.
|
||||
r.Route("/calendar", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetCalendar)
|
||||
r.Put("/calendars", s.handleSetCalendars)
|
||||
r.Delete("/", s.handleDisconnectCalendar)
|
||||
r.Post("/authorize", s.handleCalendarAuthorize)
|
||||
r.Post("/sync", s.handleCalendarSyncNow)
|
||||
r.Get("/events", s.handleCalendarEvents)
|
||||
})
|
||||
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
r.Post("/todoist", s.handleImportTodoist)
|
||||
r.Post("/csv", s.handleImportCSV)
|
||||
@@ -390,6 +451,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.
|
||||
|
||||
@@ -20,7 +20,24 @@ type totpSetupResponse struct {
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
// The password, the same as disable has always asked for.
|
||||
//
|
||||
// Symmetry is the point. Turning the second factor on is at least as
|
||||
// consequential as turning it off — it decides which device gets to say yes
|
||||
// from now on — and a door that opens more easily than it closes is not a
|
||||
// door. Together with the move behind requireSession, this is what stops a
|
||||
// stolen credential from enrolling its own authenticator and keeping the only
|
||||
// copy of the recovery codes.
|
||||
var req totpDisableRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if err := auth.VerifyPassword(user.PasswordHash, req.Password); err != nil {
|
||||
writeFieldErrors(w, map[string]string{"password": "not correct"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.TOTPEnabled {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"two-factor authentication is already switched on")
|
||||
|
||||
@@ -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: "[email protected]", body: "Vedhæftet.", flagged: true},
|
||||
{subject: "Nyhedsbrev", from: "[email protected]", body: "Læs mere", flagged: false},
|
||||
{subject: "Kontrakt", from: "[email protected]", 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: "[email protected]", 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,10 +41,19 @@ type Runner struct {
|
||||
// reason as SyncGmail: it owns turning a message into a task.
|
||||
SyncMailbox func(ctx context.Context, user *store.User, m *store.Mailbox) (int, error)
|
||||
|
||||
// SyncCalendars refreshes one person's connected calendars. Supplied the same
|
||||
// way and for the same reason: the HTTP layer owns the token refresh.
|
||||
SyncCalendars func(ctx context.Context, user *store.User) (int, error)
|
||||
|
||||
// Push delivers a notification to somebody's devices. Supplied by the HTTP
|
||||
// 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 +78,17 @@ 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)
|
||||
// Fifteen minutes rather than ten. A calendar is not a mailbox: nothing here
|
||||
// becomes a task, so being a quarter of an hour behind on a meeting next
|
||||
// Tuesday costs nothing — and the "fetch now" button covers the one case where
|
||||
// it matters, which is somebody who has just moved something and is looking at
|
||||
// the grid to check. A person who has just connected does not wait for this
|
||||
// either; connecting fetches the list, and the first sync is one click away.
|
||||
r.every(ctx, "calendars", 15*time.Minute, r.syncCalendars)
|
||||
// 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 +123,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.
|
||||
@@ -322,6 +353,44 @@ func (r *Runner) syncMailboxes(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- calendars ------------------------------------------------------------------------
|
||||
|
||||
// syncCalendars refreshes everybody's connected calendars.
|
||||
//
|
||||
// Its own sweep rather than a branch inside the mailbox one. They share nothing
|
||||
// past the word "poll": that loop asks each mailbox how far it has read and makes
|
||||
// tasks, this one replaces a window of somebody else's events. Folding them
|
||||
// together would mean one interval for two questions that do not want the same
|
||||
// answer, and a Google that is down would count against a mailbox that is not.
|
||||
//
|
||||
// Nobody is waiting, so no budget of its own — the request handler has one because
|
||||
// a person is watching a spinner. One account that has gone quiet costs this run
|
||||
// its own timeout and nothing else: the loop moves on per person.
|
||||
func (r *Runner) syncCalendars(ctx context.Context) error {
|
||||
if r.SyncCalendars == nil {
|
||||
return nil
|
||||
}
|
||||
userIDs, err := r.db.UsersWithCalendars(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range userIDs {
|
||||
user, err := r.db.UserByID(ctx, id)
|
||||
if err != nil || user == nil {
|
||||
continue
|
||||
}
|
||||
written, err := r.SyncCalendars(ctx, user)
|
||||
if err != nil {
|
||||
r.log.Warn("calendar sync", "err", err, "user", id)
|
||||
continue
|
||||
}
|
||||
if written > 0 {
|
||||
r.log.Info("calendar refreshed", "user", id, "events", written)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- housekeeping -----------------------------------------------------------------
|
||||
|
||||
func (r *Runner) emptyTrash(ctx context.Context) error {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// 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"
|
||||
"net/url"
|
||||
"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}
|
||||
}
|
||||
|
||||
// CheckURL reports why an address must not be fetched, or "" if it may be.
|
||||
//
|
||||
// A convenience for saying no at the moment somebody types an address, rather
|
||||
// than later when a request quietly fails. It is NOT the protection — a name can
|
||||
// resolve one way now and another way when it is dialled, which is why Control
|
||||
// exists and runs every time. Anything this rejects is certainly wrong; anything
|
||||
// it accepts is merely not obviously wrong.
|
||||
func CheckURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return "that is not a URL"
|
||||
}
|
||||
host := u.Hostname()
|
||||
if ip := net.ParseIP(host); ip != nil && Blocked(ip) {
|
||||
return "that address is on this machine or its private network"
|
||||
}
|
||||
// A bare name is looked up once so an obvious "localhost" is caught here. A
|
||||
// failure to resolve is not an error: the host may simply not exist yet, and
|
||||
// refusing to save an address because DNS was slow would be its own bug.
|
||||
if ips, err := net.LookupIP(host); err == nil {
|
||||
for _, ip := range ips {
|
||||
if Blocked(ip) {
|
||||
return "that address is on this machine or its private network"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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,380 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CalendarAccount is one connected account belonging to one person.
|
||||
type CalendarAccount struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"-"`
|
||||
Provider string `json:"provider"`
|
||||
Account string `json:"account,omitempty"`
|
||||
|
||||
RefreshToken string `json:"-"` // never leaves the server
|
||||
AccessToken string `json:"-"`
|
||||
ExpiresAt time.Time `json:"-"`
|
||||
|
||||
LastSyncAt time.Time `json:"last_sync_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Calendar is one calendar inside an account, shown or not.
|
||||
type Calendar struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"-"`
|
||||
RemoteID string `json:"remote_id"`
|
||||
Name string `json:"name"`
|
||||
Colour string `json:"colour,omitempty"`
|
||||
TimeZone string `json:"time_zone,omitempty"`
|
||||
Primary bool `json:"primary"`
|
||||
// Writable says the connected account could write to it. Nothing does — the
|
||||
// scope is read-only — and it is stored so the day something can, the interface
|
||||
// knows which calendars could even be offered.
|
||||
Writable bool `json:"writable"`
|
||||
Shown bool `json:"shown"`
|
||||
SortOrder int `json:"-"`
|
||||
}
|
||||
|
||||
// CalendarEvent is one occurrence, in the shape a grid asks about: days at both
|
||||
// ends, inclusive.
|
||||
type CalendarEvent struct {
|
||||
ID string `json:"id"`
|
||||
CalendarID string `json:"calendar_id"`
|
||||
RemoteID string `json:"-"`
|
||||
Summary string `json:"summary"`
|
||||
StartsAt string `json:"starts_at,omitempty"`
|
||||
EndsAt string `json:"ends_at,omitempty"`
|
||||
StartDay string `json:"start_day"`
|
||||
EndDay string `json:"end_day"`
|
||||
AllDay bool `json:"all_day"`
|
||||
Location string `json:"location,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// CalendarName and Colour ride along with the event rather than being looked up
|
||||
// per row by the caller. Same reasoning as the assignee names on a task list:
|
||||
// the chip has to be drawn in the calendar's own colour, and a second request
|
||||
// per event to learn it is a request per event.
|
||||
CalendarName string `json:"calendar_name,omitempty"`
|
||||
Colour string `json:"colour,omitempty"`
|
||||
}
|
||||
|
||||
const calendarAccountColumns = `id, user_id, provider, account, refresh_token,
|
||||
access_token, expires_at, last_sync_at, created_at`
|
||||
|
||||
// CalendarAccountFor returns a person's connection to one provider, or nil.
|
||||
func (db *DB) CalendarAccountFor(ctx context.Context, userID, provider string) (*CalendarAccount, error) {
|
||||
row := db.QueryRowContext(ctx, `SELECT `+calendarAccountColumns+`
|
||||
FROM calendar_accounts WHERE user_id = ? AND provider = ?`, userID, provider)
|
||||
a, err := db.scanCalendarAccount(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// SaveCalendarAccount writes one, sealing the two columns that must not travel
|
||||
// with a backup. Called by hand rather than by the settings tables' own seal,
|
||||
// which does not reach this table.
|
||||
func (db *DB) SaveCalendarAccount(ctx context.Context, a *CalendarAccount) error {
|
||||
if a.ID == "" {
|
||||
a.ID = NewID()
|
||||
}
|
||||
if a.Provider == "" {
|
||||
a.Provider = "google"
|
||||
}
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
refresh, err := db.sealValue(a.RefreshToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal refresh_token: %w", err)
|
||||
}
|
||||
access, err := db.sealValue(a.AccessToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal access_token: %w", err)
|
||||
}
|
||||
|
||||
// The conflict is on the pair, not on the id: a reconnect builds a fresh
|
||||
// struct with no id in it, and matching on the id would insert a second row
|
||||
// that the unique index then refuses — which reads to the person as "connecting
|
||||
// failed" when what happened is that it already had.
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO calendar_accounts (`+calendarAccountColumns+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, provider) DO UPDATE SET
|
||||
account = excluded.account,
|
||||
refresh_token = excluded.refresh_token,
|
||||
access_token = excluded.access_token,
|
||||
expires_at = excluded.expires_at,
|
||||
last_sync_at = excluded.last_sync_at`,
|
||||
a.ID, a.UserID, a.Provider, a.Account, refresh, access,
|
||||
unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The row that is actually there wins. On a reconnect the insert above updated
|
||||
// an existing row, so the id in hand is one nothing points at — and the
|
||||
// calendars are hung off the id, so writing them against it would attach them
|
||||
// to nothing.
|
||||
return db.QueryRowContext(ctx,
|
||||
`SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ?`,
|
||||
a.UserID, a.Provider).Scan(&a.ID)
|
||||
}
|
||||
|
||||
// MarkCalendarSynced records how far a run got. Written separately from the rest
|
||||
// so a sync never rewrites the credentials it is holding in memory.
|
||||
func (db *DB) MarkCalendarSynced(ctx context.Context, accountID string, at time.Time) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE calendar_accounts SET last_sync_at = ? WHERE id = ?`, at.Unix(), accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteCalendarAccount disconnects one. The calendars and their events go with it
|
||||
// — unlike the tasks a mailbox made, none of this is the person's own work; it is
|
||||
// a copy of somebody else's calendar and it has no meaning without the account.
|
||||
func (db *DB) DeleteCalendarAccount(ctx context.Context, userID, id string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`DELETE FROM calendar_accounts WHERE id = ? AND user_id = ?`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UsersWithCalendars returns everybody the sweep has to visit.
|
||||
func (db *DB) UsersWithCalendars(ctx context.Context) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT DISTINCT user_id FROM calendar_accounts`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// Calendars returns what one account holds, in the order the provider listed them.
|
||||
func (db *DB) Calendars(ctx context.Context, accountID string) ([]Calendar, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, account_id, remote_id, name, colour, time_zone, primary_one,
|
||||
writable, shown, sort_order
|
||||
FROM calendars WHERE account_id = ? ORDER BY sort_order, name`, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Calendar
|
||||
for rows.Next() {
|
||||
var c Calendar
|
||||
if err := rows.Scan(&c.ID, &c.AccountID, &c.RemoteID, &c.Name, &c.Colour,
|
||||
&c.TimeZone, &c.Primary, &c.Writable, &c.Shown, &c.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ReplaceCalendars writes the list the provider just gave, keeping what the person
|
||||
// chose.
|
||||
//
|
||||
// The choice survives because it is not in the list: Google says which calendars
|
||||
// exist and what they are called, and only verdande knows which of them somebody
|
||||
// wants to look at. Merged rather than deleted and rewritten, so ticking a calendar
|
||||
// on does not come undone the next time the sweep runs.
|
||||
//
|
||||
// A calendar that has disappeared from the account is removed, and its events go
|
||||
// with it — a calendar somebody was unshared from should stop being drawn, not
|
||||
// stay behind as a frozen copy of what it held on the last good day.
|
||||
func (db *DB) ReplaceCalendars(ctx context.Context, accountID string, list []Calendar) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
keep := map[string]bool{}
|
||||
for i, c := range list {
|
||||
keep[c.RemoteID] = true
|
||||
// The insert cannot carry `shown`: on conflict it would overwrite the
|
||||
// choice with the default. A new calendar starts hidden — an account
|
||||
// with a dozen shared calendars in it should not fill somebody's grid
|
||||
// the moment they connect it.
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO calendars
|
||||
(id, account_id, remote_id, name, colour, time_zone, primary_one,
|
||||
writable, shown, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (account_id, remote_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
colour = excluded.colour,
|
||||
time_zone = excluded.time_zone,
|
||||
primary_one = excluded.primary_one,
|
||||
writable = excluded.writable,
|
||||
sort_order = excluded.sort_order`,
|
||||
NewID(), accountID, c.RemoteID, c.Name, c.Colour, c.TimeZone,
|
||||
c.Primary, c.Writable, c.Shown, i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT id, remote_id FROM calendars WHERE account_id = ?`, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var gone []string
|
||||
for rows.Next() {
|
||||
var id, remote string
|
||||
if err := rows.Scan(&id, &remote); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if !keep[remote] {
|
||||
gone = append(gone, id)
|
||||
}
|
||||
}
|
||||
// Closed before the next statement runs on this transaction. An open result
|
||||
// set with a query behind it is the deadlock the pool comment in store.go
|
||||
// exists to make slow rather than fatal.
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range gone {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM calendars WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ShowCalendars sets which of an account's calendars are drawn.
|
||||
//
|
||||
// The whole set in one write rather than one flip at a time, for the reason the
|
||||
// projects' order is written the same way: a person has a handful of calendars, the
|
||||
// question is "these ones", and a list that can only be built by several requests
|
||||
// can land half applied.
|
||||
func (db *DB) ShowCalendars(ctx context.Context, accountID string, shown []string) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE calendars SET shown = 0 WHERE account_id = ?`, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range shown {
|
||||
// Scoped in the SQL rather than checked first: an id read in one step
|
||||
// and written in the next is a window, and the account is the whole of
|
||||
// the permission here.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE calendars SET shown = 1 WHERE id = ? AND account_id = ?`,
|
||||
id, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Events of a calendar nobody is looking at are dead weight in a table the
|
||||
// grid queries. They come back with the next sync if it is turned on again.
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM calendar_events WHERE calendar_id IN
|
||||
(SELECT id FROM calendars WHERE account_id = ? AND shown = 0)`, accountID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// ReplaceCalendarEvents swaps one calendar's events inside a window.
|
||||
//
|
||||
// Replaced rather than merged, and no sync token. Google offers incremental sync,
|
||||
// and it would save bytes verdande is not short of while costing the one thing it
|
||||
// cannot afford: a token that has to be right, forever, for the grid not to be
|
||||
// quietly wrong. A window that is deleted and rewritten cannot drift — an event
|
||||
// somebody moved out of the window disappears because it is not in the answer, not
|
||||
// because verdande remembered to delete it.
|
||||
//
|
||||
// Bounded by the window at both ends, so a sync of September does not throw away
|
||||
// August.
|
||||
func (db *DB) ReplaceCalendarEvents(ctx context.Context, calendarID, from, to string, events []CalendarEvent) error {
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM calendar_events
|
||||
WHERE calendar_id = ? AND end_day >= ? AND start_day <= ?`,
|
||||
calendarID, from, to); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range events {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO calendar_events
|
||||
(id, calendar_id, remote_id, summary, starts_at, ends_at,
|
||||
start_day, end_day, all_day, location, url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
NewID(), calendarID, e.RemoteID, e.Summary, e.StartsAt, e.EndsAt,
|
||||
e.StartDay, e.EndDay, e.AllDay, e.Location, e.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CalendarEvents returns everything one person can see between two days,
|
||||
// inclusive at both ends.
|
||||
//
|
||||
// Scoped to the caller in the join rather than checked afterwards: the events of
|
||||
// somebody else's calendar are somebody else's, and a filter applied after the read
|
||||
// is a filter that can be forgotten.
|
||||
func (db *DB) CalendarEvents(ctx context.Context, userID, from, to string) ([]CalendarEvent, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT e.id, e.calendar_id, e.remote_id, e.summary, e.starts_at, e.ends_at,
|
||||
e.start_day, e.end_day, e.all_day, e.location, e.url, c.name, c.colour
|
||||
FROM calendar_events e
|
||||
JOIN calendars c ON c.id = e.calendar_id
|
||||
JOIN calendar_accounts a ON a.id = c.account_id
|
||||
WHERE a.user_id = ? AND c.shown = 1 AND e.end_day >= ? AND e.start_day <= ?
|
||||
ORDER BY e.start_day, e.all_day DESC, e.starts_at`, userID, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []CalendarEvent
|
||||
for rows.Next() {
|
||||
var e CalendarEvent
|
||||
if err := rows.Scan(&e.ID, &e.CalendarID, &e.RemoteID, &e.Summary, &e.StartsAt,
|
||||
&e.EndsAt, &e.StartDay, &e.EndDay, &e.AllDay, &e.Location, &e.URL,
|
||||
&e.CalendarName, &e.Colour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) {
|
||||
var a CalendarAccount
|
||||
var expires, lastSync, created int64
|
||||
err := row.Scan(&a.ID, &a.UserID, &a.Provider, &a.Account, &a.RefreshToken,
|
||||
&a.AccessToken, &expires, &lastSync, &created)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
for _, field := range []*string{&a.RefreshToken, &a.AccessToken} {
|
||||
plain, err := db.unsealValue(*field)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
*field = plain
|
||||
}
|
||||
a.ExpiresAt = timeOrZero(expires)
|
||||
a.LastSyncAt = timeOrZero(lastSync)
|
||||
a.CreatedAt = time.Unix(created, 0)
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func connectedAccount(t *testing.T, db *DB, userID string) *CalendarAccount {
|
||||
t.Helper()
|
||||
a := &CalendarAccount{
|
||||
UserID: userID, Provider: "google", Account: "[email protected]",
|
||||
RefreshToken: "1//0-a-real-looking-refresh-token", AccessToken: "ya29.access",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
if err := db.SaveCalendarAccount(context.Background(), a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// The same claim the mailbox tokens have to meet: what is written must not be
|
||||
// readable in the file a backup copies.
|
||||
func TestCalendarTokensAreNotStoredInTheClear(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
|
||||
var refresh, access string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT refresh_token, access_token FROM calendar_accounts WHERE id = ?`,
|
||||
a.ID).Scan(&refresh, &access); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(refresh, "real-looking") || strings.Contains(access, "ya29") {
|
||||
t.Fatalf("a token is in the row as written: %q %q", refresh, access)
|
||||
}
|
||||
// The address stays readable, which is the reason for sealing named columns
|
||||
// rather than the whole row.
|
||||
var account string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT account FROM calendar_accounts WHERE id = ?`, a.ID).Scan(&account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account != "[email protected]" {
|
||||
t.Errorf("the address was sealed too: %q", account)
|
||||
}
|
||||
|
||||
back, err := db.CalendarAccountFor(ctx, userID, "google")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.RefreshToken != a.RefreshToken || back.AccessToken != a.AccessToken {
|
||||
t.Errorf("the tokens did not survive the round trip: %+v", back)
|
||||
}
|
||||
}
|
||||
|
||||
// Connecting again must replace what was there, not fail. It is the same OAuth
|
||||
// flow run a second time, and the person's word for it is "connect", not "add".
|
||||
func TestConnectingAgainReplacesTheAccount(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
first := connectedAccount(t, db, userID)
|
||||
|
||||
second := &CalendarAccount{
|
||||
UserID: userID, Provider: "google", Account: "[email protected]",
|
||||
RefreshToken: "1//0-fresh", AccessToken: "ya29.fresh",
|
||||
}
|
||||
if err := db.SaveCalendarAccount(ctx, second); err != nil {
|
||||
t.Fatalf("connecting a second time failed: %v", err)
|
||||
}
|
||||
// And the id in hand is the row that is actually there. The calendars hang off
|
||||
// it, so an id nothing points at would attach them to nothing.
|
||||
if second.ID != first.ID {
|
||||
t.Errorf("the second connect came back with id %q, want the existing %q",
|
||||
second.ID, first.ID)
|
||||
}
|
||||
|
||||
back, err := db.CalendarAccountFor(ctx, userID, "google")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.RefreshToken != "1//0-fresh" {
|
||||
t.Errorf("the token came back as %q", back.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
// Google says which calendars exist; only verdande knows which ones somebody wants
|
||||
// to look at. A refresh that came back with the same list must not undo the choice.
|
||||
func TestRefreshingTheListKeepsWhatWasChosen(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
|
||||
list := []Calendar{
|
||||
{RemoteID: "primary", Name: "[email protected]", Colour: "#3f51b5", Primary: true},
|
||||
{RemoteID: "fam", Name: "Familien", Colour: "#0b8043"},
|
||||
{RemoteID: "old", Name: "Et delt"},
|
||||
}
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Nothing is shown to begin with: an account with a dozen shared calendars in
|
||||
// it should not fill somebody's grid the moment they connect it.
|
||||
stored, err := db.Calendars(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stored) != 3 {
|
||||
t.Fatalf("got %d calendars, want 3", len(stored))
|
||||
}
|
||||
for _, c := range stored {
|
||||
if c.Shown {
|
||||
t.Errorf("%q was shown without anybody asking for it", c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
var famID string
|
||||
for _, c := range stored {
|
||||
if c.RemoteID == "fam" {
|
||||
famID = c.ID
|
||||
}
|
||||
}
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{famID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The sweep runs again, and one calendar has been renamed and one has gone.
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{
|
||||
{RemoteID: "primary", Name: "[email protected]", Colour: "#3f51b5", Primary: true},
|
||||
{RemoteID: "fam", Name: "Familien og hunden", Colour: "#0b8043"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stored, err = db.Calendars(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stored) != 2 {
|
||||
t.Fatalf("got %d calendars; one that has left the account should stop being drawn",
|
||||
len(stored))
|
||||
}
|
||||
for _, c := range stored {
|
||||
switch c.RemoteID {
|
||||
case "fam":
|
||||
if !c.Shown {
|
||||
t.Error("the choice was undone by a refresh of the list")
|
||||
}
|
||||
if c.Name != "Familien og hunden" {
|
||||
t.Errorf("the new name did not arrive: %q", c.Name)
|
||||
}
|
||||
if c.ID != famID {
|
||||
t.Error("the row was rebuilt rather than updated; its id is what was chosen")
|
||||
}
|
||||
case "primary":
|
||||
if c.Shown {
|
||||
t.Error("a calendar nobody chose became shown")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A window is replaced, not merged. An event moved out of it has to disappear
|
||||
// because it is absent from the answer — and the months on either side must not go
|
||||
// with it.
|
||||
func TestReplacingAWindowLeavesTheMonthsAroundItAlone(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "primary", Name: "Min"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, a.ID)
|
||||
cal := stored[0]
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{cal.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
july := CalendarEvent{RemoteID: "j", Summary: "Juli", StartDay: "2026-07-15", EndDay: "2026-07-15"}
|
||||
august := CalendarEvent{RemoteID: "a", Summary: "August", StartDay: "2026-08-15", EndDay: "2026-08-15"}
|
||||
if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-07-01", "2026-07-31",
|
||||
[]CalendarEvent{july}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-08-01", "2026-08-31",
|
||||
[]CalendarEvent{august}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := db.CalendarEvents(ctx, userID, "2026-07-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d events, want both months: %+v", len(got), got)
|
||||
}
|
||||
|
||||
// August is synced again and the meeting has been cancelled. July stays.
|
||||
if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-08-01", "2026-08-31", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = db.CalendarEvents(ctx, userID, "2026-07-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Summary != "Juli" {
|
||||
t.Fatalf("got %+v; syncing August threw away July", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The grid asks "which cells does this cover", so an event that straddles the edge
|
||||
// of the question has to come back.
|
||||
func TestAnEventThatStraddlesTheWindowIsFound(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min", Colour: "#0b8043"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, a.ID)
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-09-30",
|
||||
[]CalendarEvent{
|
||||
{RemoteID: "trip", Summary: "Aarhus", StartDay: "2026-08-28", EndDay: "2026-09-02", AllDay: true},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A week that contains neither end of it.
|
||||
got, err := db.CalendarEvents(ctx, userID, "2026-08-31", "2026-09-01")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %+v; an event covering the whole week was not found", got)
|
||||
}
|
||||
// The calendar's own colour rides along, or the chip has to ask for it per row.
|
||||
if got[0].Colour != "#0b8043" || got[0].CalendarName != "Min" {
|
||||
t.Errorf("the calendar did not come with the event: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's calendar is somebody else's.
|
||||
func TestOnlyTheOwnersEventsComeBack(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
other := &User{Email: "[email protected]", Name: "Andreas"}
|
||||
if err := db.CreateUser(ctx, other, "Indbakke"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs := &CalendarAccount{UserID: other.ID, Provider: "google", RefreshToken: "r"}
|
||||
if err := db.SaveCalendarAccount(ctx, theirs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendars(ctx, theirs.ID, []Calendar{{RemoteID: "c", Name: "Deres"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, theirs.ID)
|
||||
if err := db.ShowCalendars(ctx, theirs.ID, []string{stored[0].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31",
|
||||
[]CalendarEvent{{RemoteID: "x", Summary: "Privat", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := db.CalendarEvents(ctx, userID, "2026-08-01", "2026-08-31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("somebody else's calendar came back: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A calendar turned off stops being drawn, and stops costing the grid anything.
|
||||
func TestTurningACalendarOffRemovesItsEvents(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, a.ID)
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31",
|
||||
[]CalendarEvent{{RemoteID: "x", Summary: "Møde", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := db.ShowCalendars(ctx, a.ID, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM calendar_events`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("%d events of a calendar nobody is looking at are still in the table", count)
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnecting takes the calendars and the events with it. Unlike the tasks a
|
||||
// mailbox made, none of this is the person's own work — it is a copy of somebody
|
||||
// else's calendar and it means nothing without the account.
|
||||
func TestDisconnectingTakesEverythingWithIt(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
a := connectedAccount(t, db, userID)
|
||||
if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, _ := db.Calendars(ctx, a.ID)
|
||||
if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31",
|
||||
[]CalendarEvent{{RemoteID: "x", Summary: "Møde", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := db.DeleteCalendarAccount(ctx, userID, a.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, table := range []string{"calendar_accounts", "calendars", "calendar_events"} {
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM `+table).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("%s still holds %d rows after disconnecting", table, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sweep finds people by the presence of an account, so it has to keep working
|
||||
// now that the credential in it is ciphertext.
|
||||
func TestTheSweepFindsAConnectedCalendar(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
connectedAccount(t, db, userID)
|
||||
|
||||
users, err := db.UsersWithCalendars(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users) != 1 || users[0] != userID {
|
||||
t.Fatalf("the sweep found %v", users)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,111 @@
|
||||
-- Calendars read from somewhere else: the account, the calendars in it, and the
|
||||
-- events they hold.
|
||||
--
|
||||
-- Not in `mailboxes`, and it was the first thing considered. The two look alike
|
||||
-- from a distance — a person connects a Google account over OAuth, tokens are
|
||||
-- sealed, a sweep polls it — and every column past that disagrees. `trigger_kind`,
|
||||
-- `seen`, `last_uid` and `project_id` all answer "what turns a message into a
|
||||
-- task", and nothing here turns into a task. What is here instead is a *set*:
|
||||
-- one account holds many calendars, and a person picks which of them to look at.
|
||||
-- `mailboxes` has no room for a one-to-many, and giving it one would push the
|
||||
-- other kind's columns onto rows that can never use them.
|
||||
--
|
||||
-- The decisive part is smaller and more practical. `mailboxes.kind` carries
|
||||
-- `CHECK (kind IN ('gmail', 'imap'))`, and SQLite cannot alter a CHECK — adding a
|
||||
-- third value means rebuilding the table with foreign keys off, which is the
|
||||
-- expensive, careful thing migration 0021 had to do for a reason. Paying that to
|
||||
-- store something that shares no column with the table is the wrong trade.
|
||||
--
|
||||
-- Hence no CHECK on `provider` here. CalDAV is the next source to read, and it
|
||||
-- should cost a row, not a rebuild.
|
||||
|
||||
-- One connected account per person per provider. The tokens live here rather than
|
||||
-- on each calendar: a refresh token copied onto ten rows is ten copies of one
|
||||
-- secret, ten refreshes racing each other, and ten places to miss when access is
|
||||
-- revoked.
|
||||
--
|
||||
-- `refresh_token` and `access_token` are written sealed; see internal/secret. The
|
||||
-- store's own seal/unseal only covers the settings tables, so this table's methods
|
||||
-- call them by hand — the same fact worth knowing that migration 0014 wrote down.
|
||||
CREATE TABLE calendar_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL DEFAULT 'google',
|
||||
-- The address the account reports, so the settings page can say which one is
|
||||
-- connected rather than only that one is.
|
||||
account TEXT NOT NULL DEFAULT '',
|
||||
|
||||
refresh_token TEXT NOT NULL DEFAULT '',
|
||||
access_token TEXT NOT NULL DEFAULT '',
|
||||
expires_at INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
last_sync_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Connecting again replaces what was there, the way Gmail's does: the OAuth flow
|
||||
-- is one account at a time, so a second row for the same provider would be a
|
||||
-- second set of tokens for the same grant and no way to say which is live.
|
||||
CREATE UNIQUE INDEX calendar_accounts_once ON calendar_accounts (user_id, provider);
|
||||
|
||||
-- One row per calendar the account can see, kept whether or not it is shown.
|
||||
--
|
||||
-- `shown` rather than deleting the ones that are not: a person who turns a calendar
|
||||
-- off and on again should get the same row back, and the list is refreshed from
|
||||
-- Google on every sync — so a calendar that only existed while it was ticked would
|
||||
-- lose its choice every time somebody added a new one.
|
||||
--
|
||||
-- `colour` is Google's own hex for the calendar. Not one of verdande's ten named
|
||||
-- colours, which is the rule everywhere else in this database: those are names so
|
||||
-- the interface can restyle them per theme, and they are *verdande's* palette. A
|
||||
-- Google calendar is told apart by the colour the person already knows it by, and
|
||||
-- that colour belongs to Google.
|
||||
CREATE TABLE calendars (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES calendar_accounts (id) ON DELETE CASCADE,
|
||||
-- The id Google knows it by. Long, and an address for the primary calendar.
|
||||
remote_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
colour TEXT NOT NULL DEFAULT '',
|
||||
time_zone TEXT NOT NULL DEFAULT '',
|
||||
primary_one INTEGER NOT NULL DEFAULT 0,
|
||||
writable INTEGER NOT NULL DEFAULT 0,
|
||||
shown INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX calendars_once ON calendars (account_id, remote_id);
|
||||
|
||||
-- The events themselves, cached.
|
||||
--
|
||||
-- Cached rather than fetched per view, and the reason is what a month grid does:
|
||||
-- it pages. Asking Google on every arrow press means a token refresh, a round trip
|
||||
-- and a spinner for a question that was answered ninety seconds ago — and a
|
||||
-- calendar that is slower than the calendar it is showing is not worth having.
|
||||
-- The window is refreshed by the sweep and by the "fetch now" button.
|
||||
--
|
||||
-- Days rather than instants, and both ends inclusive. A grid asks one question —
|
||||
-- "which cells does this cover" — and `start_day <= ? AND end_day >= ?` is that
|
||||
-- question. `starts_at` is kept as Google wrote it, offset and all, because it is
|
||||
-- what the chip shows and reparsing it here would ask *this process* what time
|
||||
-- zone it is in. An all-day event has neither: it has a day, not a moment, and
|
||||
-- inventing midnight for it is how an event moves a day when a container moves
|
||||
-- time zone.
|
||||
CREATE TABLE calendar_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
calendar_id TEXT NOT NULL REFERENCES calendars (id) ON DELETE CASCADE,
|
||||
remote_id TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
starts_at TEXT NOT NULL DEFAULT '',
|
||||
ends_at TEXT NOT NULL DEFAULT '',
|
||||
start_day TEXT NOT NULL,
|
||||
end_day TEXT NOT NULL,
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- The one query there is: everything a person can see, between two days. Ordered
|
||||
-- by the account so the join has somewhere to start, then by the days the grid
|
||||
-- compares against.
|
||||
CREATE INDEX calendar_events_by_day ON calendar_events (calendar_id, start_day, end_day);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- A note can be put away without being thrown away.
|
||||
--
|
||||
-- The trash already exists and is the wrong tool. It says "this was a mistake,
|
||||
-- and in thirty days it is gone" — so anything worth keeping cannot go in it, and
|
||||
-- an archive of twelve hundred imported notes is exactly the case where most of
|
||||
-- them are worth keeping and almost none are worth looking at. Without somewhere
|
||||
-- to put them, the list is the whole archive forever, and finding this week's note
|
||||
-- means reading past a decade.
|
||||
--
|
||||
-- A column rather than a folder. Verdande already refused a second hierarchy for
|
||||
-- notes — see 0018 — and archiving is not filing: it is one bit about whether a
|
||||
-- note is current, and it has to be answerable next to `deleted_at`, which is the
|
||||
-- other bit of the same kind.
|
||||
--
|
||||
-- Nullable timestamp rather than a boolean, for the same reason `deleted_at` is
|
||||
-- one: "when was this put away" answers questions a flag cannot, and it costs the
|
||||
-- same byte.
|
||||
ALTER TABLE notes ADD COLUMN archived_at INTEGER;
|
||||
|
||||
-- The list asks "not deleted, not archived, newest first" on every load, and with
|
||||
-- twelve hundred notes that is the query that has to be quick.
|
||||
CREATE INDEX notes_current ON notes (archived_at, deleted_at, updated_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -10,15 +10,26 @@ import (
|
||||
|
||||
// Note is a page of Markdown belonging to a project, or to nobody.
|
||||
type Note struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||
|
||||
// Preview is the first of the body, for a list that shows one line of it.
|
||||
//
|
||||
// It exists so a list of twelve hundred notes does not have to carry twelve
|
||||
// hundred whole notes: the list rendered 845 KB of JSON to show ninety
|
||||
// characters of each. When it is set, Body is deliberately empty — see
|
||||
// handleListNotes. That is not tidiness but a guard: an editor handed a
|
||||
// truncated body would save the truncation, and the note would be cut down to
|
||||
// its own preview.
|
||||
Preview string `json:"preview,omitempty"`
|
||||
|
||||
// Links is what the body points at, read out of the text on the way in. Never
|
||||
// set by a caller: it is derived, and a caller that set it would be describing
|
||||
@@ -33,7 +44,16 @@ type NoteLink struct {
|
||||
}
|
||||
|
||||
const noteColumns = `id, project_id, title, body, created_by, pinned,
|
||||
created_at, updated_at, deleted_at`
|
||||
created_at, updated_at, deleted_at, archived_at`
|
||||
|
||||
// The same columns, said with the table in front of them.
|
||||
//
|
||||
// Needed only by the search, which joins `notes_fts` — and that table carries its
|
||||
// own `id` and `project_id` (UNINDEXED, so the row can be found from a match).
|
||||
// Unqualified, SQLite cannot tell which one is meant and refuses the query.
|
||||
const noteColumnsQualified = `notes.id, notes.project_id, notes.title, notes.body,
|
||||
notes.created_by, notes.pinned, notes.created_at, notes.updated_at, notes.deleted_at,
|
||||
notes.archived_at`
|
||||
|
||||
// Reference syntax, and deliberately the same characters the quick-add parser
|
||||
// already uses. A `#Firma` in a note means the same project as a `#Firma` typed
|
||||
@@ -199,8 +219,23 @@ func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||
// shared, and a list that dropped it at that moment would make sharing look like
|
||||
// deleting — which is exactly how it read before this existed.
|
||||
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||
return db.notesWhere(ctx, `
|
||||
WHERE deleted_at IS NULL
|
||||
return db.allNotes(ctx, userID, false)
|
||||
}
|
||||
|
||||
// ArchivedNotes is the same list, the other way round: what has been put away.
|
||||
func (db *DB) ArchivedNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||
return db.allNotes(ctx, userID, true)
|
||||
}
|
||||
|
||||
func (db *DB) allNotes(ctx context.Context, userID string, archived bool) ([]Note, error) {
|
||||
// Archived notes are left out of the ordinary list rather than mixed in and
|
||||
// greyed: the point of putting something away is not seeing it.
|
||||
state := "notes.archived_at IS NULL"
|
||||
if archived {
|
||||
state = "notes.archived_at IS NOT NULL"
|
||||
}
|
||||
return db.notesJoin(ctx, noteColumnsQualified, `
|
||||
WHERE `+state+` AND deleted_at IS NULL
|
||||
AND (
|
||||
(project_id IS NULL AND created_by = ?)
|
||||
OR project_id IN (
|
||||
@@ -238,15 +273,82 @@ func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note,
|
||||
if expr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// By rowid, the way tasks do it: `id` is UNINDEXED in the table, so selecting
|
||||
// on it would work and read the whole index to do it.
|
||||
return db.notesWhere(ctx, `
|
||||
WHERE deleted_at IS NULL
|
||||
AND rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?)
|
||||
ORDER BY updated_at DESC
|
||||
// Ordered by how well it matched, not by when it was last touched.
|
||||
//
|
||||
// `ORDER BY updated_at DESC` was invisible while there were ten notes and
|
||||
// useless at twelve hundred: every one of them was imported the same evening,
|
||||
// so `updated_at` says nothing, and the limit then cut the list at fifty
|
||||
// arbitrary rows. Searching "Tesla" answered with a note about nginx first and
|
||||
// the note actually called "Tesla Model Y" second — which reads as a search
|
||||
// that does not work, because from where the person is sitting it is one.
|
||||
//
|
||||
// bm25 with the title weighted ten times the body. A word in the title is what
|
||||
// the note is *about*; the same word once in a long body is a mention. `fold`
|
||||
// carries the same text transliterated, so it must weigh the same as the body
|
||||
// or a Danish match would outrank an exact one.
|
||||
//
|
||||
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
|
||||
// subquery throws it away.
|
||||
return db.notesJoin(ctx, noteColumnsQualified, `
|
||||
JOIN notes_fts ON notes_fts.rowid = notes.rowid
|
||||
WHERE notes.deleted_at IS NULL
|
||||
AND notes_fts MATCH ?
|
||||
ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0)
|
||||
LIMIT ?`, expr, limit)
|
||||
}
|
||||
|
||||
// SetNoteTimes writes the dates a note had somewhere else.
|
||||
//
|
||||
// Only the import calls this, and it exists because SaveNote is right to refuse:
|
||||
// `updated_at` is when this program last wrote the note, and letting any caller
|
||||
// set it would make the column mean whatever the last writer felt like. A note
|
||||
// brought in from Apple Notes is the one case where the dates are facts from
|
||||
// before this database existed — and without them a move of twelve hundred notes
|
||||
// is a pile that was all written the same evening, which throws away the order
|
||||
// they were written in. That order is half of what an archive is.
|
||||
func (db *DB) SetNoteTimes(ctx context.Context, id string, created, updated time.Time) error {
|
||||
if created.IsZero() && updated.IsZero() {
|
||||
return nil
|
||||
}
|
||||
// COALESCE, so a note carrying only one of the two keeps what it already had
|
||||
// for the other rather than being stamped with the zero time — which would sort
|
||||
// it to the year one.
|
||||
var c, u any
|
||||
if !created.IsZero() {
|
||||
c = created.Unix()
|
||||
}
|
||||
if !updated.IsZero() {
|
||||
u = updated.Unix()
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE notes
|
||||
SET created_at = COALESCE(?, created_at),
|
||||
updated_at = COALESCE(?, updated_at)
|
||||
WHERE id = ?`, c, u, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetNoteArchived puts a note away, or takes it back out.
|
||||
//
|
||||
// Separate from DeleteNote because they mean different things and the difference
|
||||
// is the whole feature: the trash says "this was a mistake, and in thirty days it
|
||||
// is gone", and the archive says "this is finished, and I want to keep it". A
|
||||
// twelve-hundred-note import is mostly the second, and without somewhere to put
|
||||
// them the list is the whole archive forever.
|
||||
//
|
||||
// `updated_at` is deliberately not touched. Archiving is not editing, and a list
|
||||
// sorted by when things were last written should not be reordered by somebody
|
||||
// tidying up.
|
||||
func (db *DB) SetNoteArchived(ctx context.Context, id string, archived bool) error {
|
||||
var at any
|
||||
if archived {
|
||||
at = time.Now().Unix()
|
||||
}
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE notes SET archived_at = ? WHERE id = ? AND deleted_at IS NULL`, at, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteNote puts it in the trash. Nothing here removes a row.
|
||||
func (db *DB) DeleteNote(ctx context.Context, id string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
@@ -264,7 +366,13 @@ func (db *DB) RestoreNote(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
func (db *DB) notesWhere(ctx context.Context, where string, args ...any) ([]Note, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+noteColumns+` FROM notes `+where, args...)
|
||||
return db.notesJoin(ctx, noteColumns, where, args...)
|
||||
}
|
||||
|
||||
// notesJoin is notesWhere with the column list spelled out, for the one caller
|
||||
// that joins another table and must therefore say which `id` it means.
|
||||
func (db *DB) notesJoin(ctx context.Context, columns, where string, args ...any) ([]Note, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+columns+` FROM notes `+where, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -304,10 +412,10 @@ func scanNote(row scanner) (Note, error) {
|
||||
var n Note
|
||||
var projectID, createdBy sql.NullString
|
||||
var created, updated int64
|
||||
var deleted sql.NullInt64
|
||||
var deleted, archived sql.NullInt64
|
||||
|
||||
if err := row.Scan(&n.ID, &projectID, &n.Title, &n.Body, &createdBy,
|
||||
&n.Pinned, &created, &updated, &deleted); err != nil {
|
||||
&n.Pinned, &created, &updated, &deleted, &archived); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n.ProjectID = projectID.String
|
||||
@@ -318,5 +426,9 @@ func scanNote(row scanner) (Note, error) {
|
||||
t := time.Unix(deleted.Int64, 0)
|
||||
n.DeletedAt = &t
|
||||
}
|
||||
if archived.Valid {
|
||||
t := time.Unix(archived.Int64, 0)
|
||||
n.ArchivedAt = &t
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -333,3 +333,39 @@ func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
|
||||
t.Error("a note title did not match itself")
|
||||
}
|
||||
}
|
||||
|
||||
// En søgning skal svare med det, der passer bedst, øverst.
|
||||
//
|
||||
// Den sorterede efter `updated_at`, hvilket var usynligt med ti noter og
|
||||
// ubrugeligt med tolv hundrede: de blev alle importeret samme aften, så datoen
|
||||
// siger ingenting, og loftet skar så listen af ved halvtreds tilfældige rækker.
|
||||
// At søge "Tesla" svarede med en note om nginx først og noten, der hedder "Tesla
|
||||
// Model Y", bagefter — hvilket læses som en søgning, der ikke virker, fordi det
|
||||
// fra brugerens plads er præcis dét.
|
||||
func TestSearchAnswersWithTheBestMatchFirst(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Skrevet i den rækkefølge, der ville give det forkerte svar: den, der kun
|
||||
// nævner ordet i kroppen, gemmes sidst og har derfor den nyeste dato.
|
||||
for _, body := range []string{
|
||||
"# Tesla Model Y\n\nBilen selv.\n",
|
||||
"# Ladestandere\n\nNoter om Tesla og andre mærker.\n",
|
||||
"# nginx proxy manager backup\n\nEn konfiguration. Tesla nævnes i forbifarten.\n",
|
||||
} {
|
||||
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: body}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
found, err := db.SearchNotes(ctx, "Tesla", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 3 {
|
||||
t.Fatalf("fandt %d noter, ventede 3", len(found))
|
||||
}
|
||||
if found[0].Title != "Tesla Model Y" {
|
||||
t.Errorf("øverst stod %q; den note, der hedder det, man søgte efter, skal stå først", found[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,11 +140,17 @@ func (db *DB) SavePushSubscription(ctx context.Context, sub *PushSubscription) e
|
||||
if sub.ID == "" {
|
||||
sub.ID = NewID()
|
||||
}
|
||||
// The upsert no longer moves the row between accounts.
|
||||
//
|
||||
// `user_id = excluded.user_id` was in the DO UPDATE, so anybody who learned
|
||||
// another person's endpoint could claim it by subscribing with it: the
|
||||
// victim's device would then receive the claimant's notifications and none of
|
||||
// its own. A re-subscribe from the same person still refreshes the keys, which
|
||||
// is what the upsert is actually for.
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, user_agent, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (endpoint) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
user_agent = excluded.user_agent,
|
||||
@@ -175,7 +181,21 @@ func (db *DB) ListPushSubscriptions(ctx context.Context, userID string) ([]PushS
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) DeletePushSubscription(ctx context.Context, endpoint string) error {
|
||||
// DeletePushSubscription removes one of this person's devices.
|
||||
//
|
||||
// Scoped by user as well as by endpoint. It was endpoint alone, which meant
|
||||
// anybody who learned somebody else's endpoint could silently unsubscribe their
|
||||
// phone — and the endpoint is the only thing about a subscription that ever
|
||||
// leaves the browser.
|
||||
func (db *DB) DeletePushSubscription(ctx context.Context, userID, endpoint string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?`, endpoint, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// dropPushEndpoint removes a subscription the push service has declared dead.
|
||||
// Unexported so the only *request* path to a delete stays the scoped one.
|
||||
func (db *DB) dropPushEndpoint(ctx context.Context, endpoint string) error {
|
||||
_, err := db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
|
||||
return err
|
||||
}
|
||||
@@ -186,7 +206,10 @@ func (db *DB) DeletePushSubscription(ctx context.Context, endpoint string) error
|
||||
// silently unsubscribing somebody's phone.
|
||||
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
||||
if permanent {
|
||||
return db.DeletePushSubscription(ctx, endpoint)
|
||||
// Not scoped by user, and that is right here: this is the push service
|
||||
// saying the endpoint is gone, not a person asking for something. There is
|
||||
// no caller to check against — the caller is the delivery job.
|
||||
return db.dropPushEndpoint(ctx, endpoint)
|
||||
}
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE push_subscriptions SET failures = failures + 1 WHERE endpoint = ?`, endpoint)
|
||||
@@ -289,6 +312,11 @@ var secretFields = map[string]bool{
|
||||
"access_token": true,
|
||||
"password": true,
|
||||
"client_secret": true,
|
||||
// The AI provider's key. Missed when this list was written, and it is exactly
|
||||
// the same kind of thing as the rest: a live credential, in a settings blob,
|
||||
// in a database anybody with a backup file can read. Existing rows convert
|
||||
// themselves the next time the settings are saved.
|
||||
"api_key": true,
|
||||
}
|
||||
|
||||
// seal returns a copy of values with the secret fields sealed. Without a key it
|
||||
|
||||
@@ -20,6 +20,13 @@ func FoldDanish(s string) string {
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// The ceilings on a search. See MatchExprOver for why they exist; both are far
|
||||
// above anything a person types and far below anything that costs.
|
||||
const (
|
||||
maxSearchInput = 512
|
||||
maxSearchTerms = 16
|
||||
)
|
||||
|
||||
// MatchExpr turns what someone typed into the search box into an FTS5 MATCH
|
||||
// expression.
|
||||
//
|
||||
@@ -51,6 +58,23 @@ func MatchExprOver(query string, columns ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Capped, because the expression is built from the input and then run inside
|
||||
// SQLite on one of a handful of pooled connections.
|
||||
//
|
||||
// Every term becomes roughly fifty bytes of FTS5, and every one of them is a
|
||||
// prefix query — which is what makes ⌘K feel like it searches as you type, and
|
||||
// also what makes each one expensive. A quarter of a megabyte pasted into the
|
||||
// box became a two-megabyte MATCH expression and half a minute inside SQLite,
|
||||
// holding a connection the whole time. A few of those at once and the database
|
||||
// is unavailable to everybody on the instance, logins included.
|
||||
//
|
||||
// Sixteen terms is far past anything a person types into a search box and
|
||||
// nowhere near enough to hurt. Silently, because a message about a limit
|
||||
// nobody has reached on purpose is noise.
|
||||
if len(terms) > maxSearchTerms {
|
||||
terms = terms[:maxSearchTerms]
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for i, term := range terms {
|
||||
if i > 0 {
|
||||
@@ -70,6 +94,11 @@ func MatchExprOver(query string, columns ...string) string {
|
||||
// punctuation rather than whitespace alone means "q3/2026" searches for "q3" and
|
||||
// "2026" instead of for a term FTS5's own tokenizer would never have indexed.
|
||||
func tokenize(query string) []string {
|
||||
// Trimmed before it is split, so a megabyte of text is not walked rune by rune
|
||||
// only to have all but sixteen of the results thrown away.
|
||||
if len(query) > maxSearchInput {
|
||||
query = query[:maxSearchInput]
|
||||
}
|
||||
fields := strings.FieldsFunc(query, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
|
||||
@@ -129,7 +129,14 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u.TOTPSecret = secret.String
|
||||
// Opened on the way out. A row written before the key existed is returned as
|
||||
// itself — see secret.Unseal — so this does not need a migration; a secret
|
||||
// converts the next time it is written.
|
||||
if plain, err := db.unsealValue(secret.String); err == nil {
|
||||
u.TOTPSecret = plain
|
||||
} else {
|
||||
u.TOTPSecret = secret.String
|
||||
}
|
||||
u.TOTPEnabled = totpEnabled == 1
|
||||
u.IsAdmin = isAdmin == 1
|
||||
// A malformed value is read as "nothing folded" rather than as an error: the
|
||||
@@ -433,9 +440,20 @@ func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale
|
||||
}
|
||||
|
||||
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
||||
// Sealed, like a mailbox password and for the same reason: the copies, not the
|
||||
// server. A TOTP secret in a downloadable backup lets whoever holds the copy
|
||||
// generate valid codes for every account on the instance — which turns the
|
||||
// second factor from a defence into a file somebody can take home.
|
||||
//
|
||||
// It was missed when internal/secret was written, along with three other
|
||||
// values; see SECURITY.md.
|
||||
var value any
|
||||
if secret != "" {
|
||||
value = secret
|
||||
sealed, err := db.sealValue(secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value = sealed
|
||||
}
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET totp_secret = ?, totp_enabled = ?, updated_at = ? WHERE id = ?`,
|
||||
|
||||
@@ -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 |
@@ -31,6 +31,9 @@ import sys
|
||||
from html.parser import HTMLParser
|
||||
|
||||
BLOCK_PREFIX = {"h1": "# ", "h2": "## ", "h3": "### ", "h4": "#### "}
|
||||
# Fra lukkemærke tilbage til åbnemærke, når et mærke skal deles over to linjer.
|
||||
OPENER = {"**": "**", "*": "*", "</u>": "<u>"}
|
||||
MARKS_ONLY = re.compile(r"(?:\*\*|\*|~~|<u>|</u>|\s)*")
|
||||
FORBIDDEN = set('/\\:*?"<>|')
|
||||
|
||||
|
||||
@@ -69,8 +72,26 @@ class NoteParser(HTMLParser):
|
||||
# --- linjer ---------------------------------------------------------------
|
||||
|
||||
def _flush_line(self):
|
||||
# Et mærke, der står åbent, når linjen slutter, lukkes her og åbnes igen på
|
||||
# den næste.
|
||||
#
|
||||
# Noter skriver <b>Møde forberedelse<br></b>: linjeskiftet ligger *inde i*
|
||||
# det fede. Skrev man linjen ud uden videre, blev de to stjerner, der lukker,
|
||||
# til den næste linjes første tegn — "**Møde forberedelse" og så en linje med
|
||||
# "**" på. Markdown er linjebaseret; et mærke, der skal over et linjeskift,
|
||||
# findes ikke, og skal derfor deles i to.
|
||||
reopen = list(self.marks)
|
||||
for closer in reversed(self.marks):
|
||||
self.line.append(closer)
|
||||
|
||||
text = "".join(self.line).rstrip()
|
||||
self.line = []
|
||||
|
||||
# En linje, der kun er mærker, er en tom linje. Det sker for den sidste
|
||||
# stump af et fedt afsnit, hvor der ikke står andet end linjeskiftet.
|
||||
if MARKS_ONLY.fullmatch(text):
|
||||
text = ""
|
||||
|
||||
self.line = [OPENER[c] for c in reopen]
|
||||
mono, self.mono = self.mono, False
|
||||
|
||||
if mono:
|
||||
@@ -121,11 +142,42 @@ class NoteParser(HTMLParser):
|
||||
self.lines.append("")
|
||||
self.mono_lines = []
|
||||
|
||||
def _open(self, opener: str, closer: str):
|
||||
"""Åbner et mærke — eller fortryder det, der lige lukkede.
|
||||
|
||||
Noter deler også fremhævet tekst op i kørsler: "Møde" står som
|
||||
<b>M</b><b>øde</b>, fordi der er en attributkørsel bag hver stump. Skrevet
|
||||
ligeud bliver det **M****øde**, som er ét ord i fed sagt på den grimmest
|
||||
mulige måde — og for kursiv bliver det *M**øde*, hvor de to stjerner i
|
||||
midten læses som fed af enhver Markdown-læser. Ordet kom ud forkert.
|
||||
|
||||
Lukkes et mærke og åbnes det samme igen med det samme, er der ikke sket
|
||||
noget imellem. Så tages lukningen tilbage, og de to kørsler er ét mærke.
|
||||
"""
|
||||
if self.line and self.line[-1] == closer:
|
||||
self.line.pop()
|
||||
else:
|
||||
self.line.append(opener)
|
||||
self.marks.append(closer)
|
||||
|
||||
# --- tags -----------------------------------------------------------------
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in BLOCK_PREFIX:
|
||||
self._flush_line()
|
||||
# En overskrift, der fortsætter den forrige, er den samme linje.
|
||||
#
|
||||
# Noter deler en linje op i så mange stumper, den har brug for — en
|
||||
# overskrift skrevet i ét hug står som
|
||||
# <h1>A</h1><h1>alb</h1><h1>org Kom</h1><h1>mune</h1>, fordi der er en
|
||||
# attributkørsel bag hver stump. Læste man et linjeskift ved hver <h1>,
|
||||
# blev "Aalborg Kommune" til fire overskrifter oven på hinanden, og
|
||||
# noten så ødelagt ud på den mest synlige måde, der findes: i sin egen
|
||||
# titel.
|
||||
#
|
||||
# Der brydes derfor kun, når blokken skifter til en anden slags. Selve
|
||||
# linjeskiftene kommer fra <div> og <br>, som er dem, der betyder det.
|
||||
if self.block != tag:
|
||||
self._flush_line()
|
||||
self.block = tag
|
||||
elif tag in ("ul", "ol"):
|
||||
self._flush_line()
|
||||
@@ -138,23 +190,24 @@ class NoteParser(HTMLParser):
|
||||
# Mærket sættes på linjen og aflæses, når den skrives ud.
|
||||
self.mono = True
|
||||
elif tag in ("b", "strong"):
|
||||
self.marks.append("**")
|
||||
self.line.append("**")
|
||||
self._open("**", "**")
|
||||
elif tag in ("i", "em"):
|
||||
self.marks.append("*")
|
||||
self.line.append("*")
|
||||
self._open("*", "*")
|
||||
elif tag == "u":
|
||||
self.marks.append("</u>")
|
||||
self.line.append("<u>")
|
||||
self._open("<u>", "</u>")
|
||||
elif tag == "br":
|
||||
self._flush_line()
|
||||
self.block = None
|
||||
elif tag == "div":
|
||||
self._flush_line()
|
||||
self.block = None
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in BLOCK_PREFIX:
|
||||
self._flush_line()
|
||||
self.block = None
|
||||
# Ikke her. Lukkes overskriften ved sit eget sluttag, kan den næste
|
||||
# stump af samme linje ikke se, at den hører til — det er <div> og <br>,
|
||||
# der afgør, hvor linjen holder op.
|
||||
pass
|
||||
elif tag in ("ul", "ol"):
|
||||
self._flush_line()
|
||||
if self.list_kind:
|
||||
@@ -172,6 +225,7 @@ class NoteParser(HTMLParser):
|
||||
self.line.append(self.marks.pop())
|
||||
elif tag == "div":
|
||||
self._flush_line()
|
||||
self.block = None
|
||||
|
||||
def handle_data(self, data):
|
||||
self.line.append(data)
|
||||
@@ -208,6 +262,95 @@ def safe_name(title: str, fallback: str) -> str:
|
||||
return name[:80] or fallback
|
||||
|
||||
|
||||
# Skilletegn, der ikke står i en note: enhedsadskillere fra ASCII, som ingen
|
||||
# skriver med et tastatur.
|
||||
FIELD, RECORD = "\u241f", "\u241e"
|
||||
|
||||
|
||||
def osascript_file(script: str) -> str:
|
||||
"""Som osascript(), men til et manuskript, der er for stort til -e."""
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".applescript", delete=False,
|
||||
encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
path = f.name
|
||||
try:
|
||||
out = subprocess.run(["osascript", path], capture_output=True, text=True,
|
||||
timeout=600, check=False)
|
||||
return out.stdout
|
||||
except subprocess.SubprocessError:
|
||||
return ""
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def fetch(ids: list[str]) -> list[dict]:
|
||||
"""Navn, krop og datoer for en håndfuld noter i ét kald.
|
||||
|
||||
Ét kald pr. note var det oplagte, og det tog halvanden time for tolv hundrede
|
||||
— ikke fordi Noter er langsom, men fordi hvert kald er en proces, der skal
|
||||
startes. Det gjorde det umuligt at rette en fejl i oversættelsen og se
|
||||
resultatet: hver runde var en frokostpause. Nu hentes de i klumper, og hele
|
||||
eksporten tager minutter.
|
||||
|
||||
Klumper frem for alle på én gang, fordi en enkelt uleselig note ellers ville
|
||||
tage hele kørslen med sig — og fordi svaret skal kunne være i hukommelsen.
|
||||
|
||||
Datoerne læses som tal frem for som tekst. `creation date` giver en dato
|
||||
formateret efter maskinens sprog, og den ville skulle gættes tilbage; year,
|
||||
month og day er de samme tal på enhver maskine.
|
||||
"""
|
||||
quoted = ", ".join('"' + i.replace('"', '\\"') + '"' for i in ids)
|
||||
script = f'''
|
||||
tell application "Notes"
|
||||
set out to ""
|
||||
repeat with theId in {{{quoted}}}
|
||||
try
|
||||
set n to note id theId
|
||||
set c to creation date of n
|
||||
set m to modification date of n
|
||||
set out to out & (name of n) & "{FIELD}" & ¬
|
||||
((year of c) & "-" & (month of c as integer) & "-" & (day of c) & "T" & ¬
|
||||
(hours of c) & ":" & (minutes of c) & ":" & (seconds of c)) & "{FIELD}" & ¬
|
||||
((year of m) & "-" & (month of m as integer) & "-" & (day of m) & "T" & ¬
|
||||
(hours of m) & ":" & (minutes of m) & ":" & (seconds of m)) & "{FIELD}" & ¬
|
||||
(count of attachments of n) & "{FIELD}" & ¬
|
||||
(body of n) & "{RECORD}"
|
||||
on error
|
||||
set out to out & "{FIELD}{FIELD}{FIELD}{FIELD}" & "{RECORD}"
|
||||
end try
|
||||
end repeat
|
||||
return out
|
||||
end tell
|
||||
'''
|
||||
raw = osascript_file(script)
|
||||
rows = []
|
||||
for chunk in raw.split(RECORD):
|
||||
if not chunk.strip("\n "):
|
||||
continue
|
||||
parts = chunk.split(FIELD)
|
||||
if len(parts) < 5:
|
||||
rows.append({"name": "", "created": "", "modified": "", "files": 0, "body": ""})
|
||||
continue
|
||||
rows.append({
|
||||
"name": parts[0].lstrip("\n"), "created": parts[1], "modified": parts[2],
|
||||
"files": int(parts[3]) if parts[3].strip().isdigit() else 0,
|
||||
"body": parts[4],
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def iso(stamp: str) -> str:
|
||||
""""2026-8-17T12:55:14" → "2026-08-17T12:55:14". Nul foran, så det sorterer."""
|
||||
try:
|
||||
date, _, time = stamp.partition("T")
|
||||
y, mo, d = (int(x) for x in date.split("-"))
|
||||
h, mi, se = (int(x) for x in time.split(":"))
|
||||
return f"{y:04d}-{mo:02d}-{d:02d}T{h:02d}:{mi:02d}:{se:02d}"
|
||||
except (ValueError, AttributeError):
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
out_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/Desktop/apple-noter")
|
||||
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 0
|
||||
@@ -238,16 +381,15 @@ def main() -> int:
|
||||
used: dict[str, int] = {}
|
||||
attachments_dir = os.path.join(out_dir, "vedhaeftninger")
|
||||
|
||||
for i, note_id in enumerate(ids, start=1):
|
||||
# Navn og krop i ét kald, adskilt af noget, der ikke står i en note.
|
||||
# Hentet hver for sig kunne de nå at komme fra hver sin note.
|
||||
both = osascript(
|
||||
f'tell application "Notes" to tell note id "{note_id}" '
|
||||
f'to return name & "\u241E" & body'
|
||||
)
|
||||
title, _, body = both.partition("\u241E")
|
||||
title = title.strip() or f"uden titel {i}"
|
||||
text = to_markdown(body)
|
||||
CHUNK = 40
|
||||
fetched: list[dict] = []
|
||||
for start in range(0, len(ids), CHUNK):
|
||||
fetched.extend(fetch(ids[start:start + CHUNK]))
|
||||
print(f" hentet {min(start + CHUNK, len(ids))} / {total}")
|
||||
|
||||
for i, (note_id, row) in enumerate(zip(ids, fetched), start=1):
|
||||
title = row["name"].strip() or f"uden titel {i}"
|
||||
text = to_markdown(row["body"])
|
||||
|
||||
# Kroppen begynder med notens egen titel. Skrives der en overskrift
|
||||
# ovenover, står titlen to gange i hver eneste note.
|
||||
@@ -255,11 +397,36 @@ def main() -> int:
|
||||
# De tomme linjer skal forbi først: <h1> ligger inde i en <div>, hvis
|
||||
# starttag tømmer linjen før den — så første linje er tom, og
|
||||
# sammenligningen så på ingenting.
|
||||
# Titlen kan være delt over flere overskrifter.
|
||||
#
|
||||
# Noter kalder noten det, der står på de første linjer, sat sammen med
|
||||
# mellemrum — så en note, hvis to første linjer er "CPU" og "CAN", hedder
|
||||
# "CPU CAN". Sammenlignede man kun med den første linje, gik det fri, og
|
||||
# titlen stod bagefter to gange: én gang som overskrift og én gang som de
|
||||
# stumper, den var lavet af. Fjorten noter ud af tolv hundrede.
|
||||
#
|
||||
# Overskrifterne øverst tages derfor med, så længe de tilsammen stadig er
|
||||
# begyndelsen på titlen, og fjernes kun, hvis de til sidst er hele titlen.
|
||||
lines = text.split("\n")
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
if lines and lines[0].lstrip("# ").strip() == title.strip():
|
||||
lines = lines[1:]
|
||||
|
||||
taken, joined = 0, ""
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
taken += 1
|
||||
continue
|
||||
if not line.lstrip().startswith("#"):
|
||||
break
|
||||
candidate = (joined + " " + line.lstrip("# ").strip()).strip()
|
||||
if not title.strip().startswith(candidate):
|
||||
break
|
||||
joined, taken = candidate, taken + 1
|
||||
if joined == title.strip():
|
||||
break
|
||||
|
||||
if joined == title.strip():
|
||||
lines = lines[taken:]
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
text = "\n".join(lines)
|
||||
@@ -268,18 +435,34 @@ def main() -> int:
|
||||
used[name] = used.get(name, 0) + 1
|
||||
path = os.path.join(out_dir, name if used[name] == 1 else f"{name} ({used[name]})") + ".md"
|
||||
|
||||
# Datoerne i et hoved, som enhver anden Markdown-læser kender.
|
||||
#
|
||||
# Uden dem er en flytning af tolv hundrede noter en bunke, der alle er
|
||||
# skrevet samme aften: rækkefølgen, som er halvdelen af hvad et arkiv er,
|
||||
# findes ikke længere. Obsidian, Bear og alt andet læser den her blok, og
|
||||
# verdandes egen import gør nu også.
|
||||
created, modified = iso(row["created"]), iso(row["modified"])
|
||||
head = ""
|
||||
if created or modified:
|
||||
head = "---\n"
|
||||
if created:
|
||||
head += f"created: {created}\n"
|
||||
if modified:
|
||||
head += f"modified: {modified}\n"
|
||||
head += "---\n\n"
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(f"# {title}\n\n{text}\n")
|
||||
f.write(f"{head}# {title}\n\n{text}\n")
|
||||
|
||||
# Billeder og bilag. En note fra Noter er ofte fuld af fotografier, og en
|
||||
# note, der kommer over som ord alene, er ikke den note.
|
||||
count = osascript(
|
||||
f'tell application "Notes" to return count of attachments of note id "{note_id}"'
|
||||
)
|
||||
if count.isdigit() and int(count) > 0:
|
||||
# Antallet kom med i klumpen. Det var ét kald pr. note, og da kun hver
|
||||
# tiende note har et bilag, var de ni af dem en proces startet for at få
|
||||
# svaret nul.
|
||||
if row["files"] > 0:
|
||||
os.makedirs(attachments_dir, exist_ok=True)
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
for j in range(1, int(count) + 1):
|
||||
for j in range(1, row["files"] + 1):
|
||||
a_name = osascript(
|
||||
f'tell application "Notes" to return name of attachment {j} '
|
||||
f'of note id "{note_id}"'
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pakker en Apple Noter-eksport i portioner, der kan importeres.
|
||||
|
||||
Eksporten af tolv hundrede noter fylder knap to hundrede megabyte, og næsten
|
||||
alt sammen er billeder: teksten selv er under fire. Importen tager 64 MB ad
|
||||
gangen, og tunnelen foran den tager mindre — så det skal deles.
|
||||
|
||||
Delt efter hvad en note *vejer med sine billeder*, ikke efter antal. De fleste
|
||||
noter har ingen, så de fylder én portion til sammen; de få med et fotoalbum i
|
||||
får hver deres. En note og dens billeder ligger altid i samme portion, for
|
||||
linkene i teksten er relative, og en note, hvis billeder kom med i næste
|
||||
portion, ville pege på ingenting.
|
||||
|
||||
./pak-noter-i-portioner.py ~/Desktop/apple-noter [MB]
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
# Under importens 64 MB og under det, en tunnel plejer at tage. Hellere en
|
||||
# portion mere end en, der ryger på målstregen.
|
||||
DEFAULT_LIMIT_MB = 40
|
||||
|
||||
LINK = re.compile(r"!\[[^\]]*\]\((vedhaeftninger/[^)]+)\)")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
src = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/Desktop/apple-noter")
|
||||
limit = int(float(sys.argv[2] if len(sys.argv) > 2 else DEFAULT_LIMIT_MB) * 1024 * 1024)
|
||||
out_dir = src.rstrip("/") + "-portioner"
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
notes = sorted(f for f in os.listdir(src) if f.endswith(".md"))
|
||||
if not notes:
|
||||
print(f"Ingen .md-filer i {src}")
|
||||
return 1
|
||||
|
||||
# Hvad hver note vejer, og hvad den trækker med sig.
|
||||
weighed = []
|
||||
for name in notes:
|
||||
path = os.path.join(src, name)
|
||||
text = open(path, encoding="utf-8", errors="replace").read()
|
||||
files = []
|
||||
size = os.path.getsize(path)
|
||||
for rel in LINK.findall(text):
|
||||
f = os.path.join(src, rel)
|
||||
if os.path.exists(f):
|
||||
files.append(rel)
|
||||
size += os.path.getsize(f)
|
||||
weighed.append((name, files, size))
|
||||
|
||||
# En note, der alene er større end grænsen, får sin egen portion frem for at
|
||||
# blive sprunget over: den skal med, og en portion på 25 MB er stadig en, der
|
||||
# kan sendes.
|
||||
batches, current, current_size = [], [], 0
|
||||
for item in weighed:
|
||||
if current and current_size + item[2] > limit:
|
||||
batches.append(current)
|
||||
current, current_size = [], 0
|
||||
current.append(item)
|
||||
current_size += item[2]
|
||||
if current:
|
||||
batches.append(current)
|
||||
|
||||
print(f"{len(notes)} noter → {len(batches)} portioner\n")
|
||||
for i, batch in enumerate(batches, start=1):
|
||||
zip_path = os.path.join(out_dir, f"noter-{i:02d}.zip")
|
||||
seen = set()
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for name, files, _ in batch:
|
||||
z.write(os.path.join(src, name), name)
|
||||
for rel in files:
|
||||
if rel in seen:
|
||||
continue
|
||||
seen.add(rel)
|
||||
z.write(os.path.join(src, rel), rel)
|
||||
mb = os.path.getsize(zip_path) / 1024 / 1024
|
||||
print(f" {os.path.basename(zip_path)} {len(batch):4d} noter {len(seen):3d} bilag {mb:6.1f} MB")
|
||||
|
||||
print(f"\nLigger i:\n {out_dir}")
|
||||
print("\nImportér én ad gangen under Indstillinger → Data → Noter.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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' });
|
||||
@@ -971,6 +979,211 @@ test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async (
|
||||
* asserting against a project the admin can already see would pass with the old
|
||||
* per-project endpoint behind it.
|
||||
*/
|
||||
/**
|
||||
* Kalender: Google's events and verdande's own tasks in one grid, and the rule
|
||||
* that tells them apart.
|
||||
*
|
||||
* The Google half is stubbed at the network layer rather than connected. There are
|
||||
* no credentials in a test run and there never will be — an OAuth consent screen is
|
||||
* a human pressing a button on somebody else's website — so what can be proved here
|
||||
* is what the grid does with events once it has them, which is the part that has a
|
||||
* rule in it: a task is draggable and reschedules, an event is not and cannot.
|
||||
*
|
||||
* The date is read out of the *page*, not out of this process. Playwright pins the
|
||||
* browser to Europe/Copenhagen and node is pinned to nothing, so for two hours of
|
||||
* every day the two disagree about what today is — which is how a green suite
|
||||
* starts failing at half past midnight. Today is also the one day guaranteed to be
|
||||
* in the month grid whatever the weekday, so nothing here has to page.
|
||||
*/
|
||||
test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven kan trækkes', async ({
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
// A day at least a week out, whose next day is in the same Monday-to-Sunday
|
||||
// week — so the drag below crosses one cell and not one grid.
|
||||
//
|
||||
// Worked out in the browser, not here. Playwright pins the page to
|
||||
// Europe/Copenhagen and this process is pinned to nothing, so for two hours of
|
||||
// every day the two disagree about what today is. A week out also keeps this
|
||||
// test's day clear of the ones the rest of the suite dates things on.
|
||||
const { day, next } = await page.evaluate(() => {
|
||||
const iso = (d) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
d.getDate()
|
||||
).padStart(2, '0')}`;
|
||||
const monday = (d) => {
|
||||
const m = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
m.setDate(m.getDate() - ((m.getDay() + 6) % 7));
|
||||
return iso(m);
|
||||
};
|
||||
const cursor = new Date();
|
||||
cursor.setDate(cursor.getDate() + 7);
|
||||
for (let i = 0; i < 14; i++) {
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
const after = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1);
|
||||
if (monday(cursor) === monday(after)) return { day: iso(cursor), next: iso(after) };
|
||||
}
|
||||
throw new Error('no two consecutive days inside one week in three weeks');
|
||||
});
|
||||
|
||||
// A calendar that is connected and holds two events that day: one with a clock
|
||||
// and one that lasts the whole day. The window is deliberately wide, so the
|
||||
// notice about having paged past it stays out of the way.
|
||||
await page.route('**/api/v1/calendar/events*', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
from: '2000-01-01',
|
||||
to: '2099-12-31',
|
||||
events: [
|
||||
{
|
||||
id: 'ev-1',
|
||||
calendar_id: 'c1',
|
||||
summary: 'Bestyrelsesmøde',
|
||||
starts_at: `${day}T14:00:00+02:00`,
|
||||
ends_at: `${day}T15:30:00+02:00`,
|
||||
start_day: day,
|
||||
end_day: day,
|
||||
all_day: false,
|
||||
url: 'https://calendar.google.com/event?eid=abc',
|
||||
calendar_name: 'Arbejde',
|
||||
colour: '#0b8043'
|
||||
},
|
||||
{
|
||||
id: 'ev-2',
|
||||
calendar_id: 'c1',
|
||||
summary: 'Feriedag',
|
||||
start_day: day,
|
||||
end_day: day,
|
||||
all_day: true,
|
||||
calendar_name: 'Familien',
|
||||
colour: '#3f51b5'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
);
|
||||
await page.route('**/api/v1/calendar', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
connected: true,
|
||||
account: '[email protected]',
|
||||
read_only: true,
|
||||
has_client: true,
|
||||
redirect_uri: 'http://localhost/oauth/calendar/callback',
|
||||
calendars: [
|
||||
{ id: 'c1', remote_id: 'fam', name: 'Arbejde', colour: '#0b8043', shown: true }
|
||||
]
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// verdande's own half of the grid: a task on that day, made the way anybody
|
||||
// would make it. The date is written out rather than typed as "i morgen", so
|
||||
// the assertion does not depend on the parser as well.
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill(`vande blomster ${day}`);
|
||||
await box.press('Enter');
|
||||
// Not asserted here: this is the Today view and the task is dated a week out,
|
||||
// so it is correctly absent. The grid below is where it has to turn up.
|
||||
await expect(box).toHaveValue('');
|
||||
|
||||
// The menu item, which is the thing the sidebar gained.
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.locator('a[href="/kalender"]').click();
|
||||
await page.waitForURL('**/kalender');
|
||||
await expect(page.getByRole('heading', { name: 'Kalender', level: 1 })).toBeVisible();
|
||||
|
||||
// The week, not the month, and for a reason worth writing down: a month cell
|
||||
// truncates at three chips and this suite has other tests putting tasks on
|
||||
// other days. A week cell holds ten, so what is asserted below is what the view
|
||||
// does with events rather than how full somebody else left the month.
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
|
||||
// Forward a week at a time until the row is the one holding that day. Bounded,
|
||||
// because a loop that pages for ever on a broken button is a timeout with no
|
||||
// explanation in it.
|
||||
const cell = page.locator(`[data-date="${day}"]`);
|
||||
const forward = page.getByRole('button', { name: 'Næste uge' });
|
||||
for (let i = 0; i < 6 && (await cell.count()) === 0; i++) {
|
||||
await forward.click();
|
||||
}
|
||||
await expect(cell, `uge-visningen nåede aldrig frem til ${day}`).toBeVisible();
|
||||
|
||||
// Connected, with a calendar ticked — so neither of the two "there is nothing
|
||||
// here" notices belongs on the screen. They exist so an empty grid is never
|
||||
// unexplained, and an explanation for a state you are not in is noise.
|
||||
await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeHidden();
|
||||
await expect(page.getByText('Ingen af kontoens kalendere er valgt endnu.')).toBeHidden();
|
||||
|
||||
// Both kinds are in the same cell, which is the whole point of the view.
|
||||
const event = cell.locator('.event').filter({ hasText: 'Bestyrelsesmøde' });
|
||||
const allDay = cell.locator('.event').filter({ hasText: 'Feriedag' });
|
||||
const task = cell.locator('.chip').filter({ hasText: 'vande blomster' });
|
||||
await expect(event).toBeVisible();
|
||||
await expect(allDay).toBeVisible();
|
||||
await expect(task).toBeVisible();
|
||||
|
||||
// A timed event carries its clock; an all-day one has none to carry.
|
||||
await expect(event).toContainText('14:00');
|
||||
await expect(allDay).not.toContainText(':');
|
||||
|
||||
// The rule. An event is read-only, so it must not offer to be moved — the drop
|
||||
// targets would refuse it anyway, but a chip that lifts off the page and then
|
||||
// will not land reads as a bug rather than as a rule.
|
||||
await expect(event).toHaveAttribute('draggable', 'false');
|
||||
await expect(allDay).toHaveAttribute('draggable', 'false');
|
||||
await expect(task).toHaveAttribute('draggable', 'true');
|
||||
|
||||
// And it cannot be ticked off either: there is nothing to complete about
|
||||
// somebody else's meeting.
|
||||
expect(await event.evaluate((el) => el.tagName)).not.toBe('BUTTON');
|
||||
|
||||
// It does link back to Google, which is what makes it a pointer to the event
|
||||
// rather than a second copy of it.
|
||||
await expect(event).toHaveAttribute('href', /^https:\/\/calendar\.google\.com\//);
|
||||
|
||||
// The task still reschedules by drag, in a grid that now has events in it.
|
||||
await task.dragTo(page.locator(`[data-date="${next}"]`));
|
||||
await expect(page.locator(`[data-date="${next}"]`).getByText('vande blomster')).toBeVisible();
|
||||
await expect(cell.getByText('vande blomster')).toBeHidden();
|
||||
// The events stayed where they were; a rescheduled task moves nothing else.
|
||||
await expect(cell.locator('.event')).toHaveCount(2);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The view without a calendar connected, which is what every fresh instance is.
|
||||
*
|
||||
* It has to draw anyway. The grid is verdande's own tasks with due dates — the
|
||||
* thing this view was before anything was laid over it — and a Google account that
|
||||
* is not there must not take it down with it.
|
||||
*/
|
||||
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/kalender');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Kalender', level: 1 })).toBeVisible();
|
||||
// Seven weekday columns and a grid under them, whatever day of the week it is.
|
||||
await expect(page.locator('.calendar .grid .day').first()).toBeVisible();
|
||||
|
||||
// It says so rather than looking like a calendar with nothing in it, and it
|
||||
// says where to go about it.
|
||||
await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('a[href="/indstillinger/integrationer"]').first()
|
||||
).toBeVisible();
|
||||
|
||||
// The week is the same grid with a different span, and switching to it must not
|
||||
// lose the events prop on the way.
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
await expect(page.locator('.calendar.week')).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('historik-siden viser hændelser fra et projekt, administratoren ikke er med i', async ({
|
||||
browser,
|
||||
page
|
||||
@@ -2388,3 +2601,296 @@ 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([]);
|
||||
});
|
||||
|
||||
test('en note kan oprettes inde fra et projekt og hører til det med det samme', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByLabel('Nyt projekt').click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Tagprojekt');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await sidebar.getByRole('link', { name: 'Tagprojekt' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Tagprojekt' })).toBeVisible();
|
||||
|
||||
// Ruden findes, også før der er en eneste note — ellers er der ingen knap at
|
||||
// trykke på det sted, hvor man opdager at der skal skrives noget ned.
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(ed).toBeVisible();
|
||||
await ed.click();
|
||||
await page.keyboard.type('Tagsten og lægter');
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// Den hører til projektet med det samme: "Del i" står på projektet, uden at
|
||||
// nogen har valgt det bagefter.
|
||||
await expect(page.getByLabel('Del i')).toHaveValue(/.+/);
|
||||
const shared = await page.getByLabel('Del i').evaluate((el) => el.selectedOptions[0].textContent);
|
||||
expect(shared).toContain('Tagprojekt');
|
||||
|
||||
// Og den står på projektets egen side.
|
||||
await sidebar.getByRole('link', { name: 'Tagprojekt' }).click();
|
||||
// Titlen, ikke uddraget: begge indeholder de samme ord, fordi titlen *er*
|
||||
// notens første linje.
|
||||
await expect(page.locator('.notes strong', { hasText: 'Tagsten og lægter' })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await page.goto('/');
|
||||
await sidebar.getByLabel('Nyt projekt').click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Trækmål');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(sidebar.getByRole('link', { name: 'Trækmål' })).toBeVisible();
|
||||
|
||||
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: '# Løs note\n\nSkal flyttes ved at trække.\n' })
|
||||
});
|
||||
return (await r.json()).id;
|
||||
});
|
||||
|
||||
await page.goto('/noter?note=' + id);
|
||||
await page.evaluate((noteId) => (window.__noteId = noteId), id);
|
||||
const row = page.locator('.notes button.row').filter({ hasText: 'Løs note' }).first();
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// Selve gesten, med de rigtige hændelser og den nyttelast, appen sender.
|
||||
// Rækken tog imod dragover og lod slippet falde ned i grenen, der omarrangerer
|
||||
// projekter — så den lyste op og gjorde ingenting. Prøven skal derfor gå hele
|
||||
// vejen til slippet og se på resultatet, ikke på at rækken blev fremhævet.
|
||||
await page.evaluate(() => {
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('application/x-verdande-note', window.__noteId);
|
||||
const target = [...document.querySelectorAll('nav a')].find((a) =>
|
||||
a.textContent.includes('Trækmål')
|
||||
);
|
||||
for (const type of ['dragover', 'drop']) {
|
||||
target.dispatchEvent(new DragEvent(type, { dataTransfer: dt, bubbles: true, cancelable: true }));
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Det, der tæller: noten ligger i projektet bagefter.
|
||||
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();
|
||||
}, id);
|
||||
expect(body.project_id).toBeTruthy();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
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: '# Kommandoen\n\n```bash\ndocker pull verdande\n```\n' })
|
||||
});
|
||||
return (await r.json()).id;
|
||||
});
|
||||
await page.goto('/noter?note=' + id);
|
||||
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
const copy = ed.locator('pre .copy');
|
||||
await expect(copy).toBeVisible();
|
||||
await copy.click();
|
||||
await expect(copy).toHaveText('Kopieret');
|
||||
|
||||
// Det, der ligger i udklipsholderen, er koden — ikke knappens eget ord.
|
||||
const clip = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clip).toBe('docker pull verdande');
|
||||
|
||||
// Og noten selv må ikke få "Kopiér" skrevet ind i sig, når den gemmes. Knappen
|
||||
// ligger inde i <pre> for at kunne stå i hjørnet af den, så det er præcis den
|
||||
// fælde, den her prøve findes for.
|
||||
await ed.click();
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.type(' ');
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
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).toContain('docker pull verdande');
|
||||
expect(body).not.toContain('Kopiér');
|
||||
expect(body).not.toContain('Kopieret');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
for (const title of ['Alfa', 'Bravo', 'Charlie']) {
|
||||
await page.evaluate(async (t) => {
|
||||
await fetch('/api/v1/notes', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
|
||||
body: JSON.stringify({ body: `# ${t}\n\nEn note.\n` })
|
||||
});
|
||||
}, title);
|
||||
}
|
||||
await page.reload();
|
||||
const rows = page.locator('.notes button.row');
|
||||
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
|
||||
|
||||
// To af tre: et almindeligt klik, og et ⌘-klik oven i. Den, der lige er åbnet,
|
||||
// hører med i markeringen — ellers ville det andet klik markere én og glemme
|
||||
// den, man stod på.
|
||||
await rows.filter({ hasText: 'Alfa' }).click();
|
||||
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
|
||||
await expect(page.getByText('2 markeret')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Arkivér', exact: true }).click();
|
||||
await page.waitForTimeout(900);
|
||||
|
||||
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
|
||||
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
|
||||
await expect(rows.filter({ hasText: 'Charlie' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Vis arkivet').click();
|
||||
await page.waitForTimeout(900);
|
||||
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
|
||||
await expect(rows.filter({ hasText: 'Bravo' })).toBeVisible();
|
||||
await expect(rows.filter({ hasText: 'Charlie' })).toHaveCount(0);
|
||||
|
||||
// Og tilbage igen — med rækkens egen knap, som er vejen for den ene. Et
|
||||
// almindeligt klik rydder markeringen med vilje, så bjælken er til flere.
|
||||
await rows.filter({ hasText: 'Alfa' }).hover();
|
||||
await page.locator('.rowline', { hasText: 'Alfa' }).getByLabel('Tag frem igen').click();
|
||||
await page.waitForTimeout(900);
|
||||
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
|
||||
|
||||
await page.getByLabel('Vis arkivet').click();
|
||||
await page.waitForTimeout(900);
|
||||
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
|
||||
|
||||
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: '[email protected]', 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: '[email protected]', 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: '[email protected]' });
|
||||
|
||||
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
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,6 +61,10 @@ const MESSAGES = {
|
||||
// Same shape for a mailbox read over IMAP: quoted when the host said something,
|
||||
// this when it did not.
|
||||
mailbox_failed: 'error.mailboxRefused',
|
||||
// And for a calendar read from Google. Its own code, because the sentence this
|
||||
// is wrapped in names where it came from — and "Gmail said no" about a calendar
|
||||
// sends somebody to the wrong panel.
|
||||
calendar_failed: 'error.calendarRefused',
|
||||
last_admin: 'error.lastAdmin'
|
||||
};
|
||||
|
||||
@@ -84,6 +88,9 @@ export function humanMessage(err) {
|
||||
if (err.code === 'gmail_failed' && err.message) {
|
||||
return t('error.gmailSaid', { what: err.message });
|
||||
}
|
||||
if (err.code === 'calendar_failed' && err.message) {
|
||||
return t('error.calendarSaid', { what: err.message });
|
||||
}
|
||||
|
||||
const key = MESSAGES[err.code];
|
||||
if (key) return t(key);
|
||||
@@ -196,7 +203,7 @@ export const api = {
|
||||
changePassword: (current_password, new_password) =>
|
||||
post('/auth/password/change', { current_password, new_password }),
|
||||
|
||||
totpSetup: () => post('/auth/totp/setup'),
|
||||
totpSetup: (password) => post('/auth/totp/setup', { password }),
|
||||
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
|
||||
totpDisable: (password) => post('/auth/totp/disable', { password }),
|
||||
listSessions: () => get('/auth/sessions'),
|
||||
@@ -227,6 +234,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 +344,8 @@ export const api = {
|
||||
deleteComment: (id) => del(`/comments/${id}`),
|
||||
|
||||
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
|
||||
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
|
||||
noteBulk: (body) => post('/notes/bulk', body),
|
||||
deleteAttachment: (id) => del(`/attachments/${id}`),
|
||||
attachmentURL: (id) => `/api/v1/attachments/${id}`,
|
||||
|
||||
@@ -361,6 +373,16 @@ export const api = {
|
||||
authorizeGmail: () => post('/gmail/authorize'),
|
||||
syncGmail: () => post('/gmail/sync'),
|
||||
|
||||
// The Google Calendar connection. Read-only: there is nothing here that writes
|
||||
// an event, only which calendars to look at.
|
||||
getCalendar: () => get('/calendar'),
|
||||
setCalendars: (shown) => put('/calendar/calendars', { shown }),
|
||||
disconnectCalendar: () => del('/calendar'),
|
||||
authorizeCalendar: () => post('/calendar/authorize'),
|
||||
syncCalendar: () => post('/calendar/sync'),
|
||||
calendarEvents: (from, to) =>
|
||||
get(`/calendar/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`),
|
||||
|
||||
// Notes. Listing and searching are one endpoint because they answer the same
|
||||
// question with different words.
|
||||
notes: (params = {}) => {
|
||||
|
||||
@@ -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 }))
|
||||
]);
|
||||
|
||||
|
||||
@@ -49,7 +49,19 @@
|
||||
* flight swallows the click that would page. A week that straddles the
|
||||
* 31st and the 1st has both days in the same row.
|
||||
*/
|
||||
span = 'month'
|
||||
span = 'month',
|
||||
/**
|
||||
* Events read from somewhere else, laid over the same grid.
|
||||
*
|
||||
* An event is not a task, and the cell has to say so. It has a span of time
|
||||
* rather than a day, it cannot be ticked off, and — while the connection is
|
||||
* read-only — it cannot be moved: verdande holds a copy of somebody else's
|
||||
* calendar, and a chip that lets itself be dragged is a promise the server
|
||||
* has no way to keep.
|
||||
*
|
||||
* Empty everywhere but Kalender, so no other view pays for it.
|
||||
*/
|
||||
events = []
|
||||
} = $props();
|
||||
|
||||
let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date()));
|
||||
@@ -137,6 +149,66 @@
|
||||
(!projectId || t.project_id === projectId)
|
||||
);
|
||||
|
||||
/**
|
||||
* Which cells an event covers, rather than where it starts. A trip from Friday
|
||||
* to Monday is in all four, and asking only about the start day would draw it
|
||||
* once and leave three days looking clear.
|
||||
*
|
||||
* Ordered here rather than trusted from the caller. The server does sort, but a
|
||||
* cell that shows 14:00 above 09:15 does not look like a list in the wrong
|
||||
* order — it looks like the clock on the chip is wrong, which is a far worse
|
||||
* thing to have to rule out. All-day first, because an all-day event is a band
|
||||
* over the whole day rather than something that happens at a point in it.
|
||||
*/
|
||||
const eventsOn = (date) => {
|
||||
const day = iso(date);
|
||||
return events
|
||||
.filter((e) => e.start_day <= day && e.end_day >= day)
|
||||
.toSorted((a, b) => {
|
||||
if (a.all_day !== b.all_day) return a.all_day ? -1 : 1;
|
||||
return (a.starts_at ?? '').localeCompare(b.starts_at ?? '');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The time on the chip, and only where it means something.
|
||||
*
|
||||
* An all-day event has no time. A timed one shows its start, and only on the
|
||||
* day it starts: a meeting that runs past midnight would otherwise claim to
|
||||
* begin at 20:00 on both days.
|
||||
*
|
||||
* The clock is read out of the string rather than parsed into a Date. Google
|
||||
* writes the event's own offset into it, and handing that to the browser asks
|
||||
* *this device* what time it is — so a phone in another time zone would show a
|
||||
* Copenhagen meeting an hour out.
|
||||
*/
|
||||
function timeOn(event, date) {
|
||||
if (event.all_day || !event.starts_at) return '';
|
||||
if (event.start_day !== iso(date)) return '';
|
||||
return event.starts_at.slice(11, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar's own colour, or nothing.
|
||||
*
|
||||
* Checked rather than trusted, even though it comes from Google: this goes into
|
||||
* an inline `style`, and a value that is not a colour is a value that can close
|
||||
* the declaration and open another. A hex triple is the whole of what Google
|
||||
* sends, so anything else is not a colour that failed to render — it is a
|
||||
* string that has no business being here.
|
||||
*/
|
||||
const HEX = /^#[0-9a-f]{3,8}$/i;
|
||||
const swatch = (colour) => (HEX.test(colour ?? '') ? colour : '');
|
||||
|
||||
/**
|
||||
* Whether the event links back to Google.
|
||||
*
|
||||
* `https:` and nothing else. An href is where a `javascript:` URL becomes a
|
||||
* click that runs, and the address in this field was written by a service, not
|
||||
* by the person looking at it.
|
||||
*/
|
||||
const linkOf = (event) => (String(event.url ?? '').startsWith('https://') ? event.url : '');
|
||||
|
||||
const monthName = $derived(
|
||||
cursor.toLocaleDateString(tag(), { month: 'long', year: 'numeric' })
|
||||
);
|
||||
@@ -232,7 +304,14 @@
|
||||
|
||||
<div class="grid">
|
||||
{#each grid as date (date.toISOString())}
|
||||
{@const tasks = tasksOn(date)}
|
||||
{@const dayEvents = eventsOn(date)}
|
||||
<!-- Events first, then whatever room is left goes to tasks. An event is
|
||||
fixed to a time somebody else chose; a task is only dated, and if
|
||||
something in a full cell has to fall behind "+3 mere", it should be
|
||||
the one that can be moved. -->
|
||||
{@const tasks = tasksOn(date).slice(0, Math.max(0, chipLimit - dayEvents.length))}
|
||||
{@const hidden =
|
||||
tasksOn(date).length - tasks.length + Math.max(0, dayEvents.length - chipLimit)}
|
||||
<!-- A week has no outside: every day in it is the week you asked for. -->
|
||||
{@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -254,7 +333,36 @@
|
||||
{/if}
|
||||
<span class="number">{date.getDate()}</span>
|
||||
|
||||
{#each tasks.slice(0, chipLimit) as task (task.id)}
|
||||
{#each dayEvents.slice(0, chipLimit) as event (event.id)}
|
||||
{@const link = linkOf(event)}
|
||||
{@const label = t('cal.eventCannotMove', {
|
||||
name: event.summary,
|
||||
calendar: event.calendar_name || t('cal.title')
|
||||
})}
|
||||
<!-- Not draggable, and it says so twice: the attribute stops the
|
||||
browser dragging an anchor of its own accord, and the title says
|
||||
why to anybody who tries. The drop targets would refuse it anyway
|
||||
— they only accept the task MIME type — but a chip that lifts off
|
||||
the page and then will not land reads as a bug rather than as a
|
||||
rule. It is also not a button: there is nothing here to complete,
|
||||
and a checkbox next to a meeting is an offer verdande cannot keep. -->
|
||||
<svelte:element
|
||||
this={link ? 'a' : 'span'}
|
||||
class="event"
|
||||
class:allday={event.all_day}
|
||||
draggable="false"
|
||||
href={link || undefined}
|
||||
target={link ? '_blank' : undefined}
|
||||
rel={link ? 'noreferrer noopener' : undefined}
|
||||
title={link ? `${label} — ${t('cal.openInGoogle')}` : label}
|
||||
style={swatch(event.colour) ? `--event-colour: ${swatch(event.colour)}` : undefined}
|
||||
>
|
||||
{#if timeOn(event, date)}<span class="at">{timeOn(event, date)}</span>{/if}
|
||||
{event.summary}
|
||||
</svelte:element>
|
||||
{/each}
|
||||
|
||||
{#each tasks as task (task.id)}
|
||||
<!-- Draggable as well as clickable: within a month, moving something
|
||||
to another day is the whole reason to be looking at a month. The
|
||||
browser needs a few pixels of movement before it calls a press a
|
||||
@@ -271,8 +379,8 @@
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if tasks.length > chipLimit}
|
||||
<span class="more">{t('view.more', { n: tasks.length - chipLimit })}</span>
|
||||
{#if hidden > 0}
|
||||
<span class="more">{t('view.more', { n: hidden })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -451,6 +559,58 @@
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* An event reads as a different kind of thing at a glance, before any of the
|
||||
words are read: a solid bar of colour where a task is a card with a coloured
|
||||
edge. That is the whole distinction the eye needs — a task is something you
|
||||
can move and finish, an event is something that is simply happening.
|
||||
|
||||
The colour is Google's own for that calendar, so two calendars in one grid
|
||||
are told apart by the colour the person already knows them by. `--line-strong`
|
||||
is the fallback for a calendar Google gave no colour, which is rare and not
|
||||
worth a second decision. */
|
||||
.event {
|
||||
--event-colour: var(--line-strong);
|
||||
font-size: var(--text-xs);
|
||||
text-align: left;
|
||||
display: block;
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
/* A drag that cannot land is worse than one that cannot start. The grab
|
||||
cursor is the promise; this withholds it. */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.event[href] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event:hover {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* An all-day event has no clock to show, so the colour carries it instead:
|
||||
filled rather than merely edged, which is what an all-day band looks like in
|
||||
every calendar anybody has used. */
|
||||
.event.allday {
|
||||
border-left: none;
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--event-colour) 45%, transparent);
|
||||
}
|
||||
|
||||
.at {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink-faint);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.more {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
@@ -482,6 +642,29 @@
|
||||
background: var(--p3);
|
||||
}
|
||||
|
||||
/* Counted rather than read, like the task chips beside it — a 45px column
|
||||
fits no title. A bar rather than a dot, so an event is still tellable from
|
||||
a task at a glance: what the month grid says on a phone is "how much is
|
||||
on this day, and how much of it is somebody else's". */
|
||||
.event {
|
||||
font-size: 0;
|
||||
padding: 0;
|
||||
height: 4px;
|
||||
border-radius: 1px;
|
||||
border-left: none;
|
||||
box-shadow: none;
|
||||
background: var(--event-colour);
|
||||
}
|
||||
|
||||
.event.allday {
|
||||
background: var(--event-colour);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.at {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.more {
|
||||
font-size: 10px;
|
||||
}
|
||||
@@ -536,6 +719,27 @@
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
/* Readable again in the week, for the reason the task chips are: a
|
||||
full-width row has room for a title, and a week somebody opened on
|
||||
purpose is one they want to read rather than count. */
|
||||
.calendar.week .event {
|
||||
font-size: var(--text-xs);
|
||||
height: auto;
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.calendar.week .event.allday {
|
||||
border-left: none;
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
|
||||
}
|
||||
|
||||
.calendar.week .at {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.calendar.week .more {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -120,12 +121,71 @@
|
||||
* monospace hele tiden; farverne kommer, når man forlader den, hvilket er når
|
||||
* man læser den.
|
||||
*/
|
||||
/**
|
||||
* En kopiér-knap på hver kodeblok.
|
||||
*
|
||||
* Lagt oven på blokken frem for skrevet ind i den: alt inde i `.page` er tekst,
|
||||
* der bliver til Markdown, når noten gemmes, så en knap derinde ville ende i
|
||||
* filen. Den her er `contenteditable="false"` og hænger uden for tekstflowet —
|
||||
* den findes for øjet og musen, ikke for dokumentet.
|
||||
*
|
||||
* `navigator.clipboard` fejler, når siden ikke er sikker eller ikke har fokus,
|
||||
* og en knap, der siger "Kopieret" uden at have gjort det, er værre end ingen
|
||||
* knap. Derfor siges det kun, når skrivningen faktisk lykkedes.
|
||||
*/
|
||||
function addCopyButtons() {
|
||||
if (!editor) return;
|
||||
for (const pre of editor.querySelectorAll('pre')) {
|
||||
if (pre.querySelector('.copy')) continue;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'copy';
|
||||
btn.type = 'button';
|
||||
btn.contentEditable = 'false';
|
||||
btn.textContent = t('notes.copyCode');
|
||||
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const text = codeTextOf(pre);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text.replace(/\n$/, ''));
|
||||
btn.textContent = t('notes.copied');
|
||||
setTimeout(() => (btn.textContent = t('notes.copyCode')), 1200);
|
||||
} catch {
|
||||
app.toast(t('notes.copyFailed'));
|
||||
}
|
||||
});
|
||||
pre.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teksten i en kodeblok, uden knapper og andet, editoren har lagt oven på den.
|
||||
*
|
||||
* Ét sted, fordi to steder skal bruge det samme svar: farvelægningen, som
|
||||
* skriver resultatet tilbage i blokken, og oversættelsen til Markdown, som
|
||||
* skriver det i filen. Var de to uenige, ville forskellen ende i noten.
|
||||
*/
|
||||
function codeTextOf(pre) {
|
||||
const from = pre.querySelector('code') ?? pre;
|
||||
return [...from.childNodes]
|
||||
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
|
||||
.map((n) => n.textContent ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
function colourCode() {
|
||||
if (!editor) return;
|
||||
const inside = currentBlock();
|
||||
for (const pre of editor.querySelectorAll('pre')) {
|
||||
if (pre === inside || pre.contains(inside)) continue;
|
||||
const code = pre.textContent ?? '';
|
||||
// Blokkens tekst uden det, vi selv har lagt oven på den.
|
||||
//
|
||||
// `pre.textContent` tog kopiér-knappens eget ord med, og linjen under
|
||||
// maler resultatet tilbage ind i blokken — så et klik på "Kopiér" skrev
|
||||
// "Kopieret" ind i koden, og den næste gemning lagde det i filen. Knappen
|
||||
// spiste den kode, den var sat der for at kopiere.
|
||||
const code = codeTextOf(pre);
|
||||
const lang = pre.getAttribute('data-lang') || guessLanguage(code);
|
||||
if (lang) pre.setAttribute('data-lang', lang);
|
||||
const painted = highlight(code, lang);
|
||||
@@ -134,6 +194,7 @@
|
||||
const target = pre.querySelector('code') ?? pre;
|
||||
if (target.innerHTML !== painted) target.innerHTML = painted;
|
||||
}
|
||||
addCopyButtons();
|
||||
}
|
||||
|
||||
function changed() {
|
||||
@@ -191,6 +252,9 @@
|
||||
|
||||
let suggestions = $state([]);
|
||||
let chosen = $state(0);
|
||||
// Hvor forslagslisten skal stå, og wrappen den måles imod.
|
||||
let menuAt = $state(null);
|
||||
let wrap;
|
||||
|
||||
/**
|
||||
* The word being typed after a #, if that is what is happening.
|
||||
@@ -210,6 +274,33 @@
|
||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hvor `#` står, målt i wrappens koordinater.
|
||||
*
|
||||
* Målt på selve tegnet og ikke på markøren: et sammenfaldet område har ingen
|
||||
* kasse på en tom linje, mens `#term` altid har en, fordi der står noget i den.
|
||||
*
|
||||
* Klemt inden for ruden, så en note skrevet helt ude i højre side ikke sender
|
||||
* menuen ud over kanten, og vendt op over linjen, når der ikke er plads under.
|
||||
*/
|
||||
function tagPosition(partial) {
|
||||
if (!wrap) return null;
|
||||
const range = document.createRange();
|
||||
range.setStart(partial.node, Math.max(0, partial.start - 1));
|
||||
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||
const at = range.getBoundingClientRect();
|
||||
const box = wrap.getBoundingClientRect();
|
||||
if (!at.width && !at.height) return null;
|
||||
|
||||
const MENU = { w: 210, h: 240 };
|
||||
const below = at.bottom - box.top + 6;
|
||||
const flip = at.bottom + MENU.h > box.bottom && at.top - box.top > MENU.h;
|
||||
return {
|
||||
top: flip ? at.top - box.top - MENU.h - 6 : below,
|
||||
left: Math.max(0, Math.min(at.left - box.left, box.width - MENU.w))
|
||||
};
|
||||
}
|
||||
|
||||
function readSuggestions() {
|
||||
const partial = partialTag();
|
||||
if (!partial) {
|
||||
@@ -218,6 +309,12 @@
|
||||
}
|
||||
const term = partial.term.toLowerCase();
|
||||
|
||||
// Målt når menuen åbner, ikke ved hvert tastetryk. Den skal stå ved det `#`,
|
||||
// man skriver — den stod før øverst i ruden, hvilket i en lang note er et
|
||||
// helt andet sted end det, øjet er — men den må heller ikke rykke sig et par
|
||||
// pixels for hvert bogstav, mens listen snævres ind.
|
||||
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||
|
||||
// Alle der passer, ikke de seks første.
|
||||
//
|
||||
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
|
||||
@@ -363,15 +460,95 @@
|
||||
|
||||
// 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">
|
||||
<div class="wrap" bind:this={wrap}>
|
||||
<!-- Nothing in here may take focus. A click that moves focus out of the editor
|
||||
collapses the selection first, so the format lands on nothing — the button
|
||||
appears to do nothing at all, which is how it read the first time. -->
|
||||
@@ -472,13 +649,22 @@
|
||||
onmouseup={readState}
|
||||
{onkeydown}
|
||||
{onpaste}
|
||||
{ondragover}
|
||||
{ondrop}
|
||||
ondragleave={() => (dropping = false)}
|
||||
class:dropping
|
||||
></div>
|
||||
|
||||
{#if suggestions.length}
|
||||
<!-- Anchored to the editor rather than to the caret. Following the caret means
|
||||
measuring it, and a list that jumps a few pixels as you type is worse than
|
||||
one that sits still. -->
|
||||
<ul class="suggestions" role="listbox" bind:this={list}>
|
||||
<!-- Ved det `#`, der bliver skrevet. Målt én gang, da menuen åbnede, så den
|
||||
står stille, mens listen snævres ind. -->
|
||||
<ul
|
||||
class="suggestions"
|
||||
role="listbox"
|
||||
bind:this={list}
|
||||
style:top={menuAt ? `${menuAt.top}px` : null}
|
||||
style:left={menuAt ? `${menuAt.left}px` : null}
|
||||
>
|
||||
{#each suggestions as project, i (project.id)}
|
||||
<li>
|
||||
<button
|
||||
@@ -688,7 +874,17 @@
|
||||
*
|
||||
* Kun teksten, ikke arket: værktøjslinjen og foden hører til ruden og skal
|
||||
* blive i den. */
|
||||
max-width: 46rem;
|
||||
/* Bredden vokser med ruden, men ikke i det uendelige.
|
||||
*
|
||||
* 46rem var for stramt: på en skærm, der er halvanden gang så bred, stod
|
||||
* teksten i en smal søjle med en håndflade tom plads ved siden af — og
|
||||
* kodeblokke og billeder, som er de to ting i en note, der *vil* have
|
||||
* bredde, blev klemt sammen uden grund.
|
||||
*
|
||||
* clamp frem for et tal: en smal rude får det hele, en bred får en
|
||||
* linjelængde, øjet kan finde tilbage i. Øvre grænse omkring 68rem, hvor en
|
||||
* linje er lang nok til at rumme en kommando og kort nok til at læse. */
|
||||
max-width: clamp(38rem, 92%, 68rem);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -696,6 +892,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 {
|
||||
@@ -764,6 +968,16 @@
|
||||
margin-bottom: var(--s1);
|
||||
}
|
||||
|
||||
/* Et billede fylder sin egen bredde og ikke mere. Et foto fra en telefon er
|
||||
fire tusind pixels bredt, og uden det her stikker det ud af arket. */
|
||||
.page :global(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
display: block;
|
||||
margin: var(--s2) 0;
|
||||
}
|
||||
|
||||
.page :global(blockquote) {
|
||||
margin: 0 0 var(--s2);
|
||||
padding-left: var(--s3);
|
||||
@@ -771,6 +985,40 @@
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* Kopiér-knappen, øverst til højre i blokken.
|
||||
*
|
||||
* Synlig hele tiden, ikke kun ved hover. En knap, der først findes, når musen
|
||||
* er det rigtige sted, er en knap, folk ikke ved er der — og på en berøringsskærm
|
||||
* findes den slet ikke. Den er i stedet dæmpet nok til ikke at tage
|
||||
* opmærksomhed fra koden.
|
||||
*
|
||||
* `z-index` sagt højt: uden det lå den bag blokkens eget indhold i
|
||||
* stakrækkefølgen, og et klik ramte <pre> i stedet for knappen. */
|
||||
.page :global(pre) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page :global(pre .copy) {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
font-family: var(--font);
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px var(--s2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
color: rgb(255 255 255 / 0.55);
|
||||
transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.page :global(pre:hover .copy),
|
||||
.page :global(pre .copy:hover),
|
||||
.page :global(pre .copy:focus-visible) {
|
||||
background: rgb(255 255 255 / 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* En kodeblok ser ud som en terminal, fordi det er hvad den er.
|
||||
Monospace er altid kode i praksis — Apple Noters "Monotype" er stort set kun
|
||||
brugt til kommandoer og udskrifter — og en mørk flade siger det på afstand,
|
||||
|
||||
@@ -221,6 +221,13 @@
|
||||
gap: var(--s3);
|
||||
padding: var(--s3) var(--s2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
/* Luft under stregen.
|
||||
*
|
||||
* Stregen skiller "skriv en ny" fra "det, der allerede er", og uden afstand
|
||||
* sad den første sektion klods op ad den — så den læste som en kant om
|
||||
* sektionen frem for som en afslutning på feltet over. En streg skal have
|
||||
* plads på begge sider for at kunne ses som en streg. */
|
||||
margin-bottom: var(--s4);
|
||||
}
|
||||
|
||||
.plus {
|
||||
|
||||
@@ -145,7 +145,16 @@
|
||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||
// eksisterende en anden.
|
||||
{ key: 'notes', href: '/noter', label: 'notes.title' }
|
||||
{ key: 'notes', href: '/noter', label: 'notes.title' },
|
||||
// Kalenderen bærer et gitter, af samme grund som Noter bærer et ark: den
|
||||
// er ikke endnu et filter over de samme opgaver. Den er de samme opgaver
|
||||
// med noget andet lagt over — Googles begivenheder — og en prik ville
|
||||
// sige, at den hørte til blandt I dag og Kommende.
|
||||
//
|
||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||
// eksisterende en anden.
|
||||
{ key: 'calendar', href: '/kalender', label: 'nav.calendar' }
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
@@ -354,6 +363,15 @@
|
||||
overProject = project.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Et slip på et projekt, uanset hvad der bliver sluppet.
|
||||
*
|
||||
* Rækken kaldte den her for opgaver og lod noter falde ned i grenen, der
|
||||
* omarrangerer projekter — hvor en note ikke betyder noget. Samtidig tog
|
||||
* `onTaskDragOver` imod begge, så rækken lyste op, når en note blev trukket hen
|
||||
* over den. Den værst mulige form for i stykker: den så ud, som om den ville
|
||||
* virke, helt indtil man slap.
|
||||
*/
|
||||
async function onTaskDrop(event, project) {
|
||||
event.preventDefault();
|
||||
overProject = null;
|
||||
@@ -362,9 +380,12 @@
|
||||
// The same target takes both. A note dropped on a project is filed there,
|
||||
// which is also how it gets shared — one gesture, one meaning, and the
|
||||
// sidebar already reads as "put this here".
|
||||
const noteID = dragged(event, NOTE);
|
||||
if (noteID) {
|
||||
await app.moveNoteToProject(noteID, project.id);
|
||||
// Nyttelasten er ét eller flere id'er adskilt af mellemrum. Markerer man
|
||||
// halvtreds noter og trækker dem herhen, hører de alle sammen til det ene
|
||||
// sted, man slap dem — det er én handling, uanset hvor mange den flytter.
|
||||
const noteIDs = (dragged(event, NOTE) ?? '').split(' ').filter(Boolean);
|
||||
if (noteIDs.length) {
|
||||
for (const id of noteIDs) await app.moveNoteToProject(id, project.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -488,6 +509,26 @@
|
||||
/>
|
||||
<path d="M9 1.7V5h3.3" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||
</svg>
|
||||
{:else if item.key === 'calendar'}
|
||||
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect
|
||||
x="2.2"
|
||||
y="3.2"
|
||||
width="11.6"
|
||||
height="10.6"
|
||||
rx="1.4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
/>
|
||||
<path
|
||||
d="M2.2 6.4h11.6M5.6 2.2v2M10.4 2.2v2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||
{/if}
|
||||
@@ -508,14 +549,22 @@
|
||||
sidebar is already a drop target for tasks — a task dragged onto I dag
|
||||
stopped landing, because the row had claimed the gesture. A grip claims
|
||||
nothing until it is grabbed. -->
|
||||
<!-- Håndtaget har ingen prik i sig længere.
|
||||
⠿ sad i den to pixels brede rende til venstre for teksten og lå oven i
|
||||
rækkens egen kant — idéen var rigtig, pladsen var det ikke. Men elementet
|
||||
bliver: det er dét, der ejer trækket, og gjorde man i stedet hele rækken
|
||||
draggable, ville alt indeni også blive det — og en opgave trukket hen på
|
||||
I dag holdt op med at lande, fordi rækken havde taget gesten. Nu er
|
||||
håndtaget usynligt og lidt bredere, og hånden, der kommer ved hover,
|
||||
er hele forklaringen. -->
|
||||
{#snippet grip(key)}
|
||||
<span
|
||||
class="grip"
|
||||
draggable="true"
|
||||
ondragstart={(e) => onNavDragStart(e, key)}
|
||||
ondragend={clearNavDrag}
|
||||
aria-hidden="true">⠿</span
|
||||
>
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet projectRow(project, sortable)}
|
||||
@@ -532,14 +581,17 @@
|
||||
ondragstart={(e) => sortable && onDragStart(e, project)}
|
||||
ondragend={clearDrag}
|
||||
ondragover={(e) => {
|
||||
if (carries(e, TASK)) onTaskDragOver(e, project);
|
||||
if (carries(e, TASK) || carries(e, NOTE)) onTaskDragOver(e, project);
|
||||
else if (sortable) onDragOver(e, project);
|
||||
}}
|
||||
ondragleave={() => {
|
||||
overId = null;
|
||||
overProject = null;
|
||||
}}
|
||||
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
|
||||
ondrop={(e) =>
|
||||
carries(e, TASK) || carries(e, NOTE)
|
||||
? onTaskDrop(e, project)
|
||||
: sortable && onDrop(e, project)}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
||||
{projectName(project)}
|
||||
@@ -1004,19 +1056,15 @@
|
||||
so nothing moves when it appears. */
|
||||
.grip {
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--ink-faint);
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 14px;
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.navrow:hover .grip {
|
||||
opacity: 1;
|
||||
.grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.navrow > :global(a) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -25,6 +25,40 @@ export const da = {
|
||||
'notes.bold': 'Fed',
|
||||
'notes.italic': 'Kursiv',
|
||||
'notes.underline': 'Understreget',
|
||||
'notes.sortBy': 'Rækkefølge',
|
||||
'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.newHere': 'Ny note',
|
||||
'notes.noneInProject': 'Ingen noter endnu. En note, der nævner #projektet, dukker også op her.',
|
||||
'notes.clearSearch': 'Ryd søgningen',
|
||||
'notes.found': '{count} noter',
|
||||
'notes.picked': '{count} markeret',
|
||||
'notes.archive': 'Arkivér',
|
||||
'notes.unarchive': 'Tag frem igen',
|
||||
'notes.clearPick': 'Ryd markering',
|
||||
'notes.showArchive': 'Vis arkivet',
|
||||
'notes.deleteMany': 'Slet {count} noter?',
|
||||
'notes.archiveOne': 'Arkivér noten',
|
||||
'notes.emptyArchive': 'Arkivet er tomt.',
|
||||
'notes.copyCode': 'Kopiér',
|
||||
'notes.copied': 'Kopieret',
|
||||
'notes.copyFailed': 'Kunne ikke kopiere',
|
||||
'notes.strike': 'Gennemstreget',
|
||||
'notes.showSource': 'Vis som kode',
|
||||
'notes.source': 'Notens kildetekst',
|
||||
@@ -61,6 +95,7 @@ export const da = {
|
||||
'nav.upcoming': 'Kommende',
|
||||
'nav.delegated': 'Venter på andre',
|
||||
'nav.inbox': 'Indbakke',
|
||||
'nav.calendar': 'Kalender',
|
||||
'nav.settings': 'Indstillinger',
|
||||
'nav.signOut': 'Log ud',
|
||||
'nav.main': 'Hovedmenu',
|
||||
@@ -299,6 +334,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',
|
||||
@@ -314,6 +352,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.',
|
||||
@@ -493,8 +555,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',
|
||||
@@ -542,7 +609,7 @@ export const da = {
|
||||
'Klienten er sat i miljøet (VERDANDE_GMAIL_CLIENT_ID), så den kan ikke ændres her. Det er med vilje: en værdi i runens manifest er en beslutning, der bliver anvendt igen ved hver genstart, og en formular, der kunne overskrive den, ville gøre manifestet til en løgn.',
|
||||
'int.oauthClient': 'OAuth-klient hos Google',
|
||||
'int.registerHint':
|
||||
'Registrér en klient i Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, slå Gmail API til, og indsæt de to værdier her. Din egen konto skal stå som testbruger, så længe appen ikke er verificeret hos Google.',
|
||||
'Registrér en klient i Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, og indsæt de to værdier her. Den samme klient bruges til både Gmail og Google Kalender: slå det API til, du skal bruge, og tilføj begge redirect-URI\'er — den nedenfor og kalenderens, som står i panelet Google Kalender. Din egen konto skal stå som testbruger, så længe appen ikke er verificeret hos Google.',
|
||||
'int.redirectURI': 'Godkendt redirect-URI',
|
||||
'int.clientID': 'Klient-id',
|
||||
'int.clientSecret': 'Klienthemmelighed',
|
||||
@@ -582,6 +649,32 @@ export const da = {
|
||||
'int.server': 'Server',
|
||||
'int.caldavAuth':
|
||||
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
|
||||
'int.calendar': 'Google Kalender',
|
||||
'int.calendarHint':
|
||||
'Læses, aldrig skrives. Begivenhederne vises i Kalender ved siden af dine egne opgaver med forfaldsdato — de kan ikke flyttes eller lukkes herfra, for de hører til i Google.',
|
||||
'int.connectCalendar': 'Forbind Google Kalender',
|
||||
'int.calendarConnectedAs': 'Forbundet som',
|
||||
'int.calendarSameClient':
|
||||
'Bruger den samme OAuth-klient som Gmail ovenfor. Google Calendar API skal være slået til, og denne adresse skal stå som godkendt redirect-URI.',
|
||||
'int.whichCalendars': 'Hvilke kalendere vises',
|
||||
'int.noCalendars': 'Kontoen har ingen kalendere at vise.',
|
||||
'int.calendarLastSync': 'Sidst hentet {when}.',
|
||||
'int.calendarNeverSynced': 'Ikke hentet endnu.',
|
||||
'int.calendarFetchedOne': '{n} begivenhed hentet.',
|
||||
'int.calendarFetchedMany': '{n} begivenheder hentet.',
|
||||
'int.calendarNothingNew': 'Ingen begivenheder i vinduet.',
|
||||
'int.calendarConnected': 'Google Kalender er forbundet.',
|
||||
'int.calendarState':
|
||||
'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.',
|
||||
'int.calendarExpired': 'Forsøget udløb undervejs. Prøv igen.',
|
||||
'int.calendarInvalid': 'Svaret fra Google kunne ikke læses. Prøv igen.',
|
||||
'int.calendarFailed': 'Google afviste ombytningen af koden.',
|
||||
'int.calendarNoRefresh':
|
||||
'Google sendte ingen refresh-token. Fjern verdande under din Google-konto og forbind igen.',
|
||||
'int.calendarDenied': 'Adgangen blev afvist hos Google.',
|
||||
'int.calendarOrgInternal':
|
||||
'Google-appen er registreret som Internal og tager kun imod konti i organisationen. En privat konto kan ikke godkende den, uanset hvad der står på skærmen.',
|
||||
'int.calendarOther': 'Google svarede: {what}',
|
||||
'int.gmailFetchedOne': '{n} opgave hentet.',
|
||||
'int.gmailFetchedMany': '{n} opgaver hentet.',
|
||||
'int.gmailNothingNew': 'Ingen nye beskeder.',
|
||||
@@ -596,6 +689,18 @@ export const da = {
|
||||
'int.gmailDenied': 'Adgangen blev afvist hos Google.',
|
||||
'int.gmailOther': 'Gmail svarede: {what}',
|
||||
|
||||
// --- kalenderen, med Googles begivenheder lagt over ---------------------------------
|
||||
'cal.title': 'Kalender',
|
||||
'cal.notConnected':
|
||||
'Ingen kalender er forbundet endnu. Det gøres under Indstillinger → Integrationer.',
|
||||
'cal.settings': 'Indstillinger',
|
||||
'cal.noneChosen': 'Ingen af kontoens kalendere er valgt endnu.',
|
||||
'cal.allDay': 'Hele dagen',
|
||||
'cal.openInGoogle': 'Åbn i Google Kalender',
|
||||
'cal.beyondWindow':
|
||||
'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.',
|
||||
'cal.eventCannotMove': '{name} — en begivenhed i {calendar}. Den kan ikke flyttes herfra.',
|
||||
|
||||
// --- a project ----------------------------------------------------------------------
|
||||
'project.name': 'Projektets navn',
|
||||
'project.clickToRename': 'Klik for at omdøbe',
|
||||
@@ -684,6 +789,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',
|
||||
@@ -737,6 +843,10 @@ export const da = {
|
||||
'Denne instans kan ikke genstarte sig selv: {missing} er ikke sat. En container kan ikke udskifte sit eget image — panelet gør arbejdet, og det her er kun måden at bede om det.',
|
||||
|
||||
'error.gmailSaid': 'Gmail svarede: {what}',
|
||||
'error.calendarRefused': 'Google afviste kaldet. Prøv at forbinde kalenderen igen.',
|
||||
'error.calendarSaid': 'Google svarede: {what}',
|
||||
'int.disconnectCalendarQuestion':
|
||||
'Afbryd forbindelsen til Google Kalender? Tokens bliver glemt, og den hentede kopi af begivenhederne bliver slettet. Dine egne opgaver rører den ikke.',
|
||||
|
||||
'error.mailboxRefused': 'Postkassen kunne ikke læses.',
|
||||
'error.mailboxSaid': 'Postkassen svarede: {what}',
|
||||
|
||||