Dynawidgets repo •
@@ -55,8 +55,49 @@ Easily create your own theme by tweaking a few numbers or choose from one of the
+## Installation
+There are currently two ways in which you can use Dynacat, the UI editorand editing yaml files manually.
+If you'd like to use the UI here's the recommended configuration.
+
+The quickest start is to grab the ready-made directory structure:
+
+```bash
+mkdir dynacat && cd dynacat && \
+curl -sL https://github.com/Panonim/dynacat-compose-template/releases/latest/download/dynacat.tar.gz | tar -xzf - && \
+docker compose up -d
+```
+
+
+Docker compose file
+
+
+```yaml
+services:
+ dynacat:
+ container_name: dynacat
+ image: panonim/dynacat
+ restart: unless-stopped
+ volumes:
+ - ./config:/app/config
+ - ./assets:/app/assets
+ - /etc/localtime:/etc/localtime:ro
+ # Optionally, also mount docker socket if you want to use the docker containers widget
+ # - /var/run/docker.sock:/var/run/docker.sock:ro
+ ports:
+ - 8080:8080
+ env_file: .env
+```
+
+
+If you'd like a more detailed instructions they can be found in [the docs](https://dynacat.artur.zone/#installation).
+
## Configuration
-Configuration is done through YAML files, to learn more about how the layout works, how to add more pages and how to configure widgets, visit the [configuration documentation](https://dynacat.artur.zone/configuration#configuring-dynacat).
+There are two ways to configure Dynacat, and they work on the same files:
+
+* **UI editor** - drag widgets into columns, change their options and pick your colors from the dashboard itself. Dynacat writes the result back into your YAML. See the [UI editor documentation](https://dynacat.artur.zone/ui-editor).
+* **YAML by hand** - edit the config files directly. To learn more about how the layout works, how to add more pages and how to configure widgets, visit the [configuration documentation](https://dynacat.artur.zone/configuration#configuring-dynacat).
+
+The editor is an addition to the config file, not a replacement, so hand written config keeps working exactly as before.
Preview example configuration file
@@ -194,13 +235,14 @@ You can list multiple origins. Each entry must be a full origin including the sc
-**If you like this project, please consider [sponsoring](https://www.paypal.com/paypalme/imartur).**
+**If you like this project, please consider [sponsoring](https://ko-fi.com/panonim).**
-
-
-
-
-
-
-
+
diff --git a/docs/assets/style.css b/docs/assets/style.css
index 8a68951a..bfee6057 100644
--- a/docs/assets/style.css
+++ b/docs/assets/style.css
@@ -1247,10 +1247,12 @@ body {
.home-community-icon--discord { color: #7289DA; }
.home-community-icon--issues { color: #FBBF24; }
.home-community-icon--paypal { color: #0070BA; }
+.home-community-icon--kofi { color: #D9605C; }
.home-community-card--discord { background: rgba(88, 101, 242, 0.12); }
.home-community-card--issues { background: rgba(245, 158, 11, 0.1); }
.home-community-card--paypal { background: rgba(0, 112, 186, 0.12); }
+.home-community-card--kofi { background: rgba(217, 96, 92, 0.1); }
.home-community-label {
display: inline-block;
diff --git a/docs/docs/api.md b/docs/docs/api.md
new file mode 100644
index 00000000..9f32b98e
--- /dev/null
+++ b/docs/docs/api.md
@@ -0,0 +1,281 @@
+# API
+
+Dynacat can expose the data its widgets have already fetched as read-only JSON, so you can reuse it in scripts, home automation or another dashboard. The API is disabled by default and never modifies anything.
+
+## Enabling
+
+Configure via the top-level `api` property:
+
+```yaml
+api:
+ enabled: true
+ token: ${env:DYNACAT_API_TOKEN}
+ allowed-origins:
+ - https://example.com
+```
+
+| Name | Type | Required | Default |
+| ---- | ---- | -------- | ------- |
+| enabled | boolean | no | false |
+| token | string | no | |
+| enforce-page-permissions | boolean | no | true |
+| rate-limit | number | no | 5 |
+| allowed-pages | array of strings | no | |
+| allowed-origins | array of strings | no | |
+
+#### `enabled`
+
+Turns the API on. While this is `false` the endpoints are not registered at all and requests to them return `404`.
+
+#### `token`
+
+A shared secret that callers send in the `X-API-Token` header. When it is empty no token is required and anyone who can reach the server can read your widget data, so Dynacat prints a warning on startup that cannot be silenced with `LOG_LEVEL`.
+
+#### `enforce-page-permissions`
+
+Keeps pages that use `allowed-users` or `allowed-groups` behind a real user login. See [Page permissions](#page-permissions).
+
+#### `rate-limit`
+
+How many API requests a single IP may make per minute. Requests beyond it get `429` with a `Retry-After` header. Set it to `0` to remove the limit.
+
+Example:
+
+```yaml
+api:
+ enabled: true
+ rate-limit: 60
+```
+
+The limit is applied before credentials are checked, so it also caps how fast someone can guess a token or password.
+
+> [!NOTE]
+>
+> Behind a reverse proxy, set `server.proxied: true` so the limit counts real client IPs instead of the proxy.
+
+#### `allowed-pages`
+
+A list of page slugs the API is allowed to serve. When empty every page is available. Pages outside the list behave as if they do not exist, including their widgets.
+
+#### `allowed-origins`
+
+Origins allowed to call the API from a browser. See [CORS](#cors).
+
+> [!NOTE]
+>
+> The API is read-only. There is no endpoint that changes configuration, triggers actions or writes anything.
+
+## Authentication
+
+Dynacat resolves the caller in this order:
+
+| Method | How | Identity |
+| ------ | --- | -------- |
+| Session cookie | an existing dashboard login | the logged-in user |
+| Basic auth | `Authorization: Basic ` against your configured `auth.users` | that user |
+| Token | `X-API-Token: ` matching `api.token` | none |
+
+With a token:
+
+```sh
+curl -H "X-API-Token: mytoken" http://localhost:8080/api/v1/pages
+```
+
+With a username and password:
+
+```sh
+curl -u admin:mysecretpassword http://localhost:8080/api/v1/pages
+```
+
+Failed username and password attempts count towards the same brute-force protection as the login page, which blocks an IP after 5 failures within 5 minutes. All requests are additionally capped by [`rate-limit`](#rate-limit).
+
+## Page permissions
+
+By default a page that restricts access with `allowed-users` or `allowed-groups` also requires a real user over the API. A valid `api.token` on its own is not enough, because a token identifies nobody and cannot be checked against a user list.
+
+That means for a restricted page you must call the API with either a session cookie or basic auth, and the resulting user must be allowed on that page. Requests without any identity get `401`, and users who are not on the list get `403`. Restricted pages are also left out of `/api/v1/pages` so their existence is not revealed.
+
+> [!NOTE]
+>
+> Groups only come from OIDC claims, so a user authenticating with a username and password has no groups. Add such users to `allowed-users` rather than relying on `allowed-groups`.
+
+You can turn this off:
+
+```yaml
+api:
+ enabled: true
+ token: mytoken
+ enforce-page-permissions: false
+```
+
+> [!CAUTION]
+>
+> With `enforce-page-permissions: false` your restricted pages are served to anyone holding the API token, and to everyone at all if no token is set. Dynacat prints an unsuppressable warning while this is active.
+
+## Restricting what is exposed
+
+Two things narrow the surface:
+
+- `allowed-pages` limits the API to the page slugs you list.
+- Individual widgets are only addressable when you give them an `api-id`, which is opt-in.
+
+Example:
+
+```yaml
+api:
+ enabled: true
+ token: mytoken
+ allowed-pages:
+ - home
+```
+
+## Widget IDs
+
+Add `api-id` to any widget to give it a stable address. It must be unique across your whole configuration, and Dynacat refuses to start if two widgets share one.
+
+Example:
+
+```yaml
+- type: weather
+ api-id: home-weather
+ location: London, United Kingdom
+```
+
+The widget is then available at `/api/v1/widgets/home-weather`. Widgets nested inside `group` and `split-column` work the same way.
+
+## Endpoints
+
+Every endpoint responds to `GET` and lives under `/api/v1`.
+
+### `/api/v1/pages`
+
+Lists the pages the caller is allowed to read and the widgets on them, without any data. Useful for discovering what `api-id` values exist.
+
+```sh
+curl -H "X-API-Token: mytoken" http://localhost:8080/api/v1/pages
+```
+
+```json
+[
+ {
+ "slug": "home",
+ "name": "Home",
+ "widgets": [
+ { "api-id": "home-weather", "type": "weather", "title": "Weather" },
+ { "type": "group", "widgets": [{ "type": "clock", "title": "Clock" }] }
+ ]
+ }
+]
+```
+
+### `/api/v1/pages/{slug}`
+
+Returns every widget on the page together with its data.
+
+```sh
+curl -H "X-API-Token: mytoken" http://localhost:8080/api/v1/pages/home
+```
+
+### `/api/v1/widgets/{api-id}`
+
+Returns a single widget.
+
+```sh
+curl -H "X-API-Token: mytoken" http://localhost:8080/api/v1/widgets/home-weather
+```
+
+```json
+{
+ "api-id": "home-weather",
+ "type": "weather",
+ "title": "Weather",
+ "data": {
+ "Weather": {
+ "Temperature": 14,
+ "ApparentTemperature": 12,
+ "WeatherCode": 3
+ }
+ }
+}
+```
+
+### Status codes
+
+| Code | Meaning |
+| ---- | ------- |
+| 200 | success |
+| 401 | missing or invalid credentials, or a restricted page was requested without a user |
+| 403 | the authenticated user is not allowed on that page |
+| 404 | unknown page slug or `api-id`, or the API is disabled |
+| 429 | over the rate limit, or too many failed username and password attempts |
+
+## Data shape
+
+Each widget is returned as an envelope. `api-id` and `error` are omitted when they are empty, and `widgets` only appears on `group` and `split-column` widgets.
+
+```json
+{
+ "api-id": "home-weather",
+ "type": "weather",
+ "title": "Weather",
+ "error": "failed to fetch",
+ "data": {}
+}
+```
+
+Everything inside `data` comes from the widget itself, so the keys are the internal field names and use `PascalCase`. They can change between Dynacat versions when a widget is reworked.
+
+> [!IMPORTANT]
+>
+> Anything you wrote in your configuration is never returned. Only values a widget produced at runtime are serialized, so tokens, passwords and other credentials cannot appear in a response. Field names that look like a credential are dropped as well, and secrets embedded in query strings are replaced with `redacted`.
+
+A side effect of that rule is that configured values you might expect to see, such as a bookmark title or a server name, are not part of the output.
+
+Two widgets are limited:
+
+- `custom-api` and `dynawidgets` cannot expose their rendered result as structured data, so they return the raw upstream JSON response under `APIResponse` instead.
+- `calendar` only keeps rendered markup in memory and returns almost nothing.
+
+### Updating and shared requests
+
+Requesting a widget refreshes it only when its cache has expired, exactly like loading the page would. Calling the API repeatedly never causes extra requests to the services behind your widgets.
+
+Widgets that point at the same source also continue to share a single request, so reading two `latest-media` widgets configured against one server hits that server once.
+
+## CORS
+
+Browser scripts on another origin can only call the API if you list their origin:
+
+```yaml
+api:
+ enabled: true
+ allowed-origins:
+ - https://example.com
+ - https://dash.example.com
+```
+
+Use a single `*` to allow any origin:
+
+```yaml
+api:
+ enabled: true
+ allowed-origins:
+ - "*"
+```
+
+> [!NOTE]
+>
+> Browsers refuse to send credentials to an endpoint that answers with `*`, so a page using `X-API-Token` or basic auth needs its exact origin listed.
+
+## Security
+
+> [!CAUTION]
+>
+> Enabling the API without a `token` makes your widget data readable by anyone who can reach the server, even when the dashboard itself requires a login. Set a token, keep `enforce-page-permissions` on, and use `allowed-pages` to expose only what you need.
+
+Behind a reverse proxy, set `server.proxied: true` so brute-force protection sees the real client IP:
+
+```yaml
+server:
+ proxied: true
+```
diff --git a/docs/docs/authentication.md b/docs/docs/authentication.md
index 86ebc21b..5ab85a13 100644
--- a/docs/docs/authentication.md
+++ b/docs/docs/authentication.md
@@ -280,6 +280,51 @@ pages:
...
```
+## Editing Access Control
+
+The web UI editor (drag-and-drop page/widget editing) is enabled by default for all users who can access a page. Restrict who is allowed to use it with `editing-users` and `editing-groups`, under the top-level `server` property:
+
+```yaml
+server:
+ editing-users:
+ - admin
+ editing-groups:
+ - devops
+```
+
+A user can use the web UI editor if their username appears in `editing-users` **or** they belong to any group in `editing-groups`. If neither is set, every user who can log in is allowed to edit.
+
+> [!NOTE]
+>
+> Password-based users have no groups, groups only ever come from OIDC claims. `editing-groups` has no effect unless [OIDC](#oidc-authentication) is configured.
+
+### Restricting a user to specific pages
+
+To limit which pages a particular password-based user can edit, set `restrict-editing` on that user under `auth.users`:
+
+```yaml
+auth:
+ secret-key: ...
+ users:
+ admin:
+ password: ...
+ # no restrict-editing = can edit any page, per allow-editing
+
+ alice:
+ password: ...
+ restrict-editing:
+ - home
+ - homelab
+```
+
+When a user has `restrict-editing` set, it takes precedence over `allow-editing` for them: they can only edit the listed pages (identified by `slug`) and cannot add pages or change styling, regardless of `allow-editing`. Users without `restrict-editing` fall back to `allow-editing`.
+
+> [!NOTE]
+>
+> `restrict-editing` is only available for password-based users configured under `auth.users`, since OIDC users have no corresponding config entry to attach it to.
+
+This is independent of `allow-editing` and `restrict-editing`, which control *which pages* can be edited rather than *who* can edit them, see [Server configuration](configuration.md#editing-users-editing-groups) for details.
+
---
## Docker / Environment Variables
diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md
index cd5683f6..4a8c9b01 100644
--- a/docs/docs/changelog.md
+++ b/docs/docs/changelog.md
@@ -1,3 +1,34 @@
+# Changes for 3.0.0
+- Moved the compose template to [Panonim/dynacat-compose-template](https://github.com/Panonim/dynacat-compose-template), the install command is now a single `curl | tar` with no `sed` renaming
+- Added support for `tcp://`/`http://` remote Docker hosts to `sock-path` in the `docker-controller` widget, matching `docker-containers` -> https://github.com/Panonim/dynacat/issues/137
+- Documented `id`/`parent` grouping for the `containers` property in `docker-containers` widget -> https://github.com/Panonim/dynacat/issues/134
+- Fixed an issue where subrequests were not detected properly in `dynawidgets`
+- Added automatic update checks for cached `dynawidgets` templates, with a notice on the widget when one changed
+- Fixed `dynawidgets` protocol handling
+- Added an option to visually edit your configuration
+- API, so you can now easily get all your data in external apps
+- Fixed Navidrome in currently playing
+- Added an option to see bookmarks, docker containers and monitors in search widget -> https://github.com/Panonim/dynacat/issues/126
+- Fixed issues with releases performence in `calendar` widget
+- Made image fetching in `currently playing` more reliable
+- Added a function in `calendar` widget when showing releases to also show release type
+- Added a function in `calendar` widget to also show current state of the media e.g. grabbed
+- Fixed issue with `search` widget highlighting
+- Fixed Jellyfin issue where `playing` widget couldn't be resolved because of the api changes.
+- Added a read-only JSON API for accessing widget data
+- Added option to mark `monitor` as disabled
+- Added `show-history` option to `monitor` widget showing a bar of the last hour of status checks
+- Added a comparison to the previous run in `speedtest` widget
+- Fixed a few security issues
+- Fixed trash icon disappearing while dragging a `todo` item on mobile
+- Fixed `todo` checkbox sitting too close to widget edge on mobile
+- Fixed a lot of smaller ui issues (I lost count at some point tbh)
+- Added [degoog](https://github.com/degoog-org/degoog) theme and engine
+- Fixed Reddit widget and Reddit RSS feeds returning `403` again after Reddit renamed the JS challenge token field -> https://github.com/Panonim/dynacat/issues/141
+- Updated Go packages
+- Updated docs to include missing blocky information
+- Server stats not showing CPU temp and wrong platform name -> https://github.com/Panonim/dynacat/issues/135
+
# Changes for 2.4.0
- Added Brave Search as an autocompletion engine and normal one
- Added support for icons in the page title
diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md
index bfc95b0f..7d0a6fd3 100644
--- a/docs/docs/configuration.md
+++ b/docs/docs/configuration.md
@@ -202,11 +202,15 @@ server:
| host | string | no | |
| port | number | no | 8080 |
| proxied | boolean | no | false |
+| trusted-proxies | array of strings | no | |
| base-url | string | no | |
| assets-path | string | no | /app/assets |
| cache-dir | string | no | .cache |
| db-path | string | no | /app/assets/dynacat.db |
| allowed-embed-hosts | array of strings | no | |
+| allow-editing | boolean | no | true |
+| editing-users | array of strings | no | |
+| editing-groups | array of strings | no | |
#### `host`
The address which the server will listen on. Setting it to `localhost` means that only the machine that the server is running on will be able to access the dashboard. By default it will listen on all interfaces.
@@ -217,6 +221,21 @@ A number between 1 and 65,535, so long as that port isn't already used by anythi
#### `proxied`
Set to `true` if you're using a reverse proxy in front of Dynacat. This will make Dynacat use the `X-Forwarded-*` headers to determine the original request details.
+#### `trusted-proxies`
+A list of IPs and/or CIDR ranges (e.g. `10.0.0.1` or `10.0.0.0/24`) allowed to set the `X-Forwarded-*` headers when `proxied` is `true`. A bare IP is treated as a `/32` (or `/128` for IPv6).
+
+If left empty, `X-Forwarded-*` headers are ignored entirely and the direct connection's address is used instead, even with `proxied: true`. This is the safe default: it avoids trusting forwarded headers from an unknown source, but it also means client IPs/protocol will show as your proxy's, not the real visitor's, until you list the proxy here.
+
+Example:
+
+```yaml
+server:
+ proxied: true
+ trusted-proxies:
+ - 10.0.0.0/24
+ - 192.168.1.1
+```
+
#### `base-url`
The base URL that Dynacat is hosted under. No need to specify this unless you're using a reverse proxy and are hosting Dynacat under a directory. If that's the case then you can set this value to `/dynacat` or whatever the directory is called. Note that the forward slash (`/`) in the beginning is required unless you specify the full domain and path.
@@ -283,6 +302,23 @@ server:
This sets the `Content-Security-Policy: frame-ancestors` directive to include the listed origins in addition to `'self'`.
+#### `allow-editing`
+Whether the web UI editor (the pencil icon that lets you drag, drop and configure widgets and pages directly from the dashboard) is allowed to make changes. Defaults to `true`.
+
+Set it to `false` to disable the web UI editor entirely:
+
+```yaml
+server:
+ allow-editing: false
+```
+
+> [!NOTE]
+>
+> This only controls the web UI editor. Editing the config file directly, or through `$include`d files, always works regardless of this setting.
+
+#### `editing-users`, `editing-groups`
+Restrict who is allowed to use the web UI editor, regardless of `allow-editing`. If neither is set, any user who can already log in is allowed to edit. To limit a specific user to only certain pages, use `restrict-editing` on that user instead, see [Editing Access Control](authentication.md#editing-access-control) for details.
+
## Document
If you want to insert custom HTML into the `` of the document for all pages, you can do so by using the `document` property. Example:
@@ -582,10 +618,10 @@ A keybind is one or more keys separated by spaces. Each key is a single letter o
| Config value | Behavior |
| ------------ | -------- |
-| `h` | Automatically expanded to `d h` — press `d` then `h` |
+| `h` | Automatically expanded to `d h` - press `d` then `h` |
| `d h` | Press `d`, release, then press `h` within 1 second |
| `d a c` | Three-key sequence: `d` → `a` → `c` |
-| `1` | Automatically expanded to `d 1` — press `d` then `1` |
+| `1` | Automatically expanded to `d 1` - press `d` then `1` |
> [!NOTE]
>
@@ -822,6 +858,8 @@ Preview:
| ---- | ---- | -------- | ------- |
| first-day-of-week | string | no | monday |
| hosts | array | no | |
+| release-types | list of strings | no | cinema, physical, digital, episode |
+| show-release-state | boolean | no | false |
| update-interval | string | no | 15m |
##### `first-day-of-week`
@@ -873,6 +911,65 @@ Optionally override the URL used for the release links in the popover. Useful wh
Whether to allow invalid/self-signed certificates when making requests to the instance.
+##### `release-types`
+Which kinds of release to show. Each release is shown as its own entry on the day it lands, with a small icon next to the title indicating the type:
+
+- `cinema` - Radarr, in cinemas (film icon)
+- `physical` - Radarr, physical/disc release (minidisc icon)
+- `digital` - Radarr, digital/home release (monitor icon)
+- `episode` - Sonarr episodes (television icon)
+
+A **Radarr** movie can have a cinema, a physical and a digital release date, so it may appear on up to three days. This option controls both Radarr and Sonarr - for example dropping `episode` hides Sonarr episodes entirely.
+
+By default all types are shown. Restrict it by listing only the ones you want.
+
+```yaml
+- type: calendar
+ release-types:
+ - cinema
+ - digital
+ hosts:
+ - url: radarr:https://radarr.domain.com
+ token: ${RADARR_KEY}
+```
+
+```yaml
+- type: calendar
+ release-types: digital
+ hosts:
+ - url: radarr:https://radarr.domain.com
+ token: ${RADARR_KEY}
+```
+
+##### `show-release-state`
+By default, the line under a day with releases is always your theme's ⬤ `primary-color` (the purple in the default theme). It is a static marker that only tells you something lands on that day, with no indication of whether it is out yet or downloaded.
+
+Set `show-release-state` to `true` to instead color that line by the availability status of the day's releases, pulled from Sonarr/Radarr. Each state maps to one of your theme colors:
+
+| Color | State | Meaning |
+| ----- | ----- | ------- |
+| ⬤ `positive-color` | `available` | Out and already downloaded, ready to watch |
+| ⬤ `negative-color` | `released` | Out now, but not downloaded yet |
+| ⬤ `color-upcoming` | `upcoming` | Not out yet, its release date is still in the future |
+
+`available` uses your theme's `positive-color` and `released` uses `negative-color`. `upcoming` has its own amber color so it always stands out from the other two.
+
+> [!NOTE]
+>
+> `positive-color` defaults to `primary-color`. If you want downloaded (`available`) days to look distinct, set a separate [`positive-color`](#positive-color) in your theme (for example a green). The `upcoming` amber can be overridden with the `--color-upcoming` CSS variable via a [`custom-css-file`](#custom-css-file).
+
+If a day has several releases with different states, the line shows the least available one, so it stays on the `released` or `upcoming` color until nothing is left pending. The priority is `released` → `upcoming` → `available`.
+
+Only relevant when `hosts` is configured.
+
+```yaml
+- type: calendar
+ show-release-state: true
+ hosts:
+ - url: radarr:https://radarr.domain.com
+ token: ${RADARR_KEY}
+```
+
##### `update-interval`
How often the calendar polls for release updates without reloading the page. The value is a string and must be a number followed by one of s (seconds), m (minutes) or h (hours). Only relevant when `hosts` is configured. Default is `15m`.
@@ -1321,15 +1418,16 @@ The slug of the widget from the dynawidgets repository. This is the only require
widget: daily-chess-puzzle
```
+The cached template is update automatically, so you don't have to worry about anything.
+
##### `repo`
-The branch/repository to fetch the widget from. This allows you to test widgets from different branches or forks. Defaults to `main`. Example:
+The branch of the dynawidgets repository to fetch the widget from. This allows you to test widgets that are not on `main` yet.
```yaml
widget: daily-chess-puzzle
repo: testing/main
```
-This will fetch from `https://raw.githubusercontent.com/Panonim/dynawidgets/refs/heads/testing/main/...`
##### `url`, `headers`, `method`, `body-type`, `body`, `frameless`, `allow-insecure`, `skip-json-validation`
These properties work the same as in the [custom-api widget](#custom-api). They override the default values defined in the widget's template `required` section.
@@ -1340,7 +1438,7 @@ These properties work the same as in the [custom-api widget](#custom-api) and al
Learn more about building and contributing widgets in the [Contributing to Dynawidgets](contributing.md) guide.
### DNS Stats
-Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home, Pi-hole, or Technitium.
+Display statistics from a self-hosted ad-blocking DNS resolver such as AdGuard Home, Pi-hole, Technitium, or Blocky.
Example:
@@ -1358,7 +1456,7 @@ Preview:
> [!NOTE]
>
-> When using AdGuard Home the 3rd statistic on top will be the average latency and when using Pi-hole or Technitium it will be the total number of blocked domains from all adlists.
+> When using AdGuard Home or Blocky the 3rd statistic on top will be the average latency and when using Pi-hole or Technitium it will be the total number of blocked domains from all adlists.
#### Properties
@@ -1375,7 +1473,7 @@ Preview:
| hour-format | string | no | 12h |
##### `service`
-Either `adguard`, `technitium`, or `pihole` (major version 5 and below) or `pihole-v6` (major version 6 and above).
+Either `adguard`, `technitium`, `blocky`, or `pihole` (major version 5 and below) or `pihole-v6` (major version 6 and above).
##### `allow-insecure`
Whether to allow invalid/self-signed certificates when making the request to the service.
@@ -1383,6 +1481,8 @@ Whether to allow invalid/self-signed certificates when making the request to the
##### `url`
The base URL of the service.
+When using Blocky this is the base URL of the **Prometheus** instance scraping Blocky, not of Blocky itself, since Blocky has no stats API of its own. The widget queries it through `/api/v1/query` and `/api/v1/query_range`.
+
##### `username`
Only required when using AdGuard Home. The username used to log into the admin dashboard.
@@ -1405,6 +1505,38 @@ Whether to hide the list of top blocked domains.
##### `hour-format`
Whether to display the relative time in the graph in `12h` or `24h` format.
+#### Using Blocky
+
+Blocky needs its Prometheus metrics enabled and scraped before the widget has anything to read:
+
+`config.yml` (Blocky)
+```yaml
+prometheus:
+ enable: true
+ path: /metrics
+```
+
+`prometheus.yml`
+```yaml
+scrape_configs:
+ - job_name: blocky
+ static_configs:
+ - targets:
+ - blocky:4000
+```
+
+Then point the widget at Prometheus:
+
+```yaml
+- type: dns-stats
+ service: blocky
+ url: http://prometheus:9090
+```
+
+Neither `username`, `password` nor `token` are used for Blocky, so the Prometheus instance has to be reachable from Dynacat without authentication.
+
+The statistics come from the `blocky_query_total`, `blocky_response_total`, `blocky_request_duration_seconds` and `blocky_denylist_cache_entries` metrics. Blocky exposes no per-domain counters, so the top blocked domains list is always empty regardless of `hide-top-domains`.
+
### Docker Containers
Display the status of your Docker containers along with an icon and an optional short description.
@@ -1453,6 +1585,24 @@ Alternatively, you can also define the values within your `dynacat.yml` via the
hide: false
```
+This also works for grouping child containers under a parent, same as with labels — the "main" container needs an `id` and each "child" needs a matching `parent`:
+
+```yaml
+- type: docker-containers
+ containers:
+ cloud_service:
+ name: Cloud
+ url: https://mydomain.com
+ icon: si:googlecloud
+ id: cloudreve
+ cloud_service_postgresql:
+ name: cloud.postgresql
+ parent: cloudreve
+ cloud_service_redis:
+ name: cloud.redis
+ parent: cloudreve
+```
+
For services with multiple containers you can specify a `dynacat.id` on the "main" container and `dynacat.parent` on each "child" container:
@@ -1517,7 +1667,7 @@ Whether to hide the containers by default. If set to `true` you'll have to manua
When set to `true`, automatically converts container names such as `container_name_1` into `Container Name 1`.
##### `sock-path`
-The path to the Docker socket. This can also be a [remote socket](https://docs.docker.com/engine/daemon/remote-access/) or proxied socket using something like [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy).
+The path to the Docker socket. This can also be a `tcp://host:port` or `http://host:port` address to connect to a [remote Docker host](https://docs.docker.com/engine/daemon/remote-access/), or a proxied socket using something like [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy).
###### `category`
Filter to only the containers which have this category specified via the `dynacat.category` label. Useful if you want to have multiple containers widgets, each showing a different set of containers.
@@ -1630,7 +1780,7 @@ Controls what to display in the widget. Possible values are:
- `both` - Display both containers and images (default)
##### `sock-path`
-The path to the Docker socket. This can also be a [remote socket](https://docs.docker.com/engine/daemon/remote-access/) or proxied socket using something like [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy).
+The path to the Docker socket. This can also be a `tcp://host:port` or `http://host:port` address to connect to a [remote Docker host](https://docs.docker.com/engine/daemon/remote-access/), or a proxied socket using something like [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy).
##### `format-container-names`
When set to `true`, automatically converts container names such as `container_name_1` into `Container Name 1`.
@@ -1993,11 +2143,21 @@ You can hover over the "ERROR" text to view more information.
| sites | array | yes | |
| style | string | no | |
| show-failing-only | boolean | no | false |
+| show-history | boolean | no | false |
| update-interval | string | no | 2m |
##### `show-failing-only`
Shows only a list of failing sites when set to `true`.
+##### `show-history`
+When set to `true`, a small strip of ticks is shown on the right of each site, one tick per status check, oldest on the left and newest on the right. Ticks use your theme's `positive-color` when the check was OK and `negative-color` when it failed.
+
+The strip holds the last 15 checks and nothing older than an hour, so how far back it reaches depends on your `update-interval` - with the default of `2m` it covers the last 30 minutes, with `4m` it covers the full hour. Sites with `disabled: true` are not tracked.
+
+> [!NOTE]
+>
+> The history is kept in memory only, meaning that it is lost when Dynacat restarts.
+
##### `style`
Used to change the appearance of the widget. Possible values are `compact`.
@@ -2021,6 +2181,7 @@ Properties for each site:
| timeout | string | no | 3s |
| allow-insecure | boolean | no | false |
| same-tab | boolean | no | false |
+| disabled | boolean | no | false |
| alt-status-codes | array | no | |
| basic-auth | object | no | |
@@ -2060,6 +2221,17 @@ Whether to ignore invalid/self-signed certificates.
Whether to open the link in the same or a new tab.
+`disabled`
+
+Set to `true` to stop monitoring this site without removing it from the config. The site is still shown in the list, but no request is made to it and it's displayed with a "Disabled" status instead of "OK"/"ERROR". Disabled sites are never counted towards `show-failing-only`.
+
+```yaml
+sites:
+ - title: Jellyfin
+ url: https://jellyfin.yourdomain.com
+ disabled: true
+```
+
`alt-status-codes`
Status codes other than 200 that you want to return "OK".
@@ -2528,12 +2700,19 @@ Preview:
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
| search-engine | string | no | duckduckgo |
+| degoog-url | string | when using `degoog` | |
| new-tab | boolean | no | false |
| autofocus | boolean | no | false |
| target | string | no | _blank |
| placeholder | string | no | Type here to search… |
| autocomplete | boolean | no | true |
| autocomplete-provider | string | no | duckduckgo |
+| include-bookmarks | boolean | no | false |
+| cross-page-bookmarks | boolean | no | false |
+| include-docker | boolean | no | false |
+| cross-page-docker | boolean | no | false |
+| include-monitor | boolean | no | false |
+| cross-page-monitor | boolean | no | false |
| bangs | array | no | |
##### `search-engine`
@@ -2548,6 +2727,19 @@ Either a value from the table below or a URL to a custom search engine. Use `{QU
| kagi | `https://kagi.com/search?q={QUERY}` |
| startpage | `https://www.startpage.com/search?q={QUERY}` |
| qwant | `https://www.qwant.com/?q={QUERY}&t=web` |
+| brave | `https://search.brave.com/search?q={QUERY}` |
+| degoog | your own instance, see [`degoog-url`](#degoog-url) |
+
+##### `degoog-url`
+The base URL of your [Degoog](https://github.com/degoog-org/degoog) instance, used when `search-engine` is set to `degoog`. Searches are sent to `/search?q=` and, unless you set your own [`autocomplete-provider`](#autocomplete-provider), suggestions come from that same instance through its `/api/suggest/opensearch` endpoint.
+
+```yaml
+- type: search
+ search-engine: degoog
+ degoog-url: https://degoog.example.com
+```
+
+Because the instance is yours, it may live on a private address such as `http://192.168.1.10:4444` — the suggestion request is made server-side by Dynacat, so the browser never sees the URL.
##### `new-tab`
When set to `true`, swaps the shortcuts for showing results in the same or new tab, defaulting to showing results in a new tab.
@@ -2565,12 +2757,57 @@ When set, modifies the text displayed in the input field before typing.
When set to `true` (default), displays search suggestions as you type. Navigate suggestions with ↑ and ↓ arrow keys, select with Enter, or dismiss with Escape. Set to `false` to disable autocompletion.
##### `autocomplete-provider`
-The provider used for search suggestions. Possible values are `duckduckgo` (default) and `brave`.
+Either `duckduckgo` (default), `brave`, or a URL to a custom suggestion endpoint — works the same way as [`search-engine`](#search-engine): pick a known provider by name, or supply your own URL directly.
| Value | Provider |
| ----- | -------- |
| duckduckgo | DuckDuckGo autocomplete |
| brave | Brave Search autocomplete |
+| a URL containing `{QUERY}` | Your own suggestion endpoint |
+
+A custom URL must return the [OpenSearch suggestions format](https://github.com/dewitt/opensearch/blob/master/opensearch-1-1-draft-6.md#the-json-format) (`["query", ["suggestion 1", "suggestion 2", ...]]`), which is what most self-hosted and public search engines (SearXNG, Wikipedia, etc.) expose. Use `{QUERY}` to indicate where the typed query gets placed. The request is made server-side, so the URL is never sent to the browser. Example:
+
+```yaml
+- type: search
+ search-engine: https://en.wikipedia.org/wiki/Special:Search?search={QUERY}
+ autocomplete-provider: https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search={QUERY}
+```
+
+> [!NOTE]
+>
+> Older configs written with `autocomplete-provider: custom` alongside a separate `autocomplete-url: ` still work, but should be migrated to the single-field form above — the `autocomplete-url` property is deprecated and no longer shown in the interactive editor.
+
+##### `include-bookmarks`
+When set to `true`, matches what you type against the titles of your [bookmarks](#bookmarks) (case-insensitive, matching from the start of the title) and shows matching bookmarks above the regular search suggestions. Clicking a bookmark match navigates straight to its URL instead of performing a search. Up to 3 matches are shown.
+
+By default only bookmarks on the same page as the search widget are matched. Use [`cross-page-bookmarks`](#cross-page-bookmarks) to match bookmarks from every page.
+
+Each match shows the bookmark's own [icon](#icons) if it has one, or a small arrow otherwise, exactly like in the bookmarks widget itself.
+
+```yaml
+- type: search
+ include-bookmarks: true
+```
+
+##### `cross-page-bookmarks`
+When set to `true`, implies [`include-bookmarks`](#include-bookmarks) and widens the search to bookmarks on every page, not just the page the search widget is on.
+
+```yaml
+- type: search
+ cross-page-bookmarks: true
+```
+
+##### `include-docker`
+When set to `true`, matches what you type against the container names from the [docker containers](#docker-containers) widgets on the same page. Only containers with a URL are matched and each match shows the container's [icon](#icons).
+
+##### `cross-page-docker`
+When set to `true`, widens the search to containers from every page.
+
+##### `include-monitor`
+When set to `true`, matches what you type against the site titles from the [monitor](#monitor) widgets on the same page. Each match shows the site's [icon](#icons) and points at the same URL as the widget.
+
+##### `cross-page-monitor`
+When set to `true`, widens the search to sites from every page.
##### `bangs`
What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube:
@@ -2646,6 +2883,15 @@ In the event that the CPU temperature goes over 80°C, a flame icon will appear

+When running in a container the reported platform is the one of the image (usually `alpine`) because the host's `/etc/os-release` is not visible from inside. Mount it to have the host's distribution shown instead:
+
+```yaml
+volumes:
+ - /etc/os-release:/host/etc/os-release:ro
+```
+
+Alternatively, set [`HOST_ETC`](docker-options.md#host_etc) if you'd rather bind mount the host's whole `/etc` to a custom path.
+
#### Properties
| Name | Type | Required | Default |
| ---- | ---- | -------- | ------- |
@@ -2684,7 +2930,21 @@ Whether to hide the swap usage.
| mountpoints | map\[string\]object | no | |
###### `cpu-temp-sensor`
-The name of the sensor to use for the CPU temperature. When not provided the widget will attempt to find the correct one, if it fails to do so the temperature will not be displayed. To view the available sensors you can use `sensors` command.
+The name of the sensor to use for the CPU temperature. When not provided the widget will attempt to find the correct one, if it fails to do so the temperature will not be displayed. To view the available sensors you can use the `sensors` command.
+
+Both the chip name and the `chip/Label` form printed by `sensors` are accepted, as is a bare label. For a chip listed as:
+
+```
+k10temp-pci-00c3
+Adapter: PCI adapter
+Tctl: +44.0°C
+Tccd1: +40.5°C
+```
+
+any of `k10temp-pci-00c3`, `k10temp`, `k10temp-pci-00c3/Tctl` or `Tctl` will work. When only the chip is given and it reports several temperatures, the one representing the whole package is picked. If the sensor cannot be matched, the names Dynacat can see are listed in the log so you can pick one of them.
+
+> [!NOTE]
+> Inside a container the sensors are only visible if `/sys` is readable, which is the case by default. Temperatures reported by `sensors` on the host but missing from the log usually mean the corresponding kernel module is not loaded.
###### `hide-mountpoints-by-default`
If set to `true` you'll have to manually make each mountpoint visible by adding a `hide: false` property to it like so:
@@ -3297,6 +3557,7 @@ Preview:
| show-progress-bar | boolean | no | true |
| show-progress-info | boolean | no | true |
| group-by-host | boolean | no | false |
+| hide-username | boolean | no | false |
| update-interval | string | no | 30s |
| episode-title-format | string | no | series |
@@ -3367,6 +3628,9 @@ Example:
##### `group-by-host`
When `true`, groups sessions by their media server. When `false`, displays all sessions in a unified list.
+##### `hide-username`
+When `true`, hides the user row for each session - both the username and the play-state indicator (or `[playing]`/`[paused]` text).
+
#### API Access & Tokens
**Plex:**
diff --git a/docs/docs/contributing.md b/docs/docs/contributing.md
index ad67970f..bdbc8003 100644
--- a/docs/docs/contributing.md
+++ b/docs/docs/contributing.md
@@ -66,6 +66,19 @@ If you want to share a custom widget with the community, follow these steps:
5. Add a `required:` section at the bottom of `template.txt` to specify default configuration values. This is optional but recommended for any API that requires a URL or other configuration. The format is YAML, with supported fields:
- `url`: The default API endpoint to fetch data from
+ - `subrequests`: Default subrequests keyed by name, each a request with its own `url` (and optional `headers`, `method`, etc). Required if your template calls `.Subrequest "key"`, so the key is defined even when the user does not set it. Example:
+
+ ```text
+
{{ (.Subrequest "heartbeats").JSON.Int "count" }}
+
+ required: |
+ url: https://api.example.com/stats
+ subrequests:
+ heartbeats:
+ url: https://api.example.com/heartbeats
+ ```
+
+ Users can override each subrequest's URL the same way they override the primary `url`.
When users add your widget to their config, they can override these defaults:
@@ -97,17 +110,6 @@ author: your-github-username
## Widget Guidelines
-### Use suffixed CSS classes
-
-If you need custom CSS, suffix classes with the widget name so styles stay isolated:
-
-```css
-.{class}-{widget-name} {
- text-align: center;
- margin-top: 1.5rem;
-}
-```
-
### Use environment variables for configurable values
Do not hardcode local addresses or secrets.
diff --git a/docs/docs/custom-api.md b/docs/docs/custom-api.md
index d1324262..c3eddebf 100644
--- a/docs/docs/custom-api.md
+++ b/docs/docs/custom-api.md
@@ -1,3 +1,7 @@
+> [!TIP]
+>
+> You can also build simple `custom-api` widgets visually with the [Widget Editor](widget-editor.md), which writes the template for you.
+
## Update Intervals
Custom API widgets support configurable client-side update intervals using the `update-interval` property. This controls how often the browser fetches new data from the server for the widget.
@@ -488,6 +492,7 @@ The following helper functions provided by Dynacat are available:
- `hide() template.HTML`: Hides the current `custom-api` widget for this render. Useful when an API response should not be displayed (for example, a `204 No Content` response).
- `toFloat(i int) float`: Converts an integer to a float.
- `toInt(f float) int`: Converts a float to an integer.
+- `formatBytes(v any) string`: Formats a byte count as B, KB, MB, GB, TB or PB, e.g. `7.4 GB`.
- `toRelativeTime(t time.Time) template.HTMLAttr`: Converts Time to a relative time such as 2h, 1d, etc which dynamically updates. **NOTE:** the value of this function should be used as an attribute in an HTML tag, e.g. ``.
- `now() time.Time`: Returns the current time.
- `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s".
diff --git a/docs/docs/docker-options.md b/docs/docs/docker-options.md
index 091715bd..d8f305f1 100644
--- a/docs/docs/docker-options.md
+++ b/docs/docs/docker-options.md
@@ -59,6 +59,50 @@ environment:
- BIND=127.0.0.1
```
+### ENABLE_EDITOR
+
+Permanently disables the interactive page editor. Set it to `false`, `0`, `f`, `no`, or `off` to hide the edit button on every page, stop the editor assets from loading, and unregister the `/api/editor/*` endpoints:
+
+```yaml
+environment:
+ - ENABLE_EDITOR=false
+```
+
+Editor-specific configuration (`server.allow-editing`, `server.editing-users`, `server.editing-groups`, and any user's `restrict-editing`) stays valid YAML and is **not** an error - it is simply ignored. On startup Dynacat logs a single warning naming the settings it skipped.
+
+
+> [!NOTE]
+>
+> With the editor disabled, the config file is the only way to change pages. Requests to the editor API return `404` instead of `403`.
+
+### EDITOR_SEPARATE_PAGE_FILES
+
+Controls where the interactive page editor stores a new page. By default each new page is written to its own file (e.g. `my-page.yml`) in the config directory and linked into the main config with a `$include` line, which keeps multiple pages easy to manage.
+
+Set it to `false`, `0`, or `f` to write new pages inline into the main config file (`dynacat.yml`) instead:
+
+```yaml
+environment:
+ - EDITOR_SEPARATE_PAGE_FILES=false
+```
+
+> [!NOTE]
+>
+> This only affects pages created after the change. Existing pages, whether inline or included, keep their current location.
+
+### HOST_ETC
+
+Points the `server-stats` widget at a bind-mounted copy of the host's `/etc` directory so it reports the host's OS instead of the container image's (usually `alpine`). Dynacat reads `$HOST_ETC/os-release`.
+
+This is an alternative to mounting the file directly to `/host/etc/os-release` (see [Server Stats](configuration.md#server-stats)) - useful if you'd rather bind mount the whole host `/etc` to a path of your choosing:
+
+```yaml
+environment:
+ - HOST_ETC=/host-etc
+volumes:
+ - /etc:/host-etc:ro
+```
+
## Dynamic Refreshing
Dynamic refreshing allows widgets to automatically update their data at specified intervals. This behavior can be controlled through two mechanisms:
@@ -101,6 +145,8 @@ The global page update interval can be disabled by:
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `ENABLE_DYNAMIC_UPDATE` | `true` | Set to `false`, `0`, or `f` to disable automatic widget refresh. Useful for static views or default glance behaviour. |
+| `EDITOR_SEPARATE_PAGE_FILES` | `true` | Set to `false`, `0`, or `f` to write new pages inline into the main config instead of a separate `$include` file. |
+| `ENABLE_EDITOR` | `true` | Set to `false`, `0`, `f`, `no`, or `off` to disable the page editor everywhere. Editor config keys are ignored with a startup warning. |
## ZFS Mountpoint Support
diff --git a/docs/docs/faq.md b/docs/docs/faq.md
index b68eed7f..1d397321 100644
--- a/docs/docs/faq.md
+++ b/docs/docs/faq.md
@@ -2,6 +2,10 @@
## Does the information on the page update automatically?
Yes! That's the whole point of Dynacat
+## Do I have to edit YAML by hand?
+
+No. Click the pencil icon in the header to open the UI editor, which lets you add pages, drag widgets into columns, change their options and set your theme from the dashboard itself. It writes normal YAML back into your config files, so you can switch between the two whenever you want.
+
## Can I create my own widgets?
Yes, there are multiple ways to create custom widgets:
@@ -35,11 +39,7 @@ If you're not make sure you copied the installation command correctly:
```bash
mkdir dynacat && cd dynacat && \
-curl -sL https://github.com/glanceapp/docker-compose-template/archive/refs/heads/main.tar.gz | tar -xzf - --strip-components 2 && \
-sed -i \
- -e 's/^ glance:/ dynacat:/' \
- -e 's/^ container_name: glance/ container_name: dynacat/' \
- -e 's/^ image: glanceapp\/glance/ image: panonim\/dynacat/' \
- docker-compose.yml && \
-mv config/glance.yml config/dynacat.yml
-```
\ No newline at end of file
+curl -sL https://github.com/Panonim/dynacat-compose-template/releases/latest/download/dynacat.tar.gz | tar -xzf -
+```
+
+The archive already contains `config/dynacat.yml`, so nothing needs renaming.
\ No newline at end of file
diff --git a/docs/docs/images/themes/degoog-dark.png b/docs/docs/images/themes/degoog-dark.png
new file mode 100644
index 00000000..f1638267
Binary files /dev/null and b/docs/docs/images/themes/degoog-dark.png differ
diff --git a/docs/docs/images/themes/degoog-light.png b/docs/docs/images/themes/degoog-light.png
new file mode 100644
index 00000000..1f98a4cf
Binary files /dev/null and b/docs/docs/images/themes/degoog-light.png differ
diff --git a/docs/docs/images/ui-editor-add-column.png b/docs/docs/images/ui-editor-add-column.png
new file mode 100644
index 00000000..f04cbf03
Binary files /dev/null and b/docs/docs/images/ui-editor-add-column.png differ
diff --git a/docs/docs/images/ui-editor-branding-badge.png b/docs/docs/images/ui-editor-branding-badge.png
new file mode 100644
index 00000000..e28bad85
Binary files /dev/null and b/docs/docs/images/ui-editor-branding-badge.png differ
diff --git a/docs/docs/images/ui-editor-branding.png b/docs/docs/images/ui-editor-branding.png
new file mode 100644
index 00000000..a42732ff
Binary files /dev/null and b/docs/docs/images/ui-editor-branding.png differ
diff --git a/docs/docs/images/ui-editor-column-remove.png b/docs/docs/images/ui-editor-column-remove.png
new file mode 100644
index 00000000..0aaa1813
Binary files /dev/null and b/docs/docs/images/ui-editor-column-remove.png differ
diff --git a/docs/docs/images/ui-editor-new-page.png b/docs/docs/images/ui-editor-new-page.png
new file mode 100644
index 00000000..11303dae
Binary files /dev/null and b/docs/docs/images/ui-editor-new-page.png differ
diff --git a/docs/docs/images/ui-editor-open-editor.png b/docs/docs/images/ui-editor-open-editor.png
new file mode 100644
index 00000000..7bb8315d
Binary files /dev/null and b/docs/docs/images/ui-editor-open-editor.png differ
diff --git a/docs/docs/images/ui-editor-overview.png b/docs/docs/images/ui-editor-overview.png
new file mode 100644
index 00000000..99c11286
Binary files /dev/null and b/docs/docs/images/ui-editor-overview.png differ
diff --git a/docs/docs/images/ui-editor-page-options.png b/docs/docs/images/ui-editor-page-options.png
new file mode 100644
index 00000000..fe4b72f0
Binary files /dev/null and b/docs/docs/images/ui-editor-page-options.png differ
diff --git a/docs/docs/images/ui-editor-page-tools.png b/docs/docs/images/ui-editor-page-tools.png
new file mode 100644
index 00000000..cb2e275c
Binary files /dev/null and b/docs/docs/images/ui-editor-page-tools.png differ
diff --git a/docs/docs/images/ui-editor-palette.png b/docs/docs/images/ui-editor-palette.png
new file mode 100644
index 00000000..301e54b8
Binary files /dev/null and b/docs/docs/images/ui-editor-palette.png differ
diff --git a/docs/docs/images/ui-editor-theme-actions.png b/docs/docs/images/ui-editor-theme-actions.png
new file mode 100644
index 00000000..c4270f00
Binary files /dev/null and b/docs/docs/images/ui-editor-theme-actions.png differ
diff --git a/docs/docs/images/ui-editor-theme-modal.png b/docs/docs/images/ui-editor-theme-modal.png
new file mode 100644
index 00000000..8b44b421
Binary files /dev/null and b/docs/docs/images/ui-editor-theme-modal.png differ
diff --git a/docs/docs/images/ui-editor-theme-trigger.png b/docs/docs/images/ui-editor-theme-trigger.png
new file mode 100644
index 00000000..cd97d142
Binary files /dev/null and b/docs/docs/images/ui-editor-theme-trigger.png differ
diff --git a/docs/docs/images/ui-editor-toggle.png b/docs/docs/images/ui-editor-toggle.png
new file mode 100644
index 00000000..26b9f350
Binary files /dev/null and b/docs/docs/images/ui-editor-toggle.png differ
diff --git a/docs/docs/images/ui-editor-widget-tools.png b/docs/docs/images/ui-editor-widget-tools.png
new file mode 100644
index 00000000..2947be2d
Binary files /dev/null and b/docs/docs/images/ui-editor-widget-tools.png differ
diff --git a/docs/docs/images/widget-editor-color-rule.png b/docs/docs/images/widget-editor-color-rule.png
new file mode 100644
index 00000000..3d5d9b0d
Binary files /dev/null and b/docs/docs/images/widget-editor-color-rule.png differ
diff --git a/docs/docs/images/widget-editor-example-widget.png b/docs/docs/images/widget-editor-example-widget.png
new file mode 100644
index 00000000..8ec2ac38
Binary files /dev/null and b/docs/docs/images/widget-editor-example-widget.png differ
diff --git a/docs/docs/images/widget-editor-icon-column.png b/docs/docs/images/widget-editor-icon-column.png
new file mode 100644
index 00000000..13d84a42
Binary files /dev/null and b/docs/docs/images/widget-editor-icon-column.png differ
diff --git a/docs/docs/images/widget-editor-overview.png b/docs/docs/images/widget-editor-overview.png
new file mode 100644
index 00000000..4de99b65
Binary files /dev/null and b/docs/docs/images/widget-editor-overview.png differ
diff --git a/docs/docs/images/widget-editor-preview.png b/docs/docs/images/widget-editor-preview.png
new file mode 100644
index 00000000..565fe382
Binary files /dev/null and b/docs/docs/images/widget-editor-preview.png differ
diff --git a/docs/docs/images/widget-editor-row-list.png b/docs/docs/images/widget-editor-row-list.png
new file mode 100644
index 00000000..627b9dd1
Binary files /dev/null and b/docs/docs/images/widget-editor-row-list.png differ
diff --git a/docs/docs/images/widget-editor-value-options.png b/docs/docs/images/widget-editor-value-options.png
new file mode 100644
index 00000000..fbc759d0
Binary files /dev/null and b/docs/docs/images/widget-editor-value-options.png differ
diff --git a/docs/docs/installation.md b/docs/docs/installation.md
index 8d5665c5..ad30308d 100644
--- a/docs/docs/installation.md
+++ b/docs/docs/installation.md
@@ -6,19 +6,23 @@ Create a new directory called `dynacat` as well as the template files within it
```bash
mkdir dynacat && cd dynacat && \
-curl -sL https://github.com/glanceapp/docker-compose-template/archive/refs/heads/main.tar.gz | tar -xzf - --strip-components 2 && \
-sed -i \
- -e 's/^ glance:/ dynacat:/' \
- -e 's/^ container_name: glance/ container_name: dynacat/' \
- -e 's/^ image: glanceapp\/glance/ image: panonim\/dynacat/' \
- docker-compose.yml && \
-mv config/glance.yml config/dynacat.yml
+curl -sL https://github.com/Panonim/dynacat-compose-template/releases/latest/download/dynacat.tar.gz | tar -xzf -
```
-> [!IMPORTANT]
-> Remember to keep the command exactly as-is; otherwise, the image won't work.
+*[click here to view the files that will be created](https://github.com/Panonim/dynacat-compose-template/tree/main/root)*
-*[click here to view the files that will be created](https://github.com/glanceapp/docker-compose-template/tree/main/root)*
+
+Verifying the download
+
+
+Every release ships a checksum next to the archive:
+
+```bash
+curl -sLO https://github.com/Panonim/dynacat-compose-template/releases/latest/download/dynacat.tar.gz
+curl -sLO https://github.com/Panonim/dynacat-compose-template/releases/latest/download/dynacat.tar.gz.sha256
+sha256sum -c dynacat.tar.gz.sha256
+```
+
Then, edit the following files as desired:
* `docker-compose.yml` to configure the port, volumes and other containery things
@@ -63,13 +67,39 @@ services:
env_file: .env
```
+### Running as a non-root user
+
+The image runs as root by default. Nothing in Dynacat needs it, and dropping to your own UID
+and GID is recommended, especially if you mount the docker socket:
+
+```yaml
+services:
+ dynacat:
+ user: "1000:1000"
+```
+
+Two things have to line up for this to work:
+
+- `config` and `assets` have to be readable and writable by that user, otherwise the UI editor
+ cannot save and the dynawidgets cache cannot be written: `chown -R 1000:1000 config assets`.
+- `/app` itself stays owned by root, so the image cache has to be moved onto a mounted volume
+ by setting `cache-dir` in your `dynacat.yml`:
+
+```yaml
+server:
+ cache-dir: /app/assets/.cache
+```
+
+If you mount the docker socket for the docker widgets, the user also has to be in the group
+that owns it, which is normally `docker`.
+
Then, create a new directories called `config` & `assets` and download the example starting [`dynacat.yml`](https://github.com/Panonim/dynacat/blob/main/docs/docs/dynacat.yml) file into it by running:
```bash
mkdir config && wget -O config/dynacat.yml https://raw.githubusercontent.com/Panonim/dynacat/refs/heads/main/docs/docs/dynacat.yml
```
-Feel free to edit the `dynacat.yml` file to your liking, and when ready run:
+Feel free to edit the `dynacat.yml` file to your liking, or leave it as is and use the [UI editor](ui-editor.md) once the container is up. When ready run:
```bash
docker compose up -d
diff --git a/docs/docs/manifest.json b/docs/docs/manifest.json
index d3a7180e..ff6132cd 100644
--- a/docs/docs/manifest.json
+++ b/docs/docs/manifest.json
@@ -3,6 +3,7 @@
{ "id": "home", "title": "Welcome" },
{ "id": "search", "title": "Search", "file": "docs/search.md" },
{ "section": "reference" },
+ { "id": "api", "title": "Api", "file": "docs/api.md" },
{ "id": "authentication", "title": "Authentication", "file": "docs/authentication.md" },
{ "id": "configuration", "title": "Configuration", "file": "docs/configuration.md" },
{ "id": "changelog", "title": "Changelog", "file": "docs/changelog.md" },
@@ -15,6 +16,8 @@
{ "id": "preconfigured-pages", "title": "Preconfigured Pages", "file": "docs/preconfigured-pages.md" },
{ "id": "shared-widget-options", "title": "Shared Widget Options", "file": "docs/shared-widget-options.md" },
{ "id": "themes", "title": "Themes", "file": "docs/themes.md" },
+ { "id": "ui-editor", "title": "UI Editor", "file": "docs/ui-editor.md" },
+ { "id": "widget-editor", "title": "Widget Editor", "file": "docs/widget-editor.md" },
{ "section": "dynawidgets" },
{ "id": "contributing", "title": "Contributing", "file": "docs/contributing.md" }
]
diff --git a/docs/docs/shared-widget-options.md b/docs/docs/shared-widget-options.md
index 4b6949a3..a9288aed 100644
--- a/docs/docs/shared-widget-options.md
+++ b/docs/docs/shared-widget-options.md
@@ -14,6 +14,8 @@ All widgets share a common set of configuration options that control their appea
| cache | string | no | widget-specific |
| update-interval | string | no | widget-specific |
| frameless | boolean | no | false |
+| lazy-load | boolean | no | false |
+| api-id | string | no | |
## Properties
@@ -168,7 +170,7 @@ Example:
>
> The actual update frequency may vary based on the page configuration and dynamic updates settings.
-### `lazy-load`
+### `frameless`
When set to `true`, removes the border and padding (frame) around the widget, making it blend more seamlessly with the background or other content.
@@ -193,6 +195,31 @@ Example:
frameless: true
```
+### `lazy-load`
+
+When set to `true`, the widget skips its initial update on page load and shows a loading placeholder until it actually needs its content (for example, once it scrolls into view). Useful for pages with many widgets where you want to avoid firing every widget's update on first load.
+
+Example:
+
+```yaml
+```yaml
+- type: custom-api
+ title: Minimal Widget
+ lazy-load: true
+```
+
+### `api-id`
+
+A stable name that makes the widget readable through the [API](api.md) at `/api/v1/widgets/{api-id}`. It must be unique across your whole configuration and has no effect while the API is disabled.
+
+Example:
+
+```yaml
+- type: weather
+ api-id: home-weather
+ location: London, United Kingdom
+```
+
## Combining Options
Most of these options can be combined to achieve the desired appearance and behavior:
diff --git a/docs/docs/themes.md b/docs/docs/themes.md
index 97859185..27a2128b 100644
--- a/docs/docs/themes.md
+++ b/docs/docs/themes.md
@@ -124,6 +124,17 @@ theme:
negative-color: 360 100 71
```
+### Degoog Dark
+
+```yaml
+theme:
+ background-color: 227 11 16
+ contrast-multiplier: 1.2
+ primary-color: 220 34 51
+ positive-color: 136 53 43
+ negative-color: 5 81 56
+```
+
## Light
### Catppuccin Latte
@@ -177,4 +188,16 @@ theme:
light: true
background-color: 270 12 97
primary-color: 270 35 50
+```
+
+### Degoog Light
+
+```yaml
+theme:
+ light: true
+ background-color: 0 0 95
+ contrast-multiplier: 1.1
+ primary-color: 217 36 36
+ positive-color: 136 53 40
+ negative-color: 5 81 56
```
\ No newline at end of file
diff --git a/docs/docs/ui-editor.md b/docs/docs/ui-editor.md
new file mode 100644
index 00000000..3869edc4
--- /dev/null
+++ b/docs/docs/ui-editor.md
@@ -0,0 +1,127 @@
+# UI Editor
+
+The UI editor turns the dashboard itself into the configuration screen. You add pages, drag widgets into columns, change their options and pick your colors from the page you are looking at, and Dynacat writes the result straight back into your config files.
+
+
+
+It is an addition to the config file, not a replacement for it. Everything the editor writes is normal YAML, and hand written config keeps working exactly as before.
+
+## Opening the editor
+
+Click the pencil icon in the header, on the right of the theme picker.
+
+
+
+The pencil only shows up when you are allowed to edit:
+
+| Setting | Effect |
+| ------- | ------ |
+| [`ENABLE_EDITOR`](docker-options.md#enable_editor) | `false` hides the button everywhere and unregisters the editor API |
+| [`allow-editing`](configuration.md#allow-editing) | `false` disables the editor for everyone |
+| [`editing-users`, `editing-groups`](configuration.md#editing-users-editing-groups) | Limit editing to certain users or groups |
+
+If authentication is enabled you also have to be logged in. See [Editing Access Control](authentication.md#editing-access-control) for per user and per page restrictions.
+
+## Adding a widget
+
+The palette sits at the bottom of the screen and holds every widget type.
+
+
+
+Drag an item from the palette onto a column, or onto an existing widget, and drop it where the marker line appears.
+
+## Adding a column
+
+Hover the space between two columns, or the space at either edge of the page, and a line with a **+** appears. Click it to insert a column at that spot.
+
+
+
+To remove a column, click **Remove column** under it. Its widgets go with it, so you are asked to confirm first.
+
+
+
+Column sizes themselves are not editable from the canvas, they are set in the config, see [Columns](configuration.md#columns).
+
+## Adding a page
+
+The page tools sit in the header, right after the navigation links.
+
+
+
+**Add page** asks for a name and a starting layout. The six layouts are the usual combinations of full and small columns, and the one you pick is only a starting point since columns can be added and removed afterwards.
+
+
+
+Saving creates the page and takes you straight to it. By default it is written to its own file in the config directory and linked into the main config with an `$include` line, which is controlled by [`EDITOR_SEPARATE_PAGE_FILES`](docker-options.md#editor_separate_page_files).
+
+## Page options
+
+**Edit page** opens the options of the page you are on.
+
+
+
+| Section | Options |
+| ------- | ------- |
+| Basics | **Name**, **Icon** and **Slug (URL)**, which is generated from the name when left empty |
+| Layout | **Width**, **Key bind** and **Center vertically** |
+| Navigation | **Hide from navigation**, **Hide desktop navigation**, **Show mobile header** and **Desktop navigation width** |
+
+They map one to one onto the page properties in the config file, see [Pages](configuration.md#pages) for what each one does. Because the name and the slug can change, saving navigates to the page's new address.
+
+**Remove page** deletes the page you are on after a confirmation and sends you back to the dashboard's root page.
+
+> [!NOTE]
+>
+> Pages that come from a file Dynacat cannot write to, such as a read only bind mount, refuse to be edited and say so instead.
+
+## Theming
+
+With the editor on, the theme picker in the header no longer switches the theme, it opens the styling modal.
+
+
+
+The row of swatches at the top is the list of themes. The first one is the default theme, the `theme:` key of your main config, and the rest are your [presets](configuration.md#presets). Click one to load its values into the fields below.
+
+
+
+**Background**, **Primary**, **Positive** and **Negative** take an HSL triple, e.g. `240 8 9`, but also hex and rgb. The swatch next to each field previews it. Under **Theme options**, **Light scheme** switches the theme to a light one, and **Contrast multiplier** and **Text saturation multiplier** adjust the generated text colors. All of them are described in [Theme](configuration.md#theme), and [Themes](themes.md) has ready made values you can paste in.
+
+
+
+The three buttons on the right act on the theme that is selected:
+
+| Button | Effect |
+| ------ | ------ |
+| **Save** | Writes the values back into the selected theme |
+| **Save as new** | Asks for a name and stores the current values as a new preset |
+| Trash icon | Deletes the selected preset, or resets the default theme to the built-in one |
+
+### Branding
+
+The medal icon at the end of the swatch row opens the branding page of the same modal.
+
+
+
+
+
+It covers the logo and favicon, the PWA name, icon and background color, and the footer and navigation options, all of them the [branding](configuration.md#branding) properties. **Also use this image as the favicon** copies the logo URL into the favicon field and keeps the two in sync, so you only fill in one.
+
+## Custom API widgets
+
+A `custom-api` widget has two things no other widget has. Its options start with a **Paste YAML** row, where pasting the YAML of a widget fills in every option below it and clears the ones the paste does not mention, and at the bottom left there is an **Open Editor** button.
+
+
+
+It opens a fullscreen builder where you drag blocks onto a canvas and bind them to fields of the API response instead of writing the `template` by hand. See [Widget Editor](widget-editor.md) for the whole thing.
+
+## How changes are saved
+
+Every action saves immediately, there is no separate save step for the page as a whole. Dynacat rewrites only the file the page came from, keeping your comments and `$include` lines intact, and the canvas redraws itself from the new config.
+
+Adding, editing or removing a page changes the page you are standing on, so those wait for the config to reload and then navigate. The editor is still on when the page comes back.
+
+> [!IMPORTANT]
+>
+> Blank lines between entries are lost when a file is rewritten, the content itself is not.
+
+If a change is rejected, for example because an option is invalid or the file cannot be written to, the config is left exactly as it was and the reason appears as a message in the top right corner of the screen.
diff --git a/docs/docs/widget-editor.md b/docs/docs/widget-editor.md
new file mode 100644
index 00000000..0cd13d93
--- /dev/null
+++ b/docs/docs/widget-editor.md
@@ -0,0 +1,204 @@
+# Widget Editor
+
+The widget editor is a fullscreen visual builder for `custom-api` widgets. You point it at an API, drag blocks onto a canvas, bind them to fields from the response and it writes the `template` for you.
+
+
+
+It is an addition to the template textarea, not a replacement for it. Hand written templates keep working exactly as before, and everything on this page ends up as a normal `template` in your config.
+
+## Example widget made only with the editor
+
+
+
+## Opening the editor
+
+1. Open the page editor with the pencil icon in the header.
+2. Edit an existing `custom-api` widget, or add a new one.
+3. In the widget options modal, click **Open Editor** at the bottom left.
+
+> [!NOTE]
+>
+> The editor needs a viewport of at least 1024px wide.
+
+## Layout
+
+The editor takes over the whole screen and is split into four areas:
+
+| Area | Contents |
+| ---- | -------- |
+| Top bar | The request url, **Preview**, **Refresh data**, a status line, **Cancel** and **Apply and save** |
+| Left pane | The subrequests, the block palette, and the list of fields found in the API response with a sample value for each |
+| Middle | A dotted canvas holding a preview of the widget |
+| Right | The inspector for the currently selected block |
+
+**Apply and save** writes the template and saves the widget in one step. **Cancel** leaves the saved widget exactly as it is.
+
+## Fetching data
+
+The editor fetches the url once when it opens, and again every time you press **Refresh data**.
+
+`${VARIABLE}` references in the url and in headers are expanded the same way they are in the config file, so widgets that depend on a token or a secret work in the editor too.
+
+The field paths listed in the left pane are [gjson](https://github.com/tidwall/gjson) paths, the same syntax you would use in a hand written template.
+
+## Blocks
+
+Blocks are the building pieces of the widget:
+
+- **Stat** - a value with an optional label under it, and an optional icon. Leave the label blank to render only the value.
+- **Text** - static text, with an optional icon.
+- **Row list** - repeats a row of columns for every item in a JSON array.
+- **Icon** - an icon on its own, using the same icon syntax as the rest of Dynacat (`si:`, `sh:`, `mdi:` or a url). See [Icons](configuration.md#icons).
+
+Blocks live in rows. A row holds up to four blocks side by side and they share the width evenly, except icons which stay at their natural size. A row list always takes a row of its own. Hovering a row reveals controls to move it up or down and to remove it.
+
+To place a block, drag it from the palette onto a row, onto an existing block, or onto the empty canvas.
+
+Anywhere an icon can be set, stats, text blocks and row list columns alike, **Icon position** puts it before or after the value.
+
+## Binding a field
+
+Select a block, then click a field in the left pane. You can also drag a field from the left pane straight onto a block.
+
+### Value options
+
+These options are available per stat, and per column of a row list.
+
+
+
+#### Math
+
+Add, subtract, multiply or divide the value by a constant.
+
+#### Format
+
+| Format | Result |
+| ------ | ------ |
+| Raw value | The value as it came from the API |
+| Number | Thousands separators, e.g. `1,000` |
+| Short number | e.g. `1k`, `2m` |
+| Bytes | `KB`, `MB`, `GB` |
+| Fixed decimals | A fixed number of decimal places |
+| Percent | The value as a percentage |
+| Percent change | The change against another field |
+| Date | A formatted date |
+| Relative time | e.g. `2h`, `1d`, and keeps counting on the page |
+
+The bytes format uses the `formatBytes` template function, which is also available to hand written `custom-api` templates.
+
+#### Colors
+
+Pick one of the theme colors: highlight, primary, positive, negative, subdue, paragraph or base. Raw hex values are never used, so your widget follows whichever theme is active.
+
+#### Color by a rule
+
+Tick **Color by a rule** to pick the color from the value itself. Numeric values compare with greater than, less than, at least, at most or equal to. Everything else, including `true` and `false`, compares with is or is not. Set the value to compare with, then the color to use when it matches and the one to use when it does not. This is how you get a green value when something is up and a red one when it is down.
+
+
+
+#### Show only when it matches
+
+Tick **Show only when it matches** to render the stat or column only when its field passes a comparison. The same comparisons are available as for rules, so a field can disappear when it is empty, or a row can only show a value when a flag is `true`.
+
+## Row lists
+
+A row list repeats a row of columns for every item in an array.
+
+1. Pick the array to repeat over. Only array fields are offered here.
+2. Once it is bound, the left pane shows a group named `Fields in each row of `, listing the fields of the first item. Those are the fields the columns bind to.
+3. Set **Rows to show** to choose how many rows are visible. The rest stay behind a show more control in the rendered widget rather than being dropped.
+
+
+
+Each column can have an optional label and an optional icon. As soon as any column has a label, a header line is rendered above the rows. Columns share the row width evenly so the values line up under their labels, with the first column aligned left and the last one right. **Text alignment** overrides that per column. **Long values** decides what happens when a value does not fit: `Crop to one line` keeps every row one line high and ends the value with an ellipsis, `Wrap over two lines` lets it wrap, breaking words where it has to and stopping after two lines so one long value cannot stretch the widget.
+
+Every column gets its own card in the right pane. Click a card title to collapse or expand it, and click a value in the preview to have its card scrolled into view. A collapsed card opens while its value is the one you picked and folds back once you pick another, so the state you set is never lost.
+
+Use **Sort rows by** to sort, together with a direction. The comparison is picked from that column's format, so numbers sort numerically, dates sort chronologically and everything else sorts as text.
+
+### Icon columns
+
+**+ Icon column** adds a column that shows an icon chosen by the value of a field, which is how you get a check for `true` and a cross for `false`:
+
+1. Give the column a label if you want one above it in the header line. The column keeps the width of its icon either way, so the label hangs over the space to its left rather than widening the column, and nothing else moves. Keep it short, a long label reaches into the label next to it.
+2. Click the field the column should read.
+3. Pick the comparison and the value to compare with, for example `is` and `true`.
+4. Set the icon shown when it matches, and optionally the one shown when it does not. Leaving the second icon blank hides the icon in that case while the column keeps its width, so the rows stay aligned.
+
+
+
+A row list is removed by removing its row, it has no delete control of its own.
+
+## Subrequests
+
+**Subrequests** sits at the top of the left pane and holds named extra requests, each with a name and a url. Their fields then appear as their own group in the same pane and can be bound like any other field.
+
+In the generated template they are reached with `.Subrequest "name"`.
+
+## Preview
+
+The **Preview** toggle renders the template on the server using the real API data and shows the actual widget, so you can check the result before saving. It stays live: keep editing in the inspector and the preview re-renders on its own. Toggle it off to go back to the editable canvas.
+
+
+
+## Saving and validation
+
+**Apply and save** runs three steps:
+
+1. It checks the blocks, for example a stat with no field bound, or a row list with no array selected.
+2. It compiles and renders the template on the server.
+3. Only if both pass does it write the `template` into the widget and save the config.
+
+Any template error is shown in the status line and the editor stays open, so nothing is lost.
+
+## The builder key
+
+Saving also writes a `builder:` key on the widget, a compact JSON snapshot of your layout so that reopening the editor restores your blocks. It is ignored when the widget renders.
+
+Deleting it by hand only loses the visual layout, the template keeps working.
+
+> [!IMPORTANT]
+>
+> If you hand edit a template that the editor produced, reopening the editor regenerates the template from the stored layout and overwrites your edits. Pick one approach per widget.
+
+## Opening the editor on a hand written template
+
+If a widget has a `template` but no `builder` key, the editor starts empty and warns you that applying will replace the template. Cancelling leaves the widget untouched.
+
+## Example
+
+Given this response:
+
+```json
+{
+ "total_count": 1284,
+ "items": [
+ { "name": "dynacat" },
+ { "name": "dynawidgets" }
+ ]
+}
+```
+
+A widget with one stat bound to `total_count` (labelled "Repos", number format, highlight color) and a row list over `items` showing 5 rows with one column bound to `name` produces:
+
+```html
+
+
+
+
+
{{ formatNumber ($.JSON.Int "total_count") }}
+
Repos
+
+
+
+
+ {{ range $item := $.JSON.Array "items" }}
+
+
{{ $item.String "name" }}
+
+ {{ end }}
+
+
+```
+
+This is a normal `custom-api` template. If you ever remove the `builder` key, it keeps rendering as it is and you can carry on editing it by hand.
diff --git a/go.mod b/go.mod
index c0a1045b..1935377a 100644
--- a/go.mod
+++ b/go.mod
@@ -1,48 +1,43 @@
module github.com/Panonim/dynacat
-go 1.25.0
+go 1.26.0
require (
- github.com/coreos/go-oidc/v3 v3.18.0
+ github.com/coreos/go-oidc/v3 v3.21.0
github.com/fsnotify/fsnotify v1.10.1
- github.com/mmcdole/gofeed v1.3.0
+ github.com/mmcdole/gofeed v1.4.2
github.com/refraction-networking/utls v1.8.2
- github.com/shirou/gopsutil/v4 v4.26.5
+ github.com/shirou/gopsutil/v4 v4.26.8
github.com/tidwall/gjson v1.19.0
- golang.org/x/crypto v0.52.0
- golang.org/x/net v0.55.0
- golang.org/x/oauth2 v0.36.0
- golang.org/x/text v0.37.0
+ golang.org/x/crypto v0.57.0
+ golang.org/x/net v0.59.0
+ golang.org/x/oauth2 v0.37.0
+ golang.org/x/text v0.42.0
gopkg.in/yaml.v3 v3.0.1
- modernc.org/sqlite v1.51.0
+ modernc.org/sqlite v1.58.0
)
require (
- github.com/PuerkitoBio/goquery v1.11.0 // indirect
- github.com/andybalholm/brotli v1.0.6 // indirect
- github.com/andybalholm/cascadia v1.3.3 // indirect
+ github.com/andybalholm/brotli v1.2.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/ebitengine/purego v0.10.0 // indirect
- github.com/go-jose/go-jose/v4 v4.1.4 // indirect
+ github.com/ebitengine/purego v0.11.0 // indirect
+ github.com/go-jose/go-jose/v4 v4.1.5 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/json-iterator/go v1.1.12 // indirect
- github.com/klauspost/compress v1.17.4 // indirect
- github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/mmcdole/goxpp v1.1.1 // indirect
- github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
- github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/klauspost/compress v1.20.0 // indirect
+ github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
+ github.com/mattn/go-isatty v0.0.24 // indirect
+ github.com/mmcdole/goxpp/v2 v2.0.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
- github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
+ github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
- github.com/tklauser/go-sysconf v0.3.16 // indirect
- github.com/tklauser/numcpus v0.11.0 // indirect
+ github.com/tklauser/go-sysconf v0.4.0 // indirect
+ github.com/tklauser/numcpus v0.12.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
- golang.org/x/sys v0.45.0 // indirect
- modernc.org/libc v1.72.3 // indirect
+ golang.org/x/sys v0.48.0 // indirect
+ modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
- modernc.org/memory v1.11.0 // indirect
+ modernc.org/memory v1.12.1 // indirect
)
diff --git a/go.sum b/go.sum
index edbf99af..75951cb7 100644
--- a/go.sum
+++ b/go.sum
@@ -1,66 +1,50 @@
-github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
-github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
-github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
-github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
-github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
-github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
-github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
-github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/andybalholm/brotli v1.2.4 h1:649LN6qF+/7JJYNN3Wdu4Dt5PS6eC/yP/Go3Bb0/AHI=
+github.com/andybalholm/brotli v1.2.4/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
+github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
-github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
+github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
-github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
-github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
+github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
-github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
-github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
-github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
-github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
-github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
-github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
-github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
-github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
-github.com/mmcdole/goxpp v1.1.1/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
-github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
+github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
+github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
+github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/mmcdole/gofeed v1.4.2 h1:XFFOtsNNZg+zudjtMXb8BI0J/YdnSIGfjEpPgPnZib0=
+github.com/mmcdole/gofeed v1.4.2/go.mod h1:X5x1PyeibJi152VEya0AsV+PW4daYmCD4LJaJbeFkcs=
+github.com/mmcdole/goxpp/v2 v2.0.0 h1:HrSCflxerUEqZQNq3u7ldtmE/XkwnTx4Zpq2DW4i5rQ=
+github.com/mmcdole/goxpp/v2 v2.0.0/go.mod h1:CUduYMnO9JB6Z/uqDn9Ormk/r8E9BsLQxHPWDZ961Os=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
-github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
+github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
-github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
+github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
@@ -69,124 +53,61 @@ github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
-github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
-github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
-github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
-github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
+github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
+github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
+github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
-golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
-golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
-golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
-golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
-golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
-golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
-golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
-golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
-golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
-golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
-golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
+golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
+golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
+golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
+golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
+golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
+golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
+golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
-golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
-golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
-golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
-golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
-golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
-golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
-golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
+golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
+golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
-modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
-modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
-modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
+modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
+modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
+modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
-modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
-modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
+modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
-modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
-modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
+modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
+modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
-modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
-modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
-modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
+modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0=
+modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
diff --git a/internal/dynacat/api.go b/internal/dynacat/api.go
new file mode 100644
index 00000000..e22f9996
--- /dev/null
+++ b/internal/dynacat/api.go
@@ -0,0 +1,598 @@
+package dynacat
+
+import (
+ "context"
+ "crypto/subtle"
+ "encoding"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "reflect"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+const apiTokenHeader = "X-API-Token"
+
+const apiDefaultRateLimit = 5
+const apiRateLimitWindow = time.Minute
+
+// Guards against runaway recursion and self-referencing structures.
+const apiMaxWalkDepth = 12
+
+// Field names that never get serialized. The primary protection is that only fields tagged
+// yaml:"-" are exposed (see apiStrictData), so user config never leaves the process. This is
+// the second line of defence for a widget that stores a credential in a data field.
+var apiSecretNameParts = []string{"token", "password", "passwd", "secret", "credential", "bearer", "apikey"}
+var apiSecretNames = []string{"key", "auth", "cookie", "authorization"}
+
+type apiWidgetView struct {
+ APIID string `json:"api-id,omitempty"`
+ Type string `json:"type"`
+ Title string `json:"title,omitempty"`
+ Error string `json:"error,omitempty"`
+ Data map[string]any `json:"data,omitempty"`
+ Widgets []apiWidgetView `json:"widgets,omitempty"`
+}
+
+type apiPageView struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Widgets []apiWidgetView `json:"widgets"`
+}
+
+type apiRateWindow struct {
+ requests int
+ first time.Time
+}
+
+func (a *application) apiRateLimit() int {
+ if a.Config.API.RateLimit == nil {
+ return apiDefaultRateLimit
+ }
+
+ return *a.Config.API.RateLimit
+}
+
+// Fixed window per IP. Reports whether the caller is over its allowance and how long
+// until the current window ends.
+func (a *application) apiExceededRateLimit(ip string) (bool, int) {
+ limit := a.apiRateLimit()
+ if limit <= 0 {
+ return false, 0
+ }
+
+ a.apiRateMu.Lock()
+ defer a.apiRateMu.Unlock()
+
+ now := time.Now()
+ window, exists := a.apiRateRequests[ip]
+
+ if !exists || now.Sub(window.first) >= apiRateLimitWindow {
+ a.apiRateRequests[ip] = &apiRateWindow{requests: 1, first: now}
+
+ for other := range a.apiRateRequests {
+ if now.Sub(a.apiRateRequests[other].first) >= apiRateLimitWindow {
+ delete(a.apiRateRequests, other)
+ }
+ }
+
+ return false, 0
+ }
+
+ window.requests++
+ if window.requests > limit {
+ return true, max(1, int((apiRateLimitWindow - now.Sub(window.first)).Seconds()))
+ }
+
+ return false, 0
+}
+
+// Runs before authentication so that guessing credentials is throttled too.
+func (a *application) handleAPIRateLimit(w http.ResponseWriter, r *http.Request) bool {
+ exceeded, retryAfter := a.apiExceededRateLimit(a.addressOfRequest(r))
+ if !exceeded {
+ return false
+ }
+
+ w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
+ writeJSONError(w, http.StatusTooManyRequests, "rate limit exceeded")
+
+ return true
+}
+
+func (a *application) apiEnforcesPagePermissions() bool {
+ return a.Config.API.EnforcePagePermissions == nil || *a.Config.API.EnforcePagePermissions
+}
+
+// Survives config reloads, which rebuild the application, so a warning is only repeated
+// when the setting behind it goes away and comes back.
+var apiWarnedAbout struct {
+ mu sync.Mutex
+ missingToken bool
+ permissionsDisabled bool
+}
+
+// Prints straight to stderr instead of slog because LOG_LEVEL can be set to ERROR,
+// which would hide these.
+func (a *application) warnAboutAPIExposure() {
+ enabled := a.Config.API.Enabled
+
+ apiWarnedAbout.mu.Lock()
+ defer apiWarnedAbout.mu.Unlock()
+
+ warnOnce(
+ enabled && a.Config.API.Token == "",
+ &apiWarnedAbout.missingToken,
+ "The API is enabled without a token set. Anyone who can reach this server can read your widget data.",
+ )
+
+ warnOnce(
+ enabled && !a.apiEnforcesPagePermissions(),
+ &apiWarnedAbout.permissionsDisabled,
+ "The API has enforce-page-permissions set to false. Pages restricted with allowed-users or allowed-groups are readable without logging in.",
+ )
+}
+
+func warnOnce(condition bool, alreadyWarned *bool, message string) {
+ if condition && !*alreadyWarned {
+ printUnsuppressableWarning(message)
+ }
+
+ *alreadyWarned = condition
+}
+
+func (a *application) handleAPIPreflight(w http.ResponseWriter, r *http.Request) {
+ a.apiCORS(w, r)
+ w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", apiTokenHeader+", Authorization")
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (a *application) apiCORS(w http.ResponseWriter, r *http.Request) {
+ origins := a.Config.API.AllowedOrigins
+ if len(origins) == 0 {
+ return
+ }
+
+ if slices.Contains(origins, "*") {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ return
+ }
+
+ if origin := r.Header.Get("Origin"); origin != "" && slices.Contains(origins, origin) {
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ w.Header().Add("Vary", "Origin")
+ }
+}
+
+// Resolves the caller: dashboard session, then HTTP basic against the configured users,
+// then the shared API token. Returns handled=true when it has already written a response.
+func (a *application) apiAuthenticate(w http.ResponseWriter, r *http.Request) (*authenticatedUser, bool) {
+ if user := a.getAuthenticatedUser(w, r); user != nil {
+ return user, false
+ }
+
+ if username, password, ok := r.BasicAuth(); ok && a.PasswordEnabled {
+ ip := a.addressOfRequest(r)
+
+ if limited, retryAfter := a.checkAuthRateLimit(ip); limited {
+ w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
+ writeJSONError(w, http.StatusTooManyRequests, "too many attempts")
+ return nil, true
+ }
+
+ if user := a.verifyUserPassword(username, password); user != nil {
+ a.clearAuthRateLimit(ip)
+ return user, false
+ }
+
+ slog.Warn("Failed API login attempt", "username", strconv.Quote(username), "ip", ip)
+ writeJSONError(w, http.StatusUnauthorized, "invalid credentials")
+ return nil, true
+ }
+
+ token := a.Config.API.Token
+ if token == "" {
+ return nil, false
+ }
+
+ provided := r.Header.Get(apiTokenHeader)
+ if subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1 {
+ return nil, false
+ }
+
+ writeJSONError(w, http.StatusUnauthorized, "invalid or missing "+apiTokenHeader)
+ return nil, true
+}
+
+// Whether the page is within the allow-list. A page outside it is treated as non-existent.
+func (a *application) apiPageExposed(p *page) bool {
+ allowed := a.Config.API.AllowedPages
+ return len(allowed) == 0 || slices.Contains(allowed, p.Slug)
+}
+
+func (a *application) apiCanReadPage(user *authenticatedUser, p *page) bool {
+ if len(p.AllowedUsers) == 0 && len(p.AllowedGroups) == 0 {
+ return true
+ }
+
+ if !a.apiEnforcesPagePermissions() {
+ return true
+ }
+
+ return user != nil && a.isUserAllowedOnPage(user, p)
+}
+
+func (a *application) handleAPIPages(w http.ResponseWriter, r *http.Request) {
+ a.apiCORS(w, r)
+
+ if a.handleAPIRateLimit(w, r) {
+ return
+ }
+
+ user, handled := a.apiAuthenticate(w, r)
+ if handled {
+ return
+ }
+
+ views := make([]apiPageView, 0, len(a.Config.Pages))
+
+ for i := range a.Config.Pages {
+ p := &a.Config.Pages[i]
+
+ if !a.apiPageExposed(p) || !a.apiCanReadPage(user, p) {
+ continue
+ }
+
+ // Widget titles and errors are written by background updates that hold this lock.
+ p.mu.Lock()
+ views = append(views, apiPageView{Slug: p.Slug, Name: p.Title, Widgets: apiPageWidgetViews(p, false)})
+ p.mu.Unlock()
+ }
+
+ writeJSON(w, http.StatusOK, views)
+}
+
+func (a *application) handleAPIPage(w http.ResponseWriter, r *http.Request) {
+ a.apiCORS(w, r)
+
+ if a.handleAPIRateLimit(w, r) {
+ return
+ }
+
+ user, handled := a.apiAuthenticate(w, r)
+ if handled {
+ return
+ }
+
+ p, exists := a.slugToPage[r.PathValue("page")]
+ if !exists || !a.apiPageExposed(p) {
+ writeJSONError(w, http.StatusNotFound, "page not found")
+ return
+ }
+
+ if !a.apiCanReadPage(user, p) {
+ a.writeAPIForbidden(w, user)
+ return
+ }
+
+ var view apiPageView
+
+ func() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ p.updateOutdatedWidgets()
+ view = apiPageView{Slug: p.Slug, Name: p.Title, Widgets: apiPageWidgetViews(p, true)}
+ }()
+
+ writeJSON(w, http.StatusOK, view)
+}
+
+func (a *application) handleAPIWidget(w http.ResponseWriter, r *http.Request) {
+ a.apiCORS(w, r)
+
+ if a.handleAPIRateLimit(w, r) {
+ return
+ }
+
+ user, handled := a.apiAuthenticate(w, r)
+ if handled {
+ return
+ }
+
+ widget, exists := a.widgetByAPIID[r.PathValue("apiID")]
+ if !exists {
+ writeJSONError(w, http.StatusNotFound, "widget not found")
+ return
+ }
+
+ p, exists := a.widgetToPage[widget.GetID()]
+ if !exists || !a.apiPageExposed(p) {
+ writeJSONError(w, http.StatusNotFound, "widget not found")
+ return
+ }
+
+ if !a.apiCanReadPage(user, p) {
+ a.writeAPIForbidden(w, user)
+ return
+ }
+
+ var view apiWidgetView
+
+ func() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ // Deliberately not a forced update: refreshing only when the cache is stale keeps the
+ // widget inside the shared fetch window, so widgets pointing at the same upstream
+ // still share a single request.
+ now := time.Now()
+ if widget.requiresUpdate(&now) || widget.IsLazyLoad() {
+ widget.update(withSharedFetchMaxAge(context.Background(), widget.getCacheDuration()))
+ }
+
+ view = newAPIWidgetView(widget, true)
+ }()
+
+ writeJSON(w, http.StatusOK, view)
+}
+
+func (a *application) writeAPIForbidden(w http.ResponseWriter, user *authenticatedUser) {
+ if user == nil {
+ writeJSONError(w, http.StatusUnauthorized, "this page requires an authenticated user")
+ return
+ }
+
+ writeJSONError(w, http.StatusForbidden, "user is not allowed on this page")
+}
+
+func apiPageWidgetViews(p *page, includeData bool) []apiWidgetView {
+ views := make([]apiWidgetView, 0, len(p.HeadWidgets))
+
+ for _, w := range p.HeadWidgets {
+ views = append(views, newAPIWidgetView(w, includeData))
+ }
+
+ for c := range p.Columns {
+ for _, w := range p.Columns[c].Widgets {
+ views = append(views, newAPIWidgetView(w, includeData))
+ }
+ }
+
+ return views
+}
+
+func newAPIWidgetView(w widget, includeData bool) apiWidgetView {
+ view := apiWidgetView{APIID: w.GetAPIID(), Type: w.GetType(), Title: w.GetTitle()}
+
+ if err := w.GetError(); err != nil {
+ view.Error = err.Error()
+ }
+
+ // Container widgets keep their children in an unexported embedded struct, so they are
+ // listed explicitly rather than picked up by the reflection walk.
+ switch v := w.(type) {
+ case *groupWidget:
+ view.Widgets = apiChildWidgetViews(v.Widgets, includeData)
+ return view
+ case *splitColumnWidget:
+ view.Widgets = apiChildWidgetViews(v.Widgets, includeData)
+ return view
+ }
+
+ if includeData {
+ view.Data = apiStrictData(reflect.ValueOf(w), 0)
+ }
+
+ return view
+}
+
+func apiChildWidgetViews(ws widgets, includeData bool) []apiWidgetView {
+ views := make([]apiWidgetView, 0, len(ws))
+
+ for _, w := range ws {
+ views = append(views, newAPIWidgetView(w, includeData))
+ }
+
+ return views
+}
+
+// Walks a widget struct, emitting only fields that carry no yaml key of their own, since that
+// is where widgets store what they fetched. Fields with a real yaml key are user config and are
+// only descended into, never emitted, so tokens and passwords cannot escape.
+func apiStrictData(v reflect.Value, depth int) map[string]any {
+ v = apiDeref(v)
+ if depth > apiMaxWalkDepth || v.Kind() != reflect.Struct {
+ return nil
+ }
+
+ out := make(map[string]any)
+ t := v.Type()
+
+ for i := 0; i < t.NumField(); i++ {
+ field := t.Field(i)
+ if !field.IsExported() || apiIsSecretName(field.Name) || apiIsInfrastructureType(field.Type) {
+ continue
+ }
+
+ value := v.Field(i)
+ tag := field.Tag.Get("yaml")
+
+ if field.Anonymous && (tag == "" || strings.Contains(tag, ",inline")) {
+ for k, nested := range apiStrictData(value, depth+1) {
+ out[k] = nested
+ }
+ continue
+ }
+
+ if name := strings.Split(tag, ",")[0]; name == "-" || name == "" {
+ if converted := apiOpenValue(value, depth+1); converted != nil {
+ out[field.Name] = converted
+ }
+ continue
+ }
+
+ if nested := apiStrictValue(value, depth+1); nested != nil {
+ out[field.Name] = nested
+ }
+ }
+
+ if len(out) == 0 {
+ return nil
+ }
+
+ return out
+}
+
+// Descends through config-tagged fields looking for nested data, e.g. the per-server
+// results the server-stats widget stores inside its configured `servers` list.
+func apiStrictValue(v reflect.Value, depth int) any {
+ v = apiDeref(v)
+ if depth > apiMaxWalkDepth || !v.IsValid() {
+ return nil
+ }
+
+ switch v.Kind() {
+ case reflect.Struct:
+ if data := apiStrictData(v, depth); data != nil {
+ return data
+ }
+ case reflect.Slice, reflect.Array:
+ items := make([]any, 0, v.Len())
+ found := false
+
+ for i := 0; i < v.Len(); i++ {
+ item := apiStrictValue(v.Index(i), depth+1)
+ items = append(items, item)
+ found = found || item != nil
+ }
+
+ if found {
+ return items
+ }
+ }
+
+ return nil
+}
+
+// Converts a value that already sits inside a data field, so upstream payloads survive intact.
+func apiOpenValue(v reflect.Value, depth int) any {
+ v = apiDeref(v)
+ if depth > apiMaxWalkDepth || !v.IsValid() || !v.CanInterface() {
+ return nil
+ }
+
+ if apiIsInfrastructureType(v.Type()) {
+ return nil
+ }
+
+ // Types with their own JSON or text representation (json.RawMessage, time.Time, ...)
+ // must not be taken apart field by field.
+ if apiHasOwnEncoding(v) {
+ return v.Interface()
+ }
+
+ switch v.Kind() {
+ case reflect.String:
+ return redactSecretQueryParams(v.String())
+ case reflect.Struct:
+ // Nested structs go back through the same config-versus-data split, so a widget
+ // cannot expose config by reaching it through one of its data fields.
+ if data := apiStrictData(v, depth); data != nil {
+ return data
+ }
+
+ return nil
+ case reflect.Slice, reflect.Array:
+ if v.Kind() == reflect.Slice && v.Type().Elem().Kind() == reflect.Uint8 {
+ return v.Interface()
+ }
+
+ items := make([]any, 0, v.Len())
+ for i := 0; i < v.Len(); i++ {
+ items = append(items, apiOpenValue(v.Index(i), depth+1))
+ }
+
+ return items
+ case reflect.Map:
+ if v.Type().Key().Kind() != reflect.String {
+ return nil
+ }
+
+ out := make(map[string]any, v.Len())
+ for _, key := range v.MapKeys() {
+ if apiIsSecretName(key.String()) {
+ continue
+ }
+
+ out[key.String()] = apiOpenValue(v.MapIndex(key), depth+1)
+ }
+
+ return out
+ case reflect.Chan, reflect.Func, reflect.UnsafePointer:
+ return nil
+ }
+
+ return v.Interface()
+}
+
+func apiDeref(v reflect.Value) reflect.Value {
+ for v.IsValid() && (v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface) {
+ if v.IsNil() {
+ return reflect.Value{}
+ }
+ v = v.Elem()
+ }
+
+ return v
+}
+
+func apiHasOwnEncoding(v reflect.Value) bool {
+ if !v.CanInterface() {
+ return false
+ }
+
+ switch v.Interface().(type) {
+ case json.Marshaler, encoding.TextMarshaler:
+ return true
+ }
+
+ if v.CanAddr() {
+ switch v.Addr().Interface().(type) {
+ case json.Marshaler, encoding.TextMarshaler:
+ return true
+ }
+ }
+
+ return false
+}
+
+// Widget internals and HTTP plumbing are never worth serializing and can be cyclic.
+func apiIsInfrastructureType(t reflect.Type) bool {
+ for t.Kind() == reflect.Pointer {
+ t = t.Elem()
+ }
+
+ return t == reflect.TypeOf(widgetProviders{}) || t.PkgPath() == "net/http"
+}
+
+func apiIsSecretName(name string) bool {
+ lowered := strings.ToLower(name)
+
+ if slices.Contains(apiSecretNames, lowered) {
+ return true
+ }
+
+ for _, part := range apiSecretNameParts {
+ if strings.Contains(lowered, part) {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/internal/dynacat/auth.go b/internal/dynacat/auth.go
index e007964e..27c53b7b 100644
--- a/internal/dynacat/auth.go
+++ b/internal/dynacat/auth.go
@@ -27,13 +27,11 @@ const AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5
const AUTH_TOKEN_SECRET_LENGTH = 32
const AUTH_USERNAME_HASH_LENGTH = 32
const AUTH_SECRET_KEY_LENGTH = AUTH_TOKEN_SECRET_LENGTH + AUTH_USERNAME_HASH_LENGTH
-const AUTH_TIMESTAMP_LENGTH = 4 // uint32
+const AUTH_TIMESTAMP_LENGTH = 8
const AUTH_TOKEN_DATA_LENGTH = AUTH_USERNAME_HASH_LENGTH + AUTH_TIMESTAMP_LENGTH
-// How long the token will be valid for
-const AUTH_TOKEN_VALID_PERIOD = 14 * 24 * time.Hour // 14 days
-// How long the token has left before it should be regenerated
-const AUTH_TOKEN_REGEN_BEFORE = 7 * 24 * time.Hour // 7 days
+const AUTH_TOKEN_VALID_PERIOD = 14 * 24 * time.Hour
+const AUTH_TOKEN_REGEN_BEFORE = 7 * 24 * time.Hour
var loginPageTemplate = mustParseTemplate("login.html", "document.html", "footer.html")
@@ -55,38 +53,39 @@ type failedAuthAttempt struct {
first time.Time
}
-func generateSessionToken(username string, secret []byte, now time.Time) (string, error) {
+func generateSessionToken(usernameHash []byte, secret []byte, now time.Time) (string, error) {
if len(secret) != AUTH_SECRET_KEY_LENGTH {
return "", fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
}
- usernameHash, err := computeUsernameHash(username, secret)
- if err != nil {
- return "", err
+ if len(usernameHash) != AUTH_USERNAME_HASH_LENGTH {
+ return "", fmt.Errorf("username hash length is not %d bytes", AUTH_USERNAME_HASH_LENGTH)
}
data := make([]byte, AUTH_TOKEN_DATA_LENGTH)
copy(data, usernameHash)
expires := now.Add(AUTH_TOKEN_VALID_PERIOD).Unix()
- binary.LittleEndian.PutUint32(data[AUTH_USERNAME_HASH_LENGTH:], uint32(expires))
+ binary.LittleEndian.PutUint64(data[AUTH_USERNAME_HASH_LENGTH:], uint64(expires))
h := hmac.New(sha256.New, secret[0:AUTH_TOKEN_SECRET_LENGTH])
h.Write(data)
signature := h.Sum(nil)
encodedToken := base64.StdEncoding.EncodeToString(append(data, signature...))
- // encodedToken ends up being (hashed username + expiration timestamp + signature) encoded as base64
return encodedToken, nil
}
-func computeUsernameHash(username string, secret []byte) ([]byte, error) {
+// Mixing in the credential invalidates old tokens whenever the password changes.
+func computeUsernameHash(username string, credential string, secret []byte) ([]byte, error) {
if len(secret) != AUTH_SECRET_KEY_LENGTH {
return nil, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
}
h := hmac.New(sha256.New, secret[AUTH_TOKEN_SECRET_LENGTH:])
h.Write([]byte(username))
+ h.Write([]byte{0})
+ h.Write([]byte(credential))
return h.Sum(nil), nil
}
@@ -117,13 +116,12 @@ func verifySessionToken(token string, secretBytes []byte, now time.Time) ([]byte
return nil, false, fmt.Errorf("signature does not match")
}
- expiresTimestamp := int64(binary.LittleEndian.Uint32(timestampBytes))
+ expiresTimestamp := int64(binary.LittleEndian.Uint64(timestampBytes))
if now.Unix() > expiresTimestamp {
return nil, false, fmt.Errorf("token has expired")
}
return usernameHashBytes,
- // True if the token should be regenerated
time.Unix(expiresTimestamp, 0).Add(-AUTH_TOKEN_REGEN_BEFORE).Before(now),
nil
}
@@ -147,41 +145,11 @@ func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http
ip := a.addressOfRequest(r)
- a.authAttemptsMu.Lock()
- exceededRateLimit, retryAfter := func() (bool, int) {
- attempt, exists := a.failedAuthAttempts[ip]
- if !exists {
- a.failedAuthAttempts[ip] = &failedAuthAttempt{
- attempts: 1,
- first: time.Now(),
- }
-
- return false, 0
- }
-
- elapsed := time.Since(attempt.first)
- if elapsed < AUTH_RATE_LIMIT_WINDOW && attempt.attempts >= AUTH_RATE_LIMIT_MAX_ATTEMPTS {
- return true, max(1, int(AUTH_RATE_LIMIT_WINDOW.Seconds()-elapsed.Seconds()))
- }
-
- attempt.attempts++
- return false, 0
- }()
-
- if exceededRateLimit {
- a.authAttemptsMu.Unlock()
+ if exceededRateLimit, retryAfter := a.checkAuthRateLimit(ip); exceededRateLimit {
time.Sleep(waitOnFailure)
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
w.WriteHeader(http.StatusTooManyRequests)
return
- } else {
- // Clean up old failed attempts
- for ipOfAttempt := range a.failedAuthAttempts {
- if time.Since(a.failedAuthAttempts[ipOfAttempt].first) > AUTH_RATE_LIMIT_WINDOW {
- delete(a.failedAuthAttempts, ipOfAttempt)
- }
- }
- a.authAttemptsMu.Unlock()
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
@@ -203,7 +171,7 @@ func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http
}
logAuthFailure := func() {
- slog.Warn("Failed login attempt", "username", creds.Username, "ip", ip)
+ slog.Warn("Failed login attempt", "username", strconv.Quote(creds.Username), "ip", ip)
}
if len(creds.Username) == 0 || len(creds.Password) == 0 {
@@ -234,7 +202,7 @@ func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http
return
}
- token, err := generateSessionToken(creds.Username, a.authSecretKey, time.Now())
+ token, err := generateSessionToken(u.usernameHash, a.authSecretKey, time.Now())
if err != nil {
slog.Error("Could not compute session token during login attempt", "error", err)
time.Sleep(waitOnFailure)
@@ -244,9 +212,7 @@ func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http
a.setAuthSessionCookie(w, r, token, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
- a.authAttemptsMu.Lock()
- delete(a.failedAuthAttempts, ip)
- a.authAttemptsMu.Unlock()
+ a.clearAuthRateLimit(ip)
redirect := a.takeLoginRedirect(w, r)
w.Header().Set("Content-Type", "application/json")
@@ -254,8 +220,65 @@ func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http
json.NewEncoder(w).Encode(map[string]string{"redirect": redirect})
}
+// Counts a login attempt for the IP and reports whether it has run out of allowance.
+// Also prunes attempts that have aged out of the window.
+func (a *application) checkAuthRateLimit(ip string) (bool, int) {
+ a.authAttemptsMu.Lock()
+ defer a.authAttemptsMu.Unlock()
+
+ attempt, exists := a.failedAuthAttempts[ip]
+ if !exists {
+ a.failedAuthAttempts[ip] = &failedAuthAttempt{attempts: 1, first: time.Now()}
+ return false, 0
+ }
+
+ elapsed := time.Since(attempt.first)
+ if elapsed < AUTH_RATE_LIMIT_WINDOW && attempt.attempts >= AUTH_RATE_LIMIT_MAX_ATTEMPTS {
+ return true, max(1, int(AUTH_RATE_LIMIT_WINDOW.Seconds()-elapsed.Seconds()))
+ }
+
+ attempt.attempts++
+
+ for ipOfAttempt := range a.failedAuthAttempts {
+ if time.Since(a.failedAuthAttempts[ipOfAttempt].first) > AUTH_RATE_LIMIT_WINDOW {
+ delete(a.failedAuthAttempts, ipOfAttempt)
+ }
+ }
+
+ return false, 0
+}
+
+// Drops the IP's counter after a successful login so valid callers are never throttled.
+func (a *application) clearAuthRateLimit(ip string) {
+ a.authAttemptsMu.Lock()
+ delete(a.failedAuthAttempts, ip)
+ a.authAttemptsMu.Unlock()
+}
+
+// Verifies a username/password pair against the configured users. Password users have no
+// groups, those only ever come from OIDC claims.
+func (a *application) verifyUserPassword(username, password string) *authenticatedUser {
+ if !a.PasswordEnabled || username == "" || password == "" {
+ return nil
+ }
+
+ if len(username) > 50 || len(password) > 100 {
+ return nil
+ }
+
+ u, exists := a.Config.Auth.Users[username]
+ if !exists {
+ return nil
+ }
+
+ if bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(password)) != nil {
+ return nil
+ }
+
+ return &authenticatedUser{Username: username}
+}
+
func (a *application) getAuthenticatedUser(w http.ResponseWriter, r *http.Request) *authenticatedUser {
- // Check password session cookie
if a.PasswordEnabled && len(a.Config.Auth.Users) > 0 {
token, err := r.Cookie(AUTH_SESSION_COOKIE_NAME)
if err == nil && token.Value != "" {
@@ -263,9 +286,9 @@ func (a *application) getAuthenticatedUser(w http.ResponseWriter, r *http.Reques
if err == nil {
username, exists := a.usernameHashToUsername[string(usernameHash)]
if exists {
- if _, exists = a.Config.Auth.Users[username]; exists {
+ if u, exists := a.Config.Auth.Users[username]; exists {
if shouldRegenerate {
- newToken, err := generateSessionToken(username, a.authSecretKey, time.Now())
+ newToken, err := generateSessionToken(u.usernameHash, a.authSecretKey, time.Now())
if err != nil {
slog.Error("Could not compute session token during regeneration", "error", err)
} else {
@@ -279,13 +302,11 @@ func (a *application) getAuthenticatedUser(w http.ResponseWriter, r *http.Reques
}
}
- // Check OIDC session cookie
if a.OIDCEnabled && a.oidcSessions != nil {
sessionCookie, err := r.Cookie(OIDC_SESSION_COOKIE_NAME)
if err == nil && sessionCookie.Value != "" {
sess, ok := a.oidcSessions.get(sessionCookie.Value)
if ok {
- // Check session expiry
if time.Since(sess.CreatedAt) < OIDC_SESSION_VALID_PERIOD {
return &authenticatedUser{
Username: sess.Username,
@@ -293,7 +314,6 @@ func (a *application) getAuthenticatedUser(w http.ResponseWriter, r *http.Reques
IsOIDC: true,
}
}
- // Expired - clean up
a.oidcSessions.delete(sessionCookie.Value)
}
}
@@ -310,7 +330,6 @@ func (a *application) isAuthorized(w http.ResponseWriter, r *http.Request) bool
}
func (a *application) isUserAllowedOnPage(user *authenticatedUser, p *page) bool {
- // No restrictions = allowed for all authenticated users
if len(p.AllowedUsers) == 0 && len(p.AllowedGroups) == 0 {
return true
}
@@ -353,25 +372,11 @@ func (a *application) canUserAccessPage(user *authenticatedUser, p *page) bool {
func (a *application) handleAccessControl(w http.ResponseWriter, r *http.Request, p *page, fallback doWhenUnauthorized) bool {
user := a.getAuthenticatedUser(w, r)
- pageHasRestrictions := len(p.AllowedUsers) > 0 || len(p.AllowedGroups) > 0
- allowed := a.canUserAccessPage(user, p)
-
- if allowed {
+ if a.canUserAccessPage(user, p) {
return false
}
- if user == nil {
- switch fallback {
- case redirectToLogin:
- a.redirectToLoginPage(w, r)
- case showUnauthorizedJSON:
- w.WriteHeader(http.StatusUnauthorized)
- w.Write([]byte(`{"error": "Unauthorized"}`))
- }
- return true
- }
-
- if pageHasRestrictions {
+ if user != nil {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Forbidden"))
return true
@@ -384,11 +389,10 @@ func (a *application) handleAccessControl(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "Unauthorized"}`))
}
- return true
+ return true
}
-// Handles sending the appropriate response for an unauthorized request and returns true if the request was unauthorized
func (a *application) handleUnauthorizedResponse(w http.ResponseWriter, r *http.Request, fallback doWhenUnauthorized) bool {
if a.isAuthorized(w, r) {
return false
@@ -407,18 +411,12 @@ func (a *application) handleUnauthorizedResponse(w http.ResponseWriter, r *http.
const AUTH_REDIRECT_COOKIE_NAME = "dynacat_redirect"
-// isSafeLocalPath reports whether target is a same-origin path safe to use in a
-// redirect: it must be root-relative and must not be a protocol-relative
-// ("//host") or backslash ("/\host") URL a browser could resolve elsewhere.
func isSafeLocalPath(target string) bool {
return strings.HasPrefix(target, "/") &&
!strings.HasPrefix(target, "//") &&
!strings.HasPrefix(target, "/\\")
}
-// redirectToLoginPage remembers where an unauthenticated visitor was headed
-// (path and query) so they can be returned there after logging in, then sends
-// them to the login page.
func (a *application) redirectToLoginPage(w http.ResponseWriter, r *http.Request) {
if target := r.URL.RequestURI(); isSafeLocalPath(target) {
http.SetCookie(w, &http.Cookie{
@@ -434,8 +432,6 @@ func (a *application) redirectToLoginPage(w http.ResponseWriter, r *http.Request
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
}
-// takeLoginRedirect consumes and clears the post-login redirect cookie,
-// returning a safe destination to send the user to after a successful login.
func (a *application) takeLoginRedirect(w http.ResponseWriter, r *http.Request) string {
target := a.Config.Server.BaseURL + "/"
if c, err := r.Cookie(AUTH_REDIRECT_COOKIE_NAME); err == nil && isSafeLocalPath(c.Value) {
@@ -458,7 +454,6 @@ func (a *application) AnyAuthEnabled() bool {
func (a *application) handleLogoutRequest(w http.ResponseWriter, r *http.Request) {
a.setAuthSessionCookie(w, r, "", time.Now().Add(-1*time.Hour))
- // Clear OIDC session if present
if a.OIDCEnabled && a.oidcSessions != nil {
sessionCookie, err := r.Cookie(OIDC_SESSION_COOKIE_NAME)
if err == nil && sessionCookie.Value != "" {
diff --git a/internal/dynacat/auth_oidc.go b/internal/dynacat/auth_oidc.go
index a2b3dd9a..12569cdc 100644
--- a/internal/dynacat/auth_oidc.go
+++ b/internal/dynacat/auth_oidc.go
@@ -84,7 +84,6 @@ func (a *application) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
func (a *application) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
baseURL := a.Config.Server.BaseURL
- // Validate state
stateCookie, err := r.Cookie(OIDC_STATE_COOKIE_NAME)
if err != nil || stateCookie.Value == "" {
http.Redirect(w, r, baseURL+"/login?error=invalid_state", http.StatusSeeOther)
@@ -102,7 +101,6 @@ func (a *application) handleOIDCCallback(w http.ResponseWriter, r *http.Request)
return
}
- // Clear state + PKCE cookies
http.SetCookie(w, &http.Cookie{
Name: OIDC_STATE_COOKIE_NAME,
Value: "",
@@ -118,7 +116,6 @@ func (a *application) handleOIDCCallback(w http.ResponseWriter, r *http.Request)
HttpOnly: true,
})
- // Exchange code for token
code := r.URL.Query().Get("code")
if code == "" {
errParam := r.URL.Query().Get("error")
@@ -137,7 +134,6 @@ func (a *application) handleOIDCCallback(w http.ResponseWriter, r *http.Request)
return
}
- // Extract and verify ID token
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
http.Redirect(w, r, baseURL+"/login?error=no_id_token", http.StatusSeeOther)
@@ -151,7 +147,6 @@ func (a *application) handleOIDCCallback(w http.ResponseWriter, r *http.Request)
return
}
- // Extract claims
var claims map[string]interface{}
if err := idToken.Claims(&claims); err != nil {
slog.Error("OIDC could not extract claims", "error", err)
@@ -166,7 +161,6 @@ func (a *application) handleOIDCCallback(w http.ResponseWriter, r *http.Request)
username, _ := claims[usernameClaim].(string)
if username == "" {
- // Fallback to sub
username, _ = claims["sub"].(string)
}
if username == "" {
@@ -180,7 +174,6 @@ func (a *application) handleOIDCCallback(w http.ResponseWriter, r *http.Request)
}
groups := extractGroupsClaim(claims, groupsClaim)
- // Check OIDC-level allowed users/groups restrictions
oidcCfg := a.Config.Auth.OIDC
if len(oidcCfg.AllowedUsers) > 0 || len(oidcCfg.AllowedGroups) > 0 {
allowed := false
@@ -208,7 +201,6 @@ func (a *application) handleOIDCCallback(w http.ResponseWriter, r *http.Request)
}
}
- // Generate session ID and store session
sessionID, err := makeAuthSecretKey(32)
if err != nil {
slog.Error("OIDC could not generate session ID", "error", err)
@@ -223,13 +215,14 @@ func (a *application) handleOIDCCallback(w http.ResponseWriter, r *http.Request)
CreatedAt: time.Now(),
})
+ // Strict would not survive the redirect back from the provider.
http.SetCookie(w, &http.Cookie{
Name: OIDC_SESSION_COOKIE_NAME,
Value: sessionID,
Expires: time.Now().Add(OIDC_SESSION_VALID_PERIOD),
Secure: a.isRequestHTTPS(r),
Path: baseURL + "/",
- SameSite: http.SameSiteStrictMode,
+ SameSite: http.SameSiteLaxMode,
HttpOnly: true,
})
@@ -256,7 +249,6 @@ func extractGroupsClaim(claims map[string]interface{}, claimName string) []strin
if v == "" {
return nil
}
- // Try comma-separated first, then space-separated
if strings.Contains(v, ",") {
parts := strings.Split(v, ",")
for i := range parts {
diff --git a/internal/dynacat/auth_session.go b/internal/dynacat/auth_session.go
index deee9a25..d4326e70 100644
--- a/internal/dynacat/auth_session.go
+++ b/internal/dynacat/auth_session.go
@@ -19,7 +19,7 @@ type oidcSession struct {
}
type sessionStore struct {
- sessions sync.Map // map[sessionID]*oidcSession
+ sessions sync.Map
}
func newSessionStore() *sessionStore {
diff --git a/internal/dynacat/auth_test.go b/internal/dynacat/auth_test.go
index 6f34127e..670f94ca 100644
--- a/internal/dynacat/auth_test.go
+++ b/internal/dynacat/auth_test.go
@@ -25,7 +25,12 @@ func TestAuthTokenGenerationAndVerification(t *testing.T) {
now := time.Now()
username := "admin"
- token, err := generateSessionToken(username, secretBytes, now)
+ computedUsernameHash, err := computeUsernameHash(username, "hunter2hunter2", secretBytes)
+ if err != nil {
+ t.Fatalf("Failed to compute username hash: %v", err)
+ }
+
+ token, err := generateSessionToken(computedUsernameHash, secretBytes, now)
if err != nil {
t.Fatalf("Failed to generate session token: %v", err)
}
@@ -39,11 +44,6 @@ func TestAuthTokenGenerationAndVerification(t *testing.T) {
t.Fatal("Token should not need to be regenerated immediately after generation")
}
- computedUsernameHash, err := computeUsernameHash(username, secretBytes)
- if err != nil {
- t.Fatalf("Failed to compute username hash: %v", err)
- }
-
if !bytes.Equal(usernameHashBytes, computedUsernameHash) {
t.Fatal("Username hash does not match the expected value")
}
diff --git a/internal/dynacat/cli.go b/internal/dynacat/cli.go
index c6c1e7fb..91fa003f 100644
--- a/internal/dynacat/cli.go
+++ b/internal/dynacat/cli.go
@@ -69,35 +69,34 @@ func parseCliOptions() (*cliOptions, error) {
args = flags.Args()
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
- if len(args) == 0 {
+ switch len(args) {
+ case 0:
intent = cliIntentServe
- } else if len(args) == 1 {
- if args[0] == "config:validate" {
+ case 1:
+ switch args[0] {
+ case "config:validate":
intent = cliIntentConfigValidate
- } else if args[0] == "config:print" {
+ case "config:print":
intent = cliIntentConfigPrint
- } else if args[0] == "sensors:print" {
+ case "sensors:print":
intent = cliIntentSensorsPrint
- } else if args[0] == "diagnose" {
+ case "diagnose":
intent = cliIntentDiagnose
- } else if args[0] == "secret:make" {
+ case "secret:make":
intent = cliIntentSecretMake
- } else {
+ default:
return nil, unknownCommandErr
}
- } else if len(args) == 2 {
- if args[0] == "password:hash" {
+ case 2:
+ switch args[0] {
+ case "password:hash":
intent = cliIntentPasswordHash
- } else {
- return nil, unknownCommandErr
- }
- } else if len(args) == 2 {
- if args[0] == "mountpoint:info" {
+ case "mountpoint:info":
intent = cliIntentMountpointInfo
- } else {
+ default:
return nil, unknownCommandErr
}
- } else {
+ default:
return nil, unknownCommandErr
}
diff --git a/internal/dynacat/config-editor.go b/internal/dynacat/config-editor.go
new file mode 100644
index 00000000..cf18351b
--- /dev/null
+++ b/internal/dynacat/config-editor.go
@@ -0,0 +1,1347 @@
+package dynacat
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "log/slog"
+ "math"
+ "os"
+ "path/filepath"
+ "slices"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+
+ "gopkg.in/yaml.v3"
+)
+
+var editorConfigWarnOnce sync.Once
+
+// editorEnabledFromEnv reports whether the web UI editor is available at all.
+func editorEnabledFromEnv() bool {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv("ENABLE_EDITOR"))) {
+ case "false", "0", "f", "no", "off":
+ return false
+ }
+ return true
+}
+
+func warnAboutIgnoredEditorConfig(c *config) {
+ var ignored []string
+
+ if c.Server.AllowEditing {
+ ignored = append(ignored, "server.allow-editing")
+ }
+ if len(c.Server.EditingUsers) > 0 {
+ ignored = append(ignored, "server.editing-users")
+ }
+ if len(c.Server.EditingGroups) > 0 {
+ ignored = append(ignored, "server.editing-groups")
+ }
+ for username, u := range c.Auth.Users {
+ if u != nil && len(u.RestrictEditing) > 0 {
+ ignored = append(ignored, fmt.Sprintf("auth.users.%s.restrict-editing", username))
+ }
+ }
+ if len(ignored) == 0 {
+ return
+ }
+
+ slices.Sort(ignored)
+ editorConfigWarnOnce.Do(func() {
+ slog.Warn("Editor is disabled through ENABLE_EDITOR, editor-specific configuration is ignored",
+ "settings", strings.Join(ignored, ", "))
+ })
+}
+
+var editorWarnedAboutExposure bool
+
+func (a *application) warnAboutEditorExposure() {
+ warnOnce(
+ a.EditorEnabled && a.Config.Server.AllowEditing && !a.RequiresAuth,
+ &editorWarnedAboutExposure,
+ "The web UI editor is enabled without authentication. Anyone who can reach this server can rewrite your config. Set server.allow-editing to false or configure auth.",
+ )
+}
+
+type editorConfigView struct {
+ Pages []editorPageView `json:"pages"`
+ Theme map[string]string `json:"theme"`
+ Branding map[string]string `json:"branding"`
+ ThemePresets []editorPresetView `json:"themePresets"`
+ MainWritable bool `json:"mainWritable"`
+}
+
+type editorPresetView struct {
+ Key string `json:"key"`
+ Values map[string]string `json:"values"`
+}
+
+type editorPageView struct {
+ Title string `json:"title"`
+ Slug string `json:"slug"`
+ Width string `json:"width"`
+ File string `json:"file"`
+ Writable bool `json:"writable"`
+ Options map[string]string `json:"options"`
+ Columns []editorColumnView `json:"columns"`
+}
+
+type editorColumnView struct {
+ Size string `json:"size"`
+ Widgets []editorWidgetView `json:"widgets"`
+}
+
+type editorWidgetView struct {
+ Type string `json:"type"`
+ Title string `json:"title"`
+ Values map[string]string `json:"values"`
+ Structured map[string]any `json:"structured,omitempty"`
+ Widgets []editorWidgetView `json:"widgets,omitempty"`
+}
+
+type editorMutation struct {
+ Op string `json:"op"`
+ Page int `json:"page"`
+ Column int `json:"column"`
+ Index int `json:"index"`
+ Path []int `json:"path"`
+ ToPath []int `json:"toPath"`
+ Size string `json:"size"`
+ WidgetType string `json:"widgetType"`
+ Fields map[string]any `json:"fields"`
+ RawFields map[string]string `json:"rawFields"`
+ Title string `json:"title"`
+ Layout []string `json:"layout"`
+ Theme map[string]any `json:"theme"`
+ Branding map[string]any `json:"branding"`
+ PresetKey string `json:"presetKey"`
+}
+
+type editorPermissionError struct{ path string }
+
+func (e *editorPermissionError) Error() string {
+ return fmt.Sprintf("cannot write %s: the config directory is read only or lacks write permission", filepath.Base(e.path))
+}
+
+type editorDisabledError struct{}
+
+func (e *editorDisabledError) Error() string {
+ return "editing this page through the web UI is disabled"
+}
+
+// userRestrictEditing returns user's restrict-editing slugs. Only password-based users
+// can have them, since OIDC users have no auth.users entry.
+func (a *application) userRestrictEditing(user *authenticatedUser) []string {
+ if user == nil {
+ return nil
+ }
+ if u := a.Config.Auth.Users[user.Username]; u != nil {
+ return u.RestrictEditing
+ }
+ return nil
+}
+
+// EditingAllowedForPage reports whether user may edit page p (restrict-editing/allow-editing rules).
+func (a *application) EditingAllowedForPage(user *authenticatedUser, p *page) bool {
+ if !a.EditorEnabled {
+ return false
+ }
+ if p == nil || !a.canUserAccessPage(user, p) {
+ return false
+ }
+ if restrict := a.userRestrictEditing(user); len(restrict) > 0 {
+ return slices.Contains(restrict, p.Slug)
+ }
+ return a.Config.Server.AllowEditing
+}
+
+func (a *application) editingAllowedForPageIndex(user *authenticatedUser, i int) bool {
+ if i < 0 || i >= len(a.Config.Pages) {
+ return false
+ }
+ return a.EditingAllowedForPage(user, &a.Config.Pages[i])
+}
+
+// UserAllowedToEdit reports whether user may use the web UI editor at all (editing-users/editing-groups).
+// Password-based users have no groups, so editing-groups only ever matches OIDC users.
+func (a *application) UserAllowedToEdit(user *authenticatedUser) bool {
+ if !a.EditorEnabled {
+ return false
+ }
+ users := a.Config.Server.EditingUsers
+ groups := a.Config.Server.EditingGroups
+ if len(users) == 0 && len(groups) == 0 {
+ return true
+ }
+ if user == nil {
+ return false
+ }
+ if slices.Contains(users, user.Username) {
+ return true
+ }
+ for _, group := range groups {
+ if slices.Contains(user.Groups, group) {
+ return true
+ }
+ }
+ return false
+}
+
+// Whether the user can edit anything at all. restrict-editing users keep their pages even
+// when allow-editing is off, which is how EditingAllowedForPage treats them.
+func (a *application) userCanEditAnything(user *authenticatedUser) bool {
+ if !a.UserAllowedToEdit(user) {
+ return false
+ }
+
+ return a.Config.Server.AllowEditing || len(a.userRestrictEditing(user)) > 0
+}
+
+func isWriteBlockedError(err error) bool {
+ return os.IsPermission(err) || errors.Is(err, syscall.EROFS)
+}
+
+type editorValidationError struct {
+ err error
+ field string
+}
+
+func (e *editorValidationError) Error() string { return e.err.Error() }
+
+func (a *application) buildEditorConfigView(user *authenticatedUser) (editorConfigView, error) {
+ mainPath := a.configPath
+ mainDoc, err := loadYAMLDocument(mainPath)
+ if err != nil {
+ return editorConfigView{}, err
+ }
+
+ pagesNode := getMappingValue(documentRoot(mainDoc), "pages")
+ if pagesNode == nil || pagesNode.Kind != yaml.SequenceNode {
+ return editorConfigView{}, fmt.Errorf("config has no pages")
+ }
+
+ view := editorConfigView{}
+ docs := newEditorDocs()
+ for i := range pagesNode.Content {
+ // Positions have to line up with the page indices mutations are addressed by, so a
+ // page the user cannot see is emitted as an empty slot rather than skipped.
+ if i < len(a.Config.Pages) && !a.canUserAccessPage(user, &a.Config.Pages[i]) {
+ view.Pages = append(view.Pages, editorPageView{Options: map[string]string{}})
+ continue
+ }
+
+ path, _, pageNode, err := resolvePageNode(mainDoc, mainPath, i)
+ if err != nil {
+ return editorConfigView{}, err
+ }
+ pv := pageNodeToView(pageNode, path, docs)
+ if i < len(a.Config.Pages) {
+ pv.Slug = a.Config.Pages[i].Slug
+ pv.Title = a.Config.Pages[i].Title
+ }
+ view.Pages = append(view.Pages, pv)
+ }
+
+ root := documentRoot(mainDoc)
+ view.Theme = sectionToStringMap(root, "theme")
+ view.Branding = sectionToStringMap(root, "branding")
+ view.ThemePresets = presetsToViews(root)
+ view.MainWritable = pathWritable(mainPath)
+
+ return view, nil
+}
+
+func sectionToStringMap(root *yaml.Node, key string) map[string]string {
+ out := map[string]string{}
+ section := getMappingValue(root, key)
+ if section == nil || section.Kind != yaml.MappingNode {
+ return out
+ }
+ for i := 0; i+1 < len(section.Content); i += 2 {
+ if v := section.Content[i+1]; v.Kind == yaml.ScalarNode {
+ out[section.Content[i].Value] = v.Value
+ }
+ }
+ return out
+}
+
+func presetsToViews(root *yaml.Node) []editorPresetView {
+ out := []editorPresetView{}
+ theme := getMappingValue(root, "theme")
+ if theme == nil {
+ return out
+ }
+ presets := getMappingValue(theme, "presets")
+ if presets == nil || presets.Kind != yaml.MappingNode {
+ return out
+ }
+ for i := 0; i+1 < len(presets.Content); i += 2 {
+ keyNode, valNode := presets.Content[i], presets.Content[i+1]
+ if valNode.Kind != yaml.MappingNode {
+ continue
+ }
+ vals := map[string]string{}
+ for j := 0; j+1 < len(valNode.Content); j += 2 {
+ if s := valNode.Content[j+1]; s.Kind == yaml.ScalarNode {
+ vals[valNode.Content[j].Value] = s.Value
+ }
+ }
+ out = append(out, editorPresetView{Key: keyNode.Value, Values: vals})
+ }
+ return out
+}
+
+func pageNodeToView(pageNode *yaml.Node, path string, docs *editorDocs) editorPageView {
+ pv := editorPageView{
+ Title: scalarValue(getMappingValue(pageNode, "name")),
+ Slug: scalarValue(getMappingValue(pageNode, "slug")),
+ Width: scalarValue(getMappingValue(pageNode, "width")),
+ File: filepath.Base(path),
+ Writable: pathWritable(path),
+ Options: map[string]string{},
+ }
+
+ for i := 0; i+1 < len(pageNode.Content); i += 2 {
+ if v := pageNode.Content[i+1]; v.Kind == yaml.ScalarNode {
+ pv.Options[pageNode.Content[i].Value] = v.Value
+ }
+ }
+
+ columns := getMappingValue(pageNode, "columns")
+ if columns == nil {
+ return pv
+ }
+
+ for _, col := range columns.Content {
+ cv := editorColumnView{Size: scalarValue(getMappingValue(col, "size"))}
+ if widgets := getMappingValue(col, "widgets"); widgets != nil {
+ for _, slot := range expandWidgetSlots(widgets, path, docs, 0) {
+ cv.Widgets = append(cv.Widgets, widgetNodeToView(slot.node, slot.ownerPath, docs))
+ }
+ }
+ pv.Columns = append(pv.Columns, cv)
+ }
+
+ return pv
+}
+
+func widgetNodeToView(w *yaml.Node, ownerPath string, docs *editorDocs) editorWidgetView {
+ wv := editorWidgetView{Values: map[string]string{}}
+ for i := 0; i+1 < len(w.Content); i += 2 {
+ key, val := w.Content[i].Value, w.Content[i+1]
+ switch key {
+ case "type":
+ wv.Type = val.Value
+ case "widgets":
+ if val.Kind == yaml.SequenceNode {
+ for _, slot := range expandWidgetSlots(val, ownerPath, docs, 0) {
+ wv.Widgets = append(wv.Widgets, widgetNodeToView(slot.node, slot.ownerPath, docs))
+ }
+ }
+ case "title":
+ wv.Title = val.Value
+ wv.Values[key] = nodeToText(val)
+ default:
+ wv.Values[key] = nodeToText(val)
+ }
+
+ if key != "widgets" && val.Kind == yaml.SequenceNode {
+ var decoded []any
+ if err := val.Decode(&decoded); err == nil {
+ if wv.Structured == nil {
+ wv.Structured = map[string]any{}
+ }
+ wv.Structured[key] = decoded
+ }
+ }
+ }
+ return wv
+}
+
+func (a *application) applyEditorMutation(user *authenticatedUser, m editorMutation) error {
+ a.editorMu.Lock()
+ defer a.editorMu.Unlock()
+
+ switch m.Op {
+ case "addPage", "editStyling", "deleteThemePreset":
+ // Not scoped to an existing page, so a user restricted to specific pages
+ // can't do them and editing must be allowed globally.
+ if len(a.userRestrictEditing(user)) > 0 || !a.Config.Server.AllowEditing {
+ return &editorDisabledError{}
+ }
+ default:
+ if !a.editingAllowedForPageIndex(user, m.Page) {
+ return &editorDisabledError{}
+ }
+ }
+
+ mainPath := a.configPath
+ mainDoc, err := loadYAMLDocument(mainPath)
+ if err != nil {
+ return err
+ }
+
+ if m.Op == "addPage" {
+ if separatePageFilesEnabled() {
+ return a.addPageFile(mainDoc, mainPath, m)
+ }
+ return a.addPageInline(mainDoc, mainPath, m)
+ }
+
+ if m.Op == "editStyling" {
+ root := documentRoot(mainDoc)
+ if m.PresetKey != "" {
+ applyThemePreset(root, m.PresetKey, m.Theme)
+ } else {
+ applyStylingSection(root, "theme", m.Theme)
+ applyStylingSection(root, "branding", m.Branding)
+ }
+ return a.writeConfigCandidate(mainPath, marshalDocument(mainDoc))
+ }
+
+ if m.Op == "deleteThemePreset" {
+ root := documentRoot(mainDoc)
+ if m.PresetKey == "" {
+ resetBaseTheme(root)
+ } else {
+ removeThemePreset(root, m.PresetKey)
+ }
+ return a.writeConfigCandidate(mainPath, marshalDocument(mainDoc))
+ }
+
+ if m.Op == "editPage" {
+ path, doc, pageNode, err := resolvePageNode(mainDoc, mainPath, m.Page)
+ if err != nil {
+ return err
+ }
+ if err := applyPageFields(pageNode, m.Fields); err != nil {
+ return err
+ }
+ setBlockStyleDeep(pageNode)
+ return a.writeConfigCandidate(path, marshalDocument(doc))
+ }
+
+ if m.Op == "removePage" {
+ pages := getMappingValue(documentRoot(mainDoc), "pages")
+ if pages == nil || !validIndex(pages.Content, m.Page) {
+ return fmt.Errorf("page %d out of range", m.Page)
+ }
+ includeFile := includeTarget(pages.Content[m.Page])
+ pages.Content = removeNode(pages.Content, m.Page)
+ if err := a.writeConfigCandidate(mainPath, marshalDocument(mainDoc)); err != nil {
+ return err
+ }
+ if includeFile != "" {
+ includePath := includeFile
+ if !filepath.IsAbs(includePath) {
+ includePath = filepath.Join(filepath.Dir(mainPath), includeFile)
+ }
+ os.Remove(includePath)
+ }
+ return nil
+ }
+
+ path, doc, pageNode, err := resolvePageNode(mainDoc, mainPath, m.Page)
+ if err != nil {
+ return err
+ }
+
+ columns := getMappingValue(pageNode, "columns")
+ if columns == nil || columns.Kind != yaml.SequenceNode {
+ return fmt.Errorf("page %d has no columns", m.Page)
+ }
+
+ docs := newEditorDocs()
+ docs.track(path, doc)
+ docs.pagePath = filepath.Clean(path)
+
+ if err := mutateColumns(columns, m, docs); err != nil {
+ return err
+ }
+
+ setBlockStyleDeep(pageNode)
+
+ return a.writeEditorDocs(docs)
+}
+
+func setBlockStyleDeep(n *yaml.Node) {
+ if n == nil {
+ return
+ }
+ if n.Kind == yaml.MappingNode || n.Kind == yaml.SequenceNode {
+ n.Style = 0
+ }
+ for _, c := range n.Content {
+ setBlockStyleDeep(c)
+ }
+}
+
+func mutateColumns(columns *yaml.Node, m editorMutation, docs *editorDocs) error {
+ switch m.Op {
+ case "addColumn":
+ col := newMappingNode()
+ addPair(col, "size", scalarNode(orDefault(m.Size, "full")))
+ addPair(col, "widgets", sequenceNode())
+ columns.Content = insertNode(columns.Content, m.Index, col)
+ return nil
+ case "removeColumn":
+ if !validIndex(columns.Content, m.Column) {
+ return fmt.Errorf("column %d out of range", m.Column)
+ }
+ columns.Content = removeNode(columns.Content, m.Column)
+ return nil
+ }
+
+ widgets, owner, err := resolveWidgets(columns, m.Path, docs)
+ if err != nil {
+ return err
+ }
+
+ index := m.Path[len(m.Path)-1]
+ slots := expandWidgetSlots(widgets, owner, docs, 0)
+
+ switch m.Op {
+ case "addWidget":
+ node, err := buildWidgetNode(m.WidgetType, m.Fields, m.RawFields)
+ if err != nil {
+ return err
+ }
+ seq, at := widgetInsertPoint(slots, widgets, index)
+ seq.Content = insertNode(seq.Content, at, node)
+ case "editWidget":
+ if !validSlotIndex(slots, index) {
+ return fmt.Errorf("widget index out of range")
+ }
+ if err := editWidgetNode(slots[index].node, m.Fields, m.RawFields); err != nil {
+ return err
+ }
+ case "removeWidget":
+ if !validSlotIndex(slots, index) {
+ return fmt.Errorf("widget index out of range")
+ }
+ slot := slots[index]
+ slot.seq.Content = removeNode(slot.seq.Content, slot.index)
+ pruneEmptyInclude(slot)
+ case "moveWidget":
+ if isIntPrefix(m.Path, m.ToPath) {
+ return fmt.Errorf("cannot move a container into itself")
+ }
+ if !validSlotIndex(slots, index) {
+ return fmt.Errorf("widget index out of range")
+ }
+ slot := slots[index]
+ dstWidgets, dstOwner, err := resolveWidgets(columns, m.ToPath, docs)
+ if err != nil {
+ return err
+ }
+ dstSlots := expandWidgetSlots(dstWidgets, dstOwner, docs, 0)
+ dstSeq, dstIndex := widgetInsertPoint(dstSlots, dstWidgets, m.ToPath[len(m.ToPath)-1])
+
+ slot.seq.Content = removeNode(slot.seq.Content, slot.index)
+ if dstSeq == slot.seq && dstIndex > slot.index {
+ dstIndex--
+ }
+ dstSeq.Content = insertNode(dstSeq.Content, dstIndex, slot.node)
+ pruneEmptyInclude(slot)
+ default:
+ return fmt.Errorf("unknown op: %s", m.Op)
+ }
+ return nil
+}
+
+func pruneEmptyInclude(slot widgetSlot) {
+ if slot.seq == slot.outerSeq || len(slot.seq.Content) > 0 {
+ return
+ }
+ if validIndex(slot.outerSeq.Content, slot.outerIdx) && includeTarget(slot.outerSeq.Content[slot.outerIdx]) != "" {
+ slot.outerSeq.Content = removeNode(slot.outerSeq.Content, slot.outerIdx)
+ }
+}
+
+func validSlotIndex(slots []widgetSlot, index int) bool {
+ return index >= 0 && index < len(slots)
+}
+
+func resolveWidgets(columns *yaml.Node, path []int, docs *editorDocs) (*yaml.Node, string, error) {
+ if len(path) < 2 {
+ return nil, "", fmt.Errorf("invalid widget path")
+ }
+
+ widgets := columnWidgets(columns, path[0])
+ if widgets == nil {
+ return nil, "", fmt.Errorf("column %d out of range", path[0])
+ }
+
+ owner := docs.pagePath
+ for _, wi := range path[1 : len(path)-1] {
+ slots := expandWidgetSlots(widgets, owner, docs, 0)
+ if !validSlotIndex(slots, wi) {
+ return nil, "", fmt.Errorf("widget %d out of range", wi)
+ }
+ container := slots[wi]
+ inner := getMappingValue(container.node, "widgets")
+ if inner == nil {
+ inner = sequenceNode()
+ addPair(container.node, "widgets", inner)
+ }
+ widgets, owner = inner, container.ownerPath
+ }
+
+ return widgets, owner, nil
+}
+
+func isIntPrefix(prefix, full []int) bool {
+ if len(prefix) > len(full) {
+ return false
+ }
+ for i := range prefix {
+ if prefix[i] != full[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func applyPageFields(pageNode *yaml.Node, fields map[string]any) error {
+ for _, k := range sortedKeys(fields) {
+ v := fields[k]
+ if k == "name" && isEmptyValue(v) {
+ continue
+ }
+ if isEmptyValue(v) || v == false {
+ removeMappingKey(pageNode, k)
+ continue
+ }
+ node := valueNode(v)
+ if err := rejectIncludeDirective(k, node); err != nil {
+ return err
+ }
+ setPageMappingKey(pageNode, k, node)
+ }
+
+ return nil
+}
+
+// parseYAMLIncludes is a textual preprocessor that matches any line, so an include smuggled in
+// as a key or inside a multi-line value would make the loader read, and removePage delete, an
+// arbitrary file.
+func rejectIncludeDirective(key string, node *yaml.Node) error {
+ if key == "$include" || key == "!include" || configIncludePattern.MatchString(nodeToText(node)) {
+ return &editorValidationError{err: fmt.Errorf("field %s must not contain an include directive", key), field: key}
+ }
+
+ return nil
+}
+
+func setPageMappingKey(m *yaml.Node, key string, value *yaml.Node) {
+ for i := 0; i+1 < len(m.Content); i += 2 {
+ if m.Content[i].Value == key {
+ m.Content[i+1] = value
+ return
+ }
+ }
+ for i := 0; i+1 < len(m.Content); i += 2 {
+ if m.Content[i].Value == "columns" {
+ out := make([]*yaml.Node, 0, len(m.Content)+2)
+ out = append(out, m.Content[:i]...)
+ out = append(out, scalarNode(key), value)
+ out = append(out, m.Content[i:]...)
+ m.Content = out
+ return
+ }
+ }
+ addPair(m, key, value)
+}
+
+func newPageMapping(m editorMutation) *yaml.Node {
+ page := newMappingNode()
+ addPair(page, "name", scalarNode(orDefault(m.Title, "New Page")))
+
+ columns := sequenceNode()
+ layout := m.Layout
+ if len(layout) == 0 {
+ layout = []string{"full"}
+ }
+ for _, size := range layout {
+ col := newMappingNode()
+ addPair(col, "size", scalarNode(size))
+ addPair(col, "widgets", sequenceNode())
+ columns.Content = append(columns.Content, col)
+ }
+ addPair(page, "columns", columns)
+ return page
+}
+
+func separatePageFilesEnabled() bool {
+ switch os.Getenv("EDITOR_SEPARATE_PAGE_FILES") {
+ case "false", "0", "f":
+ return false
+ }
+ return true
+}
+
+func (a *application) addPageInline(mainDoc *yaml.Node, mainPath string, m editorMutation) error {
+ root := documentRoot(mainDoc)
+ pages := getMappingValue(root, "pages")
+ if pages == nil {
+ pages = sequenceNode()
+ addPair(root, "pages", pages)
+ }
+
+ page := newPageMapping(m)
+ setBlockStyleDeep(page)
+ pages.Content = append(pages.Content, page)
+
+ return a.writeConfigCandidate(mainPath, marshalDocument(mainDoc))
+}
+
+func (a *application) addPageFile(mainDoc *yaml.Node, mainPath string, m editorMutation) error {
+ pages := getMappingValue(documentRoot(mainDoc), "pages")
+ if pages == nil {
+ pages = sequenceNode()
+ addPair(documentRoot(mainDoc), "pages", pages)
+ }
+
+ dir := filepath.Dir(mainPath)
+ file := uniquePageFileName(dir, orDefault(m.Title, "New Page"))
+ pagePath := filepath.Join(dir, file)
+
+ page := newPageMapping(m)
+ setBlockStyleDeep(page)
+ pageDoc := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{
+ {Kind: yaml.SequenceNode, Content: []*yaml.Node{page}},
+ }}
+ if err := os.WriteFile(pagePath, marshalDocument(pageDoc), 0o644); err != nil {
+ if isWriteBlockedError(err) {
+ return &editorPermissionError{pagePath}
+ }
+ return err
+ }
+
+ include := newMappingNode()
+ addPair(include, "$include", scalarNode(file))
+ pages.Content = append(pages.Content, include)
+
+ if err := a.writeConfigCandidate(mainPath, marshalDocument(mainDoc)); err != nil {
+ os.Remove(pagePath)
+ return err
+ }
+ return nil
+}
+
+func uniquePageFileName(dir, title string) string {
+ base := titleToSlug(title)
+ base = pageFileNamePattern.ReplaceAllString(base, "")
+ base = strings.Trim(base, "-")
+ if base == "" {
+ base = "page"
+ }
+
+ name := base + ".yml"
+ for i := 2; ; i++ {
+ if _, err := os.Stat(filepath.Join(dir, name)); os.IsNotExist(err) {
+ return name
+ }
+ name = fmt.Sprintf("%s-%d.yml", base, i)
+ }
+}
+
+func (a *application) writeConfigCandidate(path string, candidate []byte) error {
+ return a.writeConfigCandidates(map[string][]byte{path: candidate})
+}
+
+func (a *application) writeConfigCandidates(candidates map[string][]byte) error {
+ if len(candidates) == 0 {
+ return nil
+ }
+
+ originals := map[string][]byte{}
+ perms := map[string]os.FileMode{}
+
+ for path, candidate := range candidates {
+ if err := checkIncludesStayInConfigDir(candidate, filepath.Dir(a.configPath)); err != nil {
+ return &editorValidationError{err: err, field: "$include"}
+ }
+
+ perms[path] = os.FileMode(0o644)
+ if info, err := os.Stat(path); err == nil {
+ perms[path] = info.Mode()
+ }
+
+ // Without the original there is nothing to roll back to, so refuse rather than risk truncating.
+ original, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ originals[path] = original
+ }
+
+ var written []string
+ rollback := func() {
+ for _, path := range written {
+ os.WriteFile(path, originals[path], perms[path])
+ }
+ }
+
+ for path, candidate := range candidates {
+ if err := os.WriteFile(path, candidate, perms[path]); err != nil {
+ rollback()
+ if isWriteBlockedError(err) {
+ return &editorPermissionError{path}
+ }
+ return err
+ }
+ written = append(written, path)
+ }
+
+ merged, _, err := parseYAMLIncludes(a.configPath)
+ if err == nil {
+ _, err = newConfigFromYAML(merged)
+ }
+ if err != nil {
+ rollback()
+ return &editorValidationError{err: err}
+ }
+
+ return nil
+}
+
+func (a *application) writeEditorDocs(docs *editorDocs) error {
+ candidates := map[string][]byte{}
+
+ for path, doc := range docs.docs {
+ root := documentRoot(doc)
+ if root.Kind == 0 {
+ continue
+ }
+ if path != docs.pagePath {
+ setBlockStyleDeep(root)
+ }
+
+ candidate := marshalDocument(doc)
+ if root.Kind == yaml.SequenceNode && len(root.Content) == 0 {
+ candidate = nil
+ }
+ if bytes.Equal(candidate, docs.original[path]) {
+ continue
+ }
+ candidates[path] = candidate
+ }
+
+ return a.writeConfigCandidates(candidates)
+}
+
+// Second line of defence behind rejectIncludeDirective, covering anything the editor writes.
+func checkIncludesStayInConfigDir(candidate []byte, dir string) error {
+ absDir, err := filepath.Abs(dir)
+ if err != nil {
+ return err
+ }
+
+ for _, match := range configIncludePattern.FindAllSubmatch(candidate, -1) {
+ target := strings.TrimSpace(string(match[2]))
+
+ path := target
+ if !filepath.IsAbs(path) {
+ path = filepath.Join(absDir, path)
+ }
+
+ abs, err := filepath.Abs(path)
+ if err != nil || !strings.HasPrefix(abs, absDir+string(filepath.Separator)) {
+ return fmt.Errorf("include %q is outside the config directory", target)
+ }
+ }
+
+ return nil
+}
+
+type editorDocs struct {
+ docs map[string]*yaml.Node
+ original map[string][]byte
+ pagePath string
+}
+
+func newEditorDocs() *editorDocs {
+ return &editorDocs{docs: map[string]*yaml.Node{}, original: map[string][]byte{}}
+}
+
+func (d *editorDocs) track(path string, doc *yaml.Node) {
+ path = filepath.Clean(path)
+ if _, ok := d.docs[path]; ok {
+ return
+ }
+ d.docs[path] = doc
+ if b, err := os.ReadFile(path); err == nil {
+ d.original[path] = b
+ }
+}
+
+func (d *editorDocs) load(ownerPath, file string) (string, *yaml.Node, error) {
+ path := file
+ if !filepath.IsAbs(path) {
+ path = filepath.Join(filepath.Dir(ownerPath), file)
+ }
+ path = filepath.Clean(path)
+
+ if doc, ok := d.docs[path]; ok {
+ return path, doc, nil
+ }
+
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return "", nil, err
+ }
+ var doc yaml.Node
+ if err := yaml.Unmarshal(b, &doc); err != nil {
+ return "", nil, err
+ }
+
+ d.docs[path] = &doc
+ d.original[path] = b
+ return path, &doc, nil
+}
+
+type widgetSlot struct {
+ node *yaml.Node
+ ownerPath string
+ seq *yaml.Node
+ index int
+ outerSeq *yaml.Node
+ outerIdx int
+}
+
+func expandWidgetSlots(seq *yaml.Node, ownerPath string, docs *editorDocs, depth int) []widgetSlot {
+ var slots []widgetSlot
+ if seq == nil || seq.Kind != yaml.SequenceNode {
+ return slots
+ }
+
+ for i, item := range seq.Content {
+ raw := widgetSlot{node: item, ownerPath: ownerPath, seq: seq, index: i, outerSeq: seq, outerIdx: i}
+
+ include := includeTarget(item)
+ if include == "" || depth >= CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT {
+ slots = append(slots, raw)
+ continue
+ }
+
+ includePath, includeDoc, err := docs.load(ownerPath, include)
+ if err != nil {
+ slots = append(slots, raw)
+ continue
+ }
+ includeRoot := documentRoot(includeDoc)
+ if includeRoot.Kind == 0 || includeRoot.Tag == "!!null" {
+ continue
+ }
+ if includeRoot.Kind != yaml.SequenceNode {
+ slots = append(slots, raw)
+ continue
+ }
+
+ for _, s := range expandWidgetSlots(includeRoot, includePath, docs, depth+1) {
+ if s.seq == includeRoot && s.index == 0 {
+ s.outerSeq, s.outerIdx = seq, i
+ }
+ slots = append(slots, s)
+ }
+ }
+
+ return slots
+}
+
+func widgetInsertPoint(slots []widgetSlot, seq *yaml.Node, index int) (*yaml.Node, int) {
+ if index < 0 {
+ index = 0
+ }
+ if index >= len(slots) {
+ return seq, len(seq.Content)
+ }
+ if s := slots[index]; s.index == 0 {
+ return s.outerSeq, s.outerIdx
+ }
+ return slots[index].seq, slots[index].index
+}
+
+func resolvePageNode(mainDoc *yaml.Node, mainPath string, idx int) (string, *yaml.Node, *yaml.Node, error) {
+ pages := getMappingValue(documentRoot(mainDoc), "pages")
+ if pages == nil || pages.Kind != yaml.SequenceNode || !validIndex(pages.Content, idx) {
+ return "", nil, nil, fmt.Errorf("page %d out of range", idx)
+ }
+
+ item := pages.Content[idx]
+ if inc := includeTarget(item); inc != "" {
+ path := inc
+ if !filepath.IsAbs(path) {
+ path = filepath.Join(filepath.Dir(mainPath), path)
+ }
+ doc, err := loadYAMLDocument(path)
+ if err != nil {
+ return "", nil, nil, err
+ }
+ return path, doc, firstMapping(documentRoot(doc)), nil
+ }
+
+ return mainPath, mainDoc, item, nil
+}
+
+func loadYAMLDocument(path string) (*yaml.Node, error) {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ var doc yaml.Node
+ if err := yaml.Unmarshal(b, &doc); err != nil {
+ return nil, err
+ }
+ return &doc, nil
+}
+
+func documentRoot(doc *yaml.Node) *yaml.Node {
+ if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 {
+ return doc.Content[0]
+ }
+ return doc
+}
+
+func firstMapping(root *yaml.Node) *yaml.Node {
+ if root.Kind == yaml.SequenceNode && len(root.Content) > 0 {
+ return root.Content[0]
+ }
+ return root
+}
+
+func includeTarget(item *yaml.Node) string {
+ if item.Kind != yaml.MappingNode {
+ return ""
+ }
+ for i := 0; i+1 < len(item.Content); i += 2 {
+ if k := item.Content[i].Value; k == "$include" || k == "!include" {
+ return strings.TrimSpace(item.Content[i+1].Value)
+ }
+ }
+ return ""
+}
+
+func getMappingValue(m *yaml.Node, key string) *yaml.Node {
+ if m == nil || m.Kind != yaml.MappingNode {
+ return nil
+ }
+ for i := 0; i+1 < len(m.Content); i += 2 {
+ if m.Content[i].Value == key {
+ return m.Content[i+1]
+ }
+ }
+ return nil
+}
+
+func setMappingKey(m *yaml.Node, key string, value *yaml.Node) {
+ for i := 0; i+1 < len(m.Content); i += 2 {
+ if m.Content[i].Value == key {
+ m.Content[i+1] = value
+ return
+ }
+ }
+ addPair(m, key, value)
+}
+
+func removeMappingKey(m *yaml.Node, key string) {
+ for i := 0; i+1 < len(m.Content); i += 2 {
+ if m.Content[i].Value == key {
+ m.Content = append(m.Content[:i], m.Content[i+2:]...)
+ return
+ }
+ }
+}
+
+func columnWidgets(columns *yaml.Node, index int) *yaml.Node {
+ if !validIndex(columns.Content, index) {
+ return nil
+ }
+ widgets := getMappingValue(columns.Content[index], "widgets")
+ if widgets == nil {
+ widgets = sequenceNode()
+ addPair(columns.Content[index], "widgets", widgets)
+ }
+ return widgets
+}
+
+func buildWidgetNode(widgetType string, fields map[string]any, rawFields map[string]string) (*yaml.Node, error) {
+ if widgetType == "" {
+ return nil, fmt.Errorf("widget type is required")
+ }
+ node := newMappingNode()
+ addPair(node, "type", scalarNode(widgetType))
+ return node, applyFieldsToNode(node, fields, rawFields)
+}
+
+func editWidgetNode(node *yaml.Node, fields map[string]any, rawFields map[string]string) error {
+ return applyFieldsToNode(node, fields, rawFields)
+}
+
+func applyFieldsToNode(node *yaml.Node, fields map[string]any, rawFields map[string]string) error {
+ for _, k := range sortedKeys(fields) {
+ if isEmptyValue(fields[k]) {
+ removeMappingKey(node, k)
+ continue
+ }
+ value := valueNode(fields[k])
+ if err := rejectIncludeDirective(k, value); err != nil {
+ return err
+ }
+ setMappingKey(node, k, value)
+ }
+
+ for _, k := range sortedStringKeys(rawFields) {
+ if strings.TrimSpace(rawFields[k]) == "" {
+ removeMappingKey(node, k)
+ continue
+ }
+ parsed, err := parseYAMLValue(rawFields[k])
+ if err != nil {
+ return fmt.Errorf("field %s: %w", k, err)
+ }
+ if err := rejectIncludeDirective(k, parsed); err != nil {
+ return err
+ }
+ setMappingKey(node, k, parsed)
+ }
+
+ return nil
+}
+
+func applyStylingSection(root *yaml.Node, key string, fields map[string]any) {
+ if fields == nil {
+ return
+ }
+ section := getMappingValue(root, key)
+ if section == nil {
+ section = newMappingNode()
+ setMappingKey(root, key, section)
+ }
+ for _, k := range sortedKeys(fields) {
+ v := fields[k]
+ if isEmptyValue(v) || v == false || v == float64(0) {
+ removeMappingKey(section, k)
+ continue
+ }
+ setMappingKey(section, k, valueNode(v))
+ }
+ if len(section.Content) == 0 {
+ removeMappingKey(root, key)
+ }
+}
+
+func applyThemePreset(root *yaml.Node, key string, fields map[string]any) {
+ theme := getMappingValue(root, "theme")
+ if theme == nil {
+ theme = newMappingNode()
+ setMappingKey(root, "theme", theme)
+ }
+ presets := getMappingValue(theme, "presets")
+ if presets == nil {
+ presets = newMappingNode()
+ setMappingKey(theme, "presets", presets)
+ }
+ preset := getMappingValue(presets, key)
+ if preset == nil {
+ preset = newMappingNode()
+ setMappingKey(presets, key, preset)
+ }
+ for _, k := range sortedKeys(fields) {
+ v := fields[k]
+ if isEmptyValue(v) || v == false || v == float64(0) {
+ removeMappingKey(preset, k)
+ continue
+ }
+ setMappingKey(preset, k, valueNode(v))
+ }
+}
+
+func removeThemePreset(root *yaml.Node, key string) {
+ theme := getMappingValue(root, "theme")
+ if theme == nil {
+ return
+ }
+ presets := getMappingValue(theme, "presets")
+ if presets == nil {
+ return
+ }
+ removeMappingKey(presets, key)
+ if len(presets.Content) == 0 {
+ removeMappingKey(theme, "presets")
+ }
+}
+
+func resetBaseTheme(root *yaml.Node) {
+ theme := getMappingValue(root, "theme")
+ if theme == nil {
+ return
+ }
+ kept := theme.Content[:0:0]
+ for i := 0; i+1 < len(theme.Content); i += 2 {
+ if theme.Content[i+1].Kind == yaml.ScalarNode {
+ continue
+ }
+ kept = append(kept, theme.Content[i], theme.Content[i+1])
+ }
+ theme.Content = kept
+ if len(theme.Content) == 0 {
+ removeMappingKey(root, "theme")
+ }
+}
+
+func parseYAMLValue(text string) (*yaml.Node, error) {
+ var doc yaml.Node
+ if err := yaml.Unmarshal([]byte(text), &doc); err != nil {
+ return nil, err
+ }
+ if len(doc.Content) == 0 {
+ return scalarNode(""), nil
+ }
+ return doc.Content[0], nil
+}
+
+func marshalDocument(doc *yaml.Node) []byte {
+ var buf bytes.Buffer
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(2)
+ enc.Encode(doc)
+ enc.Close()
+ return buf.Bytes()
+}
+
+func nodeToText(n *yaml.Node) string {
+ if n.Kind == yaml.ScalarNode {
+ return n.Value
+ }
+ return strings.TrimRight(string(marshalDocument(n)), "\n")
+}
+
+func scalarValue(n *yaml.Node) string {
+ if n == nil {
+ return ""
+ }
+ return n.Value
+}
+
+func newMappingNode() *yaml.Node { return &yaml.Node{Kind: yaml.MappingNode} }
+
+func sequenceNode() *yaml.Node { return &yaml.Node{Kind: yaml.SequenceNode} }
+
+func scalarNode(v string) *yaml.Node { return &yaml.Node{Kind: yaml.ScalarNode, Value: v} }
+
+func addPair(m *yaml.Node, key string, value *yaml.Node) {
+ m.Content = append(m.Content, scalarNode(key), value)
+}
+
+func valueNode(v any) *yaml.Node {
+ switch x := v.(type) {
+ case bool:
+ return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(x)}
+ case float64:
+ if x == math.Trunc(x) {
+ return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.FormatInt(int64(x), 10)}
+ }
+ return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: strconv.FormatFloat(x, 'f', -1, 64)}
+ case string:
+ return scalarNode(x)
+ case []any:
+ seq := sequenceNode()
+ for _, item := range x {
+ seq.Content = append(seq.Content, valueNode(item))
+ }
+ return seq
+ case map[string]any:
+ // Escape hatch for list entries whose sub-field is edited as raw YAML.
+ if raw, ok := x["$yaml"].(string); ok && len(x) == 1 {
+ if parsed, err := parseYAMLValue(raw); err == nil {
+ return parsed
+ }
+ }
+ m := newMappingNode()
+ for _, k := range orderedFieldKeys(x) {
+ addPair(m, k, valueNode(x[k]))
+ }
+ return m
+ default:
+ return scalarNode(fmt.Sprintf("%v", v))
+ }
+}
+
+func orderedFieldKeys(m map[string]any) []string {
+ keys := sortedKeys(m)
+ slices.SortStableFunc(keys, func(a, b string) int { return fieldOrderRank(a) - fieldOrderRank(b) })
+ return keys
+}
+
+func isEmptyValue(v any) bool {
+ switch x := v.(type) {
+ case nil:
+ return true
+ case string:
+ return strings.TrimSpace(x) == ""
+ case []any:
+ return len(x) == 0
+ case map[string]any:
+ return len(x) == 0
+ default:
+ return false
+ }
+}
+
+func insertNode(nodes []*yaml.Node, index int, node *yaml.Node) []*yaml.Node {
+ if index < 0 || index > len(nodes) {
+ return append(nodes, node)
+ }
+ nodes = append(nodes, nil)
+ copy(nodes[index+1:], nodes[index:])
+ nodes[index] = node
+ return nodes
+}
+
+func removeNode(nodes []*yaml.Node, index int) []*yaml.Node {
+ return append(nodes[:index], nodes[index+1:]...)
+}
+
+func validIndex(nodes []*yaml.Node, index int) bool {
+ return index >= 0 && index < len(nodes)
+}
+
+func pathWritable(path string) bool {
+ f, err := os.OpenFile(path, os.O_WRONLY, 0)
+ if err != nil {
+ return false
+ }
+ f.Close()
+ return true
+}
+
+func orDefault(v, fallback string) string {
+ if strings.TrimSpace(v) == "" {
+ return fallback
+ }
+ return v
+}
+
+func sortedKeys(m map[string]any) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+func sortedStringKeys(m map[string]string) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
diff --git a/internal/dynacat/config.go b/internal/dynacat/config.go
index 4a1103ae..c0a939fc 100644
--- a/internal/dynacat/config.go
+++ b/internal/dynacat/config.go
@@ -40,6 +40,9 @@ type config struct {
BaseURL string `yaml:"base-url"`
DBPath string `yaml:"db-path"`
AllowedEmbedHosts []string `yaml:"allowed-embed-hosts"`
+ AllowEditing bool `yaml:"allow-editing"`
+ EditingUsers []string `yaml:"editing-users"`
+ EditingGroups []string `yaml:"editing-groups"`
trustedProxyNets []*net.IPNet `yaml:"-"`
} `yaml:"server"`
@@ -51,6 +54,15 @@ type config struct {
OIDC *oidcConfig `yaml:"oidc"`
} `yaml:"auth"`
+ API struct {
+ Enabled bool `yaml:"enabled"`
+ Token string `yaml:"token"`
+ EnforcePagePermissions *bool `yaml:"enforce-page-permissions"`
+ RateLimit *int `yaml:"rate-limit"`
+ AllowedPages []string `yaml:"allowed-pages"`
+ AllowedOrigins []string `yaml:"allowed-origins"`
+ } `yaml:"api"`
+
Document struct {
Head template.HTML `yaml:"head"`
} `yaml:"document"`
@@ -94,9 +106,11 @@ type oidcConfig struct {
}
type user struct {
- Password string `yaml:"password"`
- PasswordHashString string `yaml:"password-hash"`
- PasswordHash []byte `yaml:"-"`
+ Password string `yaml:"password"`
+ PasswordHashString string `yaml:"password-hash"`
+ PasswordHash []byte `yaml:"-"`
+ RestrictEditing []string `yaml:"restrict-editing"`
+ usernameHash []byte `yaml:"-"`
}
type page struct {
@@ -143,6 +157,7 @@ func newConfigFromYAML(contents []byte) (*config, error) {
config := &config{}
config.Server.Port = 8080
+ config.Server.AllowEditing = true
err = yaml.Unmarshal(contents, config)
if err != nil {
@@ -177,15 +192,17 @@ func newConfigFromYAML(contents []byte) (*config, error) {
var envVariableNamePattern = regexp.MustCompile(`^[A-Z0-9_]+$`)
var configVariablePattern = regexp.MustCompile(`(^|.)\$\{(?:([a-zA-Z]+):)?([a-zA-Z0-9_-]+)\}`)
-// Parses variables defined in the config such as:
-// ${API_KEY} - gets replaced with the value of the API_KEY environment variable
-// \${API_KEY} - escaped, gets used as is without the \ in the config
-// ${secret:api_key} - value gets loaded from /run/secrets/api_key
-// ${readFileFromEnv:PATH_TO_SECRET} - value gets loaded from the file path specified in the environment variable PATH_TO_SECRET
-//
-// TODO: don't match against commented out sections, not sure exactly how since
-// variables can be placed anywhere and used to modify the YAML structure itself
+// TODO: don't match against commented out sections
func parseConfigVariables(contents []byte) ([]byte, error) {
+ return expandConfigVariables(contents, true)
+}
+
+// Expands ${ENV_VAR} only, so a remote template cannot pull a mounted secret into the URL it calls.
+func parseEnvVariablesOnly(contents []byte) ([]byte, error) {
+ return expandConfigVariables(contents, false)
+}
+
+func expandConfigVariables(contents []byte, allowTypedVariables bool) ([]byte, error) {
var err error
replaced := configVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte {
@@ -195,8 +212,6 @@ func parseConfigVariables(contents []byte) ([]byte, error) {
groups := configVariablePattern.FindSubmatch(match)
if len(groups) != 4 {
- // we can't handle this match, this shouldn't happen unless the number of groups
- // in the regex has been changed without updating the below code
return match
}
@@ -210,6 +225,9 @@ func parseConfigVariables(contents []byte) ([]byte, error) {
}
typeAsString, variableName := string(groups[2]), string(groups[3])
+ if typeAsString != "" && !allowTypedVariables {
+ return match
+ }
variableType := ternary(typeAsString == "", configVarTypeEnv, typeAsString)
parsedValue, returnOriginal, localErr := parseConfigVariableOfType(variableType, variableName)
@@ -232,7 +250,6 @@ func parseConfigVariables(contents []byte) ([]byte, error) {
return replaced, nil
}
-// When the bool return value is true, it indicates that the caller should use the original value
func parseConfigVariableOfType(variableType, variableName string) (string, bool, error) {
switch variableType {
case configVarTypeEnv:
@@ -420,7 +437,6 @@ func configFilesWatcher(
updateWatchedFiles(nil, lastIncludes)
- // needed for lastContents and lastIncludes because they get updated in multiple goroutines
mu := sync.Mutex{}
parseAndCompareBeforeCallback := func() {
@@ -475,19 +491,9 @@ func configFilesWatcher(
if event.Has(fsnotify.Write) {
debouncedParseAndCompareBeforeCallback()
} else if event.Has(fsnotify.Rename) {
- // on linux the file will no longer be watched after a rename, on windows
- // it will continue to be watched with the new name but we have no access to
- // the new name in this event in order to stop watching it manually and match the
- // behavior in linux, may lead to weird unintended behaviors on windows as we're
- // only handling renames from linux's perspective
// see https://github.com/fsnotify/fsnotify/issues/255
-
- // remove the old file from our manually tracked includes, calling
- // debouncedParseAndCompareBeforeCallback will re-add it if it's still
- // required after it triggers
deleteLastInclude(event.Name)
- // wait for file to maybe get created again
// see https://github.com/Panonim/dynacat/pull/358
for range 10 {
if _, err := os.Stat(event.Name); err == nil {
@@ -521,10 +527,7 @@ func configFilesWatcher(
}, nil
}
-// TODO: Refactor, we currently validate in two different places, this being
-// one of them, which doesn't modify the data and only checks for logical errors
-// and then again when creating the application which does modify the data and do
-// further validation. Would be better if validation was done in a single place.
+// TODO: validation happens in two places, would be better in a single place
func isConfigStateValid(config *config) error {
if len(config.Pages) == 0 {
return fmt.Errorf("no pages configured")
@@ -640,10 +643,58 @@ func isConfigStateValid(config *config) error {
}
}
+ if err := validateAPIIDsAreUnique(config); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func validateAPIIDsAreUnique(config *config) error {
+ seen := make(map[string]struct{})
+
+ var walk func(ws widgets) error
+ walk = func(ws widgets) error {
+ for _, w := range ws {
+ if id := w.GetAPIID(); id != "" {
+ if _, taken := seen[id]; taken {
+ return fmt.Errorf("api-id %q is used by more than one widget", id)
+ }
+ seen[id] = struct{}{}
+ }
+
+ switch v := w.(type) {
+ case *groupWidget:
+ if err := walk(v.Widgets); err != nil {
+ return err
+ }
+ case *splitColumnWidget:
+ if err := walk(v.Widgets); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+ }
+
+ for i := range config.Pages {
+ page := &config.Pages[i]
+
+ if err := walk(page.HeadWidgets); err != nil {
+ return err
+ }
+
+ for j := range page.Columns {
+ if err := walk(page.Columns[j].Widgets); err != nil {
+ return err
+ }
+ }
+ }
+
return nil
}
-// Read-only way to store ordered maps from a YAML structure
type orderedYAMLMap[K comparable, V any] struct {
keys []K
data map[K]V
diff --git a/internal/dynacat/diagnose.go b/internal/dynacat/diagnose.go
index 9997ae58..9baa06bc 100644
--- a/internal/dynacat/diagnose.go
+++ b/internal/dynacat/diagnose.go
@@ -56,7 +56,6 @@ var diagnosticSteps = []diagnosticStep{
{
name: "fetch data from Twitch.tv GQL",
fn: func() (string, error) {
- // this should always return 0 bytes, we're mainly looking for a 200 status code
return testHttpRequest("OPTIONS", "https://gql.twitch.tv/gql", 200)
},
},
diff --git a/internal/dynacat/dynacat.go b/internal/dynacat/dynacat.go
index 369f744e..3a9dce32 100644
--- a/internal/dynacat/dynacat.go
+++ b/internal/dynacat/dynacat.go
@@ -10,6 +10,7 @@ import (
"log/slog"
"net"
"net/http"
+ "net/url"
"os"
"path/filepath"
"slices"
@@ -40,15 +41,18 @@ type imageProxyInfo struct {
}
type application struct {
- Version string
- CreatedAt time.Time
- Config config
+ Version string
+ CreatedAt time.Time
+ Config config
+ configPath string
parsedManifest []byte
- slugToPage map[string]*page
- widgetByID map[uint64]widget
- widgetToPage map[uint64]*page
+ slugToPage map[string]*page
+ widgetByID map[uint64]widget
+ widgetByAPIID map[string]widget
+ widgetToPage map[uint64]*page
+ searchTargets searchTargetRegistry
RequiresAuth bool
OIDCEnabled bool
@@ -69,31 +73,37 @@ type application struct {
sseMu sync.RWMutex
sseClients map[*sseClient]struct{}
DynamicUpdateEnabled bool
+ EditorEnabled bool
+ editorMu sync.Mutex
+
+ apiRateMu sync.Mutex
+ apiRateRequests map[string]*apiRateWindow
imageProxyMu sync.RWMutex
imageProxyURLs map[string]imageProxyInfo
+ searchAutocompleteURLs map[uint64]searchAutocompleteSource
+
imageCache *imageCache
}
func newApplication(c *config) (*application, error) {
app := &application{
- Version: buildVersion,
- CreatedAt: time.Now(),
- Config: *c,
- slugToPage: make(map[string]*page),
- widgetByID: make(map[uint64]widget),
- widgetToPage: make(map[uint64]*page),
- sseClients: make(map[*sseClient]struct{}),
- imageProxyURLs: make(map[string]imageProxyInfo),
- todoListIDToPage: make(map[string]*page),
+ Version: buildVersion,
+ CreatedAt: time.Now(),
+ Config: *c,
+ slugToPage: make(map[string]*page),
+ widgetByID: make(map[uint64]widget),
+ widgetByAPIID: make(map[string]widget),
+ apiRateRequests: make(map[string]*apiRateWindow),
+ widgetToPage: make(map[uint64]*page),
+ sseClients: make(map[*sseClient]struct{}),
+ imageProxyURLs: make(map[string]imageProxyInfo),
+ todoListIDToPage: make(map[string]*page),
+ searchAutocompleteURLs: make(map[uint64]searchAutocompleteSource),
}
config := &app.Config
- //
- // Init auth
- //
-
hasAnyAuth := len(config.Auth.Users) > 0 || config.Auth.OIDC != nil
if hasAnyAuth {
secretBytes, err := base64.StdEncoding.DecodeString(config.Auth.SecretKey)
@@ -121,10 +131,17 @@ func newApplication(c *config) (*application, error) {
for username := range config.Auth.Users {
user := config.Auth.Users[username]
- usernameHash, err := computeUsernameHash(username, app.authSecretKey)
+
+ credential := user.PasswordHashString
+ if credential == "" {
+ credential = user.Password
+ }
+
+ usernameHash, err := computeUsernameHash(username, credential, app.authSecretKey)
if err != nil {
return nil, fmt.Errorf("computing username hash for user %s: %v", username, err)
}
+ user.usernameHash = usernameHash
app.usernameHashToUsername[string(usernameHash)] = username
if user.PasswordHashString != "" {
@@ -154,10 +171,6 @@ func newApplication(c *config) (*application, error) {
app.OIDCEnabled = true
}
- //
- // Init themes
- //
-
if !config.Theme.DisablePicker {
themeKeys := make([]string, 0, 2)
themeProps := make([]*themeProperties, 0, 2)
@@ -235,10 +248,6 @@ func newApplication(c *config) (*application, error) {
config.Server.trustedProxyNets = append(config.Server.trustedProxyNets, ipnet)
}
- //
- // Init pages
- //
-
app.slugToPage[""] = &config.Pages[0]
dynamicUpdateEnabled := true
@@ -248,6 +257,11 @@ func newApplication(c *config) (*application, error) {
app.DynamicUpdateEnabled = dynamicUpdateEnabled
+ app.EditorEnabled = editorEnabledFromEnv()
+ if !app.EditorEnabled {
+ warnAboutIgnoredEditorConfig(config)
+ }
+
app.imageCache = newImageCache(config.Server.BaseURL, config.Server.CacheDir)
providers := &widgetProviders{
@@ -258,6 +272,8 @@ func newApplication(c *config) (*application, error) {
app: app,
}
+ usedSlugs := make(map[string]struct{}, len(config.Pages))
+
for p := range config.Pages {
page := &config.Pages[p]
page.PrimaryColumnIndex = -1
@@ -272,6 +288,15 @@ func newApplication(c *config) (*application, error) {
return nil, fmt.Errorf("page slug \"%s\" is reserved", page.Slug)
}
+ baseSlug := page.Slug
+ for i := 2; ; i++ {
+ if _, taken := usedSlugs[page.Slug]; !taken {
+ break
+ }
+ page.Slug = fmt.Sprintf("%s-%d", baseSlug, i)
+ }
+ usedSlugs[page.Slug] = struct{}{}
+
app.slugToPage[page.Slug] = page
if page.Width == "default" {
@@ -302,6 +327,9 @@ func newApplication(c *config) (*application, error) {
registerWidget = func(widget widget) {
app.widgetByID[widget.GetID()] = widget
app.widgetToPage[widget.GetID()] = page
+ if apiID := widget.GetAPIID(); apiID != "" {
+ app.widgetByAPIID[apiID] = widget
+ }
widget.setProviders(providers)
switch v := widget.(type) {
@@ -335,6 +363,32 @@ func newApplication(c *config) (*application, error) {
}
}
+ for id, w := range app.widgetByID {
+ sw, ok := w.(*searchWidget)
+ if !ok || !sw.IncludeBookmarks {
+ continue
+ }
+
+ var pageFilter *page
+ if !sw.CrossPageBookmarks {
+ pageFilter = app.widgetToPage[id]
+ }
+
+ sw.collectBookmarks(app, pageFilter)
+ }
+
+ if config.API.Enabled {
+ // Slugs are only final at this point, so allowed-pages cannot be validated during config parsing.
+ for _, slug := range config.API.AllowedPages {
+ if _, exists := app.slugToPage[slug]; !exists {
+ return nil, fmt.Errorf("api: allowed-pages references unknown page slug %q", slug)
+ }
+ }
+ }
+
+ app.warnAboutAPIExposure()
+ app.warnAboutEditorExposure()
+
config.Theme.CustomCSSFile = app.resolveUserDefinedAssetPath(config.Theme.CustomCSSFile)
config.Branding.LogoURL = app.resolveUserDefinedAssetPath(config.Branding.LogoURL)
@@ -368,10 +422,6 @@ func newApplication(c *config) (*application, error) {
}
app.parsedManifest = []byte(manifest)
- //
- // Init todo storage
- //
-
needsTodoDB := false
for p := range config.Pages {
for _, w := range config.Pages[p].HeadWidgets {
@@ -491,8 +541,6 @@ func getMinUpdateIntervalForWidgets(ws widgets) (time.Duration, bool) {
widgetFound := false
if cw, ok := w.(*customAPIWidget); ok {
- // Only include custom-api widgets in global polling if they don't have update-interval set
- // Widgets with update-interval will poll independently on the client side
if cw.UpdateInterval == nil {
widgetFound = true
interval = 1 * time.Second
@@ -556,7 +604,6 @@ func (a *application) getAccessiblePages(user *authenticatedUser) []*page {
for i := range a.Config.Pages {
p := &a.Config.Pages[i]
if user == nil {
- // Unauthenticated: only pages with no restrictions (when RequireAuth is false)
if len(p.AllowedUsers) == 0 && len(p.AllowedGroups) == 0 {
pages = append(pages, p)
}
@@ -624,8 +671,6 @@ func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Re
page.mu.Lock()
defer page.mu.Unlock()
- // Determine cache-build status after widgets have had a chance to queue
- // image fetches to avoid missing the initial "building cache" response.
page.updateOutdatedWidgets()
if a.imageCache != nil {
isCacheBuilding = a.imageCache.IsBuildingCache()
@@ -646,40 +691,39 @@ func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Re
w.Write(responseBytes.Bytes())
}
-func (a *application) addressOfRequest(r *http.Request) string {
- remoteAddrWithoutPort := func() string {
- if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
- return host
- }
- return r.RemoteAddr
+func remoteAddrWithoutPort(r *http.Request) string {
+ if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
+ return host
}
+ return r.RemoteAddr
+}
- if !a.Config.Server.Proxied {
- return remoteAddrWithoutPort()
+func (a *application) ipIsTrustedProxy(ipStr string) bool {
+ ip := net.ParseIP(strings.TrimSpace(ipStr))
+ if ip == nil {
+ return false
}
+ for _, n := range a.Config.Server.trustedProxyNets {
+ if n.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
- remote := remoteAddrWithoutPort()
- trustedNets := a.Config.Server.trustedProxyNets
+// Without trusted-proxies there is nothing to check the peer against.
+func (a *application) requestCameThroughTrustedProxy(r *http.Request) bool {
+ return len(a.Config.Server.trustedProxyNets) == 0 || a.ipIsTrustedProxy(remoteAddrWithoutPort(r))
+}
- // Without a trusted-proxies allow-list, honoring XFF would let clients spoof.
- if len(trustedNets) == 0 {
- return remote
- }
+func (a *application) addressOfRequest(r *http.Request) string {
+ remote := remoteAddrWithoutPort(r)
- ipIsTrusted := func(ipStr string) bool {
- ip := net.ParseIP(strings.TrimSpace(ipStr))
- if ip == nil {
- return false
- }
- for _, n := range trustedNets {
- if n.Contains(ip) {
- return true
- }
- }
- return false
+ if !a.Config.Server.Proxied || len(a.Config.Server.trustedProxyNets) == 0 {
+ return remote
}
- if !ipIsTrusted(remote) {
+ if !a.ipIsTrustedProxy(remote) {
return remote
}
@@ -689,13 +733,12 @@ func (a *application) addressOfRequest(r *http.Request) string {
}
ips := strings.Split(forwardedFor, ",")
- // Walk right-to-left, skipping trusted proxies; return the first untrusted hop.
for i := len(ips) - 1; i >= 0; i-- {
candidate := strings.TrimSpace(ips[i])
if candidate == "" {
continue
}
- if ipIsTrusted(candidate) {
+ if a.ipIsTrustedProxy(candidate) {
continue
}
return candidate
@@ -775,6 +818,9 @@ func (a *application) handleWidgetActionRequest(w http.ResponseWriter, r *http.R
return
}
+ page.mu.Lock()
+ defer page.mu.Unlock()
+
widget.handleRequest(w, r)
}
@@ -787,7 +833,7 @@ func (a *application) VersionedAssetPath(asset string) string {
"?v=" + strconv.FormatInt(a.CreatedAt.Unix(), 10)
}
-const todoMaxBodyBytes = 1 << 20 // 1 MiB
+const todoMaxBodyBytes = 1 << 20
func (a *application) authorizeTodoRequest(w http.ResponseWriter, r *http.Request) (string, bool) {
listID := r.PathValue("listID")
@@ -849,10 +895,8 @@ func (a *application) handleTodoSave(w http.ResponseWriter, r *http.Request) {
func (a *application) securityHeadersMiddleware(next http.Handler) http.Handler {
frameAncestors := "'self'"
- xFrameOptions := "SAMEORIGIN"
if len(a.Config.Server.AllowedEmbedHosts) > 0 {
frameAncestors = "'self' " + strings.Join(a.Config.Server.AllowedEmbedHosts, " ")
- xFrameOptions = "ALLOW-FROM " + strings.Join(a.Config.Server.AllowedEmbedHosts, " ")
}
csp := "default-src 'self'; " +
"img-src 'self' data: blob: https: http:; " +
@@ -869,7 +913,10 @@ func (a *application) securityHeadersMiddleware(next http.Handler) http.Handler
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
- h.Set("X-Frame-Options", xFrameOptions)
+ // Only the default case is expressible in this header, embed hosts are covered by frame-ancestors.
+ if len(a.Config.Server.AllowedEmbedHosts) == 0 {
+ h.Set("X-Frame-Options", "SAMEORIGIN")
+ }
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy", csp)
if a.Config.Server.HTTPS || a.isRequestHTTPS(r) {
@@ -879,6 +926,40 @@ func (a *application) securityHeadersMiddleware(next http.Handler) http.Handler
})
}
+// Blocks cross-site writes such as a form POST to the editor from another website. Browsers
+// always send Origin on unsafe methods; API clients that send none are left alone.
+func sameOriginMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ origin := r.Header.Get("Origin")
+
+ switch r.Method {
+ case http.MethodGet, http.MethodHead, http.MethodOptions:
+ default:
+ if origin == "" {
+ // No Origin and no cookies means an API client, not a browser.
+ if len(r.Cookies()) > 0 {
+ http.Error(w, "cross-origin request rejected", http.StatusForbidden)
+ return
+ }
+ } else if originHost(origin) != r.Host {
+ http.Error(w, "cross-origin request rejected", http.StatusForbidden)
+ return
+ }
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
+
+func originHost(origin string) string {
+ parsed, err := url.Parse(origin)
+ if err != nil {
+ return ""
+ }
+
+ return parsed.Host
+}
+
func (a *application) isRequestHTTPS(r *http.Request) bool {
if a.Config.Server.HTTPS {
return true
@@ -886,7 +967,7 @@ func (a *application) isRequestHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
}
- if a.Config.Server.Proxied {
+ if a.Config.Server.Proxied && a.requestCameThroughTrustedProxy(r) {
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
return false
@@ -932,19 +1013,38 @@ func (a *application) server() (func() error, func() error) {
mux.HandleFunc("PUT /api/todo/{listID}", a.handleTodoSave)
}
+ if a.Config.API.Enabled {
+ mux.HandleFunc("GET /api/v1/pages", a.handleAPIPages)
+ mux.HandleFunc("GET /api/v1/pages/{page}", a.handleAPIPage)
+ mux.HandleFunc("GET /api/v1/widgets/{apiID}", a.handleAPIWidget)
+ mux.HandleFunc("OPTIONS /api/v1/{path...}", a.handleAPIPreflight)
+ }
+
+ if a.EditorEnabled {
+ mux.HandleFunc("GET /api/editor/schema", a.handleEditorSchema)
+ mux.HandleFunc("GET /api/editor/status", a.handleEditorStatus)
+ mux.HandleFunc("GET /api/editor/config", a.handleEditorConfigLoad)
+ mux.HandleFunc("POST /api/editor/config", a.handleEditorConfigSave)
+ mux.HandleFunc("POST /api/editor/convert", a.handleEditorConvert)
+ mux.HandleFunc("POST /api/editor/custom-api/preview", a.handleEditorCustomAPIPreview)
+ mux.HandleFunc("GET /api/editor/dynawidgets/variables", a.handleEditorDynawidgetVariables)
+ }
+
mux.Handle(
fmt.Sprintf("GET /static/%s/{path...}", getStaticFSHash()),
- http.StripPrefix(
+ gzipTextAssets(http.StripPrefix(
"/static/"+getStaticFSHash(),
fileServerWithCache(http.FS(staticFS), STATIC_ASSETS_CACHE_DURATION),
- ),
+ )),
)
if a.Config.Server.CacheDir != "" {
- cacheHandler := http.StripPrefix(
+ // Cached files are fetched from remote widget content and an SVG among them would
+ // otherwise run scripts on this origin when opened directly.
+ cacheHandler := sandboxedHandler(http.StripPrefix(
"/.cache",
fileServerWithCache(http.Dir(a.Config.Server.CacheDir), REMOTE_IMAGE_CACHE_DURATION),
- )
+ ))
if a.RequiresAuth {
mux.Handle("GET /.cache/{path...}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -964,11 +1064,16 @@ func (a *application) server() (func() error, func() error) {
int(STATIC_ASSETS_CACHE_DURATION.Seconds()),
)
- mux.HandleFunc(fmt.Sprintf("GET /static/%s/css/bundle.css", getStaticFSHash()), func(w http.ResponseWriter, r *http.Request) {
- w.Header().Add("Cache-Control", assetCacheControlValue)
- w.Header().Add("Content-Type", "text/css; charset=utf-8")
- w.Write(bundledCSSContents)
- })
+ serveCSSBundle := func(contents []byte) http.Handler {
+ return gzipTextAssets(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Cache-Control", assetCacheControlValue)
+ w.Header().Add("Content-Type", "text/css; charset=utf-8")
+ w.Write(contents)
+ }))
+ }
+
+ mux.Handle(fmt.Sprintf("GET /static/%s/css/bundle.css", getStaticFSHash()), serveCSSBundle(bundledCSSContents))
+ mux.Handle(fmt.Sprintf("GET /static/%s/css/editor-bundle.css", getStaticFSHash()), serveCSSBundle(bundledEditorCSSContents))
mux.HandleFunc("GET /manifest.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Cache-Control", assetCacheControlValue)
@@ -998,7 +1103,7 @@ func (a *application) server() (func() error, func() error) {
server := http.Server{
Addr: fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port),
- Handler: a.securityHeadersMiddleware(mux),
+ Handler: a.securityHeadersMiddleware(sameOriginMiddleware(mux)),
}
start := func() error {
diff --git a/internal/dynacat/dynawidgets-templates.go b/internal/dynacat/dynawidgets-templates.go
new file mode 100644
index 00000000..51738154
--- /dev/null
+++ b/internal/dynacat/dynawidgets-templates.go
@@ -0,0 +1,266 @@
+package dynacat
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ dynawidgetsUpdateCheckInterval = 3 * 24 * time.Hour
+ dynawidgetsSlowUpdateCheckInterval = 14 * 24 * time.Hour
+ // Consecutive checks that found nothing before the slow interval takes over.
+ dynawidgetsSlowUpdateAfterChecks = 2
+ // How often a widget asks; the metadata interval decides whether that reaches the network.
+ dynawidgetsCheckPollInterval = time.Hour
+ dynawidgetsUpdateNoticeDuration = 7 * 24 * time.Hour
+)
+
+type dynawidgetsTemplateMeta struct {
+ URL string `json:"url"`
+ Title string `json:"title,omitempty"`
+ ETag string `json:"etag,omitempty"`
+ CheckedAt time.Time `json:"checked-at"`
+ UpdatedAt time.Time `json:"updated-at"`
+ UnchangedChecks int `json:"unchanged-checks,omitempty"`
+}
+
+// Templates that keep coming back unchanged are checked far less often.
+func (meta *dynawidgetsTemplateMeta) checkInterval() time.Duration {
+ if meta.UnchangedChecks >= dynawidgetsSlowUpdateAfterChecks {
+ return dynawidgetsSlowUpdateCheckInterval
+ }
+
+ return dynawidgetsUpdateCheckInterval
+}
+
+// Includes the branch so two branches cannot share a cached template.
+func dynawidgetsCacheKey(slug string, repo string) string {
+ return slug + "@" + repo
+}
+
+// Keeps generated paths inside the assets directory, whatever the slug looks like.
+func dynawidgetsAssetPath(slug string, repo string, suffix string) (string, error) {
+ if !dynawidgetsSlugPattern.MatchString(slug) {
+ return "", fmt.Errorf("invalid slug %q", slug)
+ }
+
+ if !dynawidgetsRepoPattern.MatchString(repo) {
+ return "", fmt.Errorf("invalid repo %q", repo)
+ }
+
+ path := filepath.Join(dynawidgetsAssetsDir, dynawidgetsCacheKey(slug, repo)+suffix)
+ absAssets, err := filepath.Abs(dynawidgetsAssetsDir)
+ if err != nil {
+ return path, nil
+ }
+ absPath, err := filepath.Abs(path)
+ if err != nil || !strings.HasPrefix(absPath, absAssets+string(filepath.Separator)) {
+ return "", errors.New("refusing to resolve template outside assets dir")
+ }
+
+ return path, nil
+}
+
+func dynawidgetsTemplateModTime(slug string, repo string) time.Time {
+ path, err := dynawidgetsAssetPath(slug, repo, ".txt")
+ if err != nil {
+ return time.Time{}
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ return time.Time{}
+ }
+
+ return info.ModTime()
+}
+
+// Metadata for every cached template, keyed by slug and branch.
+var dynawidgetsMetaPath = filepath.Join(dynawidgetsAssetsDir, "templates.meta.json")
+
+var dynawidgetsMetaMu sync.Mutex
+
+func dynawidgetsReadAllMeta() map[string]dynawidgetsTemplateMeta {
+ data, err := os.ReadFile(dynawidgetsMetaPath)
+ if err != nil {
+ return map[string]dynawidgetsTemplateMeta{}
+ }
+
+ entries := map[string]dynawidgetsTemplateMeta{}
+ if err := json.Unmarshal(data, &entries); err != nil {
+ slog.Warn("Ignoring unreadable dynawidgets metadata", "error", err, "path", dynawidgetsMetaPath)
+ return map[string]dynawidgetsTemplateMeta{}
+ }
+
+ return entries
+}
+
+func dynawidgetsReadMeta(slug string, repo string) *dynawidgetsTemplateMeta {
+ dynawidgetsMetaMu.Lock()
+ defer dynawidgetsMetaMu.Unlock()
+
+ meta, ok := dynawidgetsReadAllMeta()[dynawidgetsCacheKey(slug, repo)]
+ if !ok {
+ return nil
+ }
+
+ return &meta
+}
+
+func dynawidgetsWriteMeta(slug string, repo string, meta *dynawidgetsTemplateMeta) {
+ dynawidgetsMetaMu.Lock()
+ defer dynawidgetsMetaMu.Unlock()
+
+ entries := dynawidgetsReadAllMeta()
+ entries[dynawidgetsCacheKey(slug, repo)] = *meta
+
+ data, err := json.MarshalIndent(entries, "", " ")
+ if err != nil {
+ return
+ }
+
+ if err := os.MkdirAll(dynawidgetsAssetsDir, 0755); err != nil {
+ slog.Error("Failed to create dynawidgets assets directory", "error", err)
+ return
+ }
+
+ if err := os.WriteFile(dynawidgetsMetaPath, data, 0600); err != nil {
+ slog.Error("Failed to write dynawidgets metadata", "error", err, "path", dynawidgetsMetaPath)
+ }
+}
+
+var dynawidgetsCheckLocks = struct {
+ mu sync.Mutex
+ slugs map[string]*sync.Mutex
+}{slugs: map[string]*sync.Mutex{}}
+
+func dynawidgetsCheckLock(slug string, repo string) *sync.Mutex {
+ dynawidgetsCheckLocks.mu.Lock()
+ defer dynawidgetsCheckLocks.mu.Unlock()
+
+ key := repo + "/" + slug
+ lock, ok := dynawidgetsCheckLocks.slugs[key]
+ if !ok {
+ lock = &sync.Mutex{}
+ dynawidgetsCheckLocks.slugs[key] = lock
+ }
+
+ return lock
+}
+
+// Refetches the cached template once its check interval has passed and rewrites the cache file if it changed.
+func dynawidgetsCheckTemplate(slug string, repo string) error {
+ if repo == "" {
+ repo = dynawidgetsDefaultRepo
+ }
+
+ lock := dynawidgetsCheckLock(slug, repo)
+ lock.Lock()
+ defer lock.Unlock()
+
+ templatePath, err := dynawidgetsAssetPath(slug, repo, ".txt")
+ if err != nil {
+ return err
+ }
+
+ cached, err := os.ReadFile(templatePath)
+ if err != nil {
+ // Nothing cached yet, so the next initialize downloads it anyway.
+ return nil
+ }
+
+ meta := dynawidgetsReadMeta(slug, repo)
+ if meta == nil {
+ meta = &dynawidgetsTemplateMeta{}
+ }
+
+ if time.Since(meta.CheckedAt) < meta.checkInterval() {
+ return nil
+ }
+
+ templateURL := meta.URL
+ if templateURL == "" {
+ templateURL, meta.Title, err = dynawidgetsTemplateURL(slug, repo)
+ if err != nil {
+ return err
+ }
+ meta.URL = templateURL
+ }
+
+ body, notModified, etag, err := dynawidgetsDownloadTemplate(templateURL, meta.ETag)
+ if err != nil {
+ return err
+ }
+
+ now := time.Now()
+ meta.CheckedAt = now
+ meta.ETag = etag
+
+ if notModified || string(body) == string(cached) {
+ meta.UnchangedChecks++
+ dynawidgetsWriteMeta(slug, repo, meta)
+ return nil
+ }
+
+ if err := os.WriteFile(templatePath, body, 0600); err != nil {
+ return fmt.Errorf("writing template: %w", err)
+ }
+
+ meta.UpdatedAt = now
+ meta.UnchangedChecks = 0
+ dynawidgetsWriteMeta(slug, repo, meta)
+ slog.Info("Dynawidget template updated", "slug", slug, "repo", repo, "url", templateURL)
+
+ return nil
+}
+
+func dynawidgetsDownloadTemplate(templateURL string, etag string) (body []byte, notModified bool, newETag string, err error) {
+ parsedURL, err := url.Parse(templateURL)
+ if err != nil {
+ return nil, false, "", fmt.Errorf("invalid template URL %q: %w", templateURL, err)
+ }
+ if parsedURL.Scheme != "https" || parsedURL.Host != dynawidgetsTemplateHost {
+ return nil, false, "", fmt.Errorf(
+ "refusing to fetch template from unexpected host %q (must be https://%s)",
+ parsedURL.Host, dynawidgetsTemplateHost,
+ )
+ }
+
+ request, err := http.NewRequest(http.MethodGet, templateURL, nil)
+ if err != nil {
+ return nil, false, "", err
+ }
+ request.Header.Set("User-Agent", dynacatUserAgentString)
+ if etag != "" {
+ request.Header.Set("If-None-Match", etag)
+ }
+
+ resp, err := defaultHTTPClient.Do(request)
+ if err != nil {
+ return nil, false, "", fmt.Errorf("fetching template: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusNotModified {
+ return nil, true, etag, nil
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, false, "", fmt.Errorf("fetching template: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
+ }
+
+ body, err = readLimited(resp.Body)
+ if err != nil {
+ return nil, false, "", fmt.Errorf("reading template body: %w", err)
+ }
+
+ return body, false, resp.Header.Get("ETag"), nil
+}
diff --git a/internal/dynacat/editor-http.go b/internal/dynacat/editor-http.go
new file mode 100644
index 00000000..bf892aae
--- /dev/null
+++ b/internal/dynacat/editor-http.go
@@ -0,0 +1,371 @@
+package dynacat
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "io"
+ "log/slog"
+ "net/http"
+ "strings"
+)
+
+const (
+ editorMaxBodyBytes = 1 << 20
+ editorNotAllowedMessage = "you are not allowed to use the web UI editor"
+)
+
+func (a *application) handleEditorSchema(w http.ResponseWriter, r *http.Request) {
+ if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) {
+ return
+ }
+ writeJSON(w, http.StatusOK, allWidgetSchemas())
+}
+
+func (a *application) handleEditorStatus(w http.ResponseWriter, r *http.Request) {
+ if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) {
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]int64{"generation": a.CreatedAt.UnixNano()})
+}
+
+func (a *application) handleEditorConfigLoad(w http.ResponseWriter, r *http.Request) {
+ if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) {
+ return
+ }
+ user := a.getAuthenticatedUser(w, r)
+ if !a.userCanEditAnything(user) {
+ writeEditorForbidden(w)
+ return
+ }
+
+ view, err := a.buildEditorConfigView(user)
+ if err != nil {
+ slog.Error("Editor config load failed", "error", err)
+ writeEditorError(w, http.StatusInternalServerError, "editor_config_unreadable",
+ "the config could not be read into the editor",
+ "The config file is missing a pages list or failed to parse. The server log holds the parser error.")
+ return
+ }
+ writeJSON(w, http.StatusOK, view)
+}
+
+func (a *application) handleEditorConfigSave(w http.ResponseWriter, r *http.Request) {
+ if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) {
+ return
+ }
+ user := a.getAuthenticatedUser(w, r)
+ if !a.userCanEditAnything(user) {
+ writeEditorForbidden(w)
+ return
+ }
+
+ r.Body = http.MaxBytesReader(w, r.Body, editorMaxBodyBytes)
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ writeEditorError(w, http.StatusBadRequest, "editor_body_unreadable", "invalid body",
+ "The request body could not be read, most likely because it exceeded the 1 MiB editor limit.")
+ return
+ }
+
+ var mutation editorMutation
+ if err := json.Unmarshal(body, &mutation); err != nil {
+ writeEditorError(w, http.StatusBadRequest, "editor_body_not_json", "invalid JSON",
+ "The editor sent a body the server could not decode into a mutation.")
+ return
+ }
+
+ if err := a.applyEditorMutation(user, mutation); err != nil {
+ out := editorErrorBody{Error: err.Error(), Context: mutationContext(mutation)}
+ status := http.StatusBadRequest
+
+ switch e := err.(type) {
+ case *editorPermissionError:
+ status, out.Code = http.StatusForbidden, "editor_write_blocked"
+ out.Hint = "The config directory is read only or owned by another user. Check the volume mount and file ownership."
+ case *editorDisabledError:
+ status, out.Code = http.StatusForbidden, "editor_page_locked"
+ out.Hint = "Editing is off for this page. Check server.allow-editing and the user's restrict-editing list."
+ case *editorValidationError:
+ status, out.Code, out.Field = http.StatusUnprocessableEntity, "editor_config_invalid", e.field
+ out.Hint = "The change was rolled back because the resulting config did not validate. The message is the config validation error."
+ default:
+ out.Code = "editor_mutation_failed"
+ out.Hint = "The mutation could not be applied to the config document. The server log holds the full error."
+ slog.Error("Editor mutation failed", "op", mutation.Op, "error", err)
+ }
+
+ writeJSON(w, status, out)
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// Reports which variables a dynawidget template needs and which of them the container is missing.
+func (a *application) handleEditorDynawidgetVariables(w http.ResponseWriter, r *http.Request) {
+ if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) {
+ return
+ }
+ if !a.userCanEditAnything(a.getAuthenticatedUser(w, r)) {
+ writeEditorForbidden(w)
+ return
+ }
+
+ slug := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("widget")))
+ repo := strings.TrimSpace(r.URL.Query().Get("repo"))
+ if repo == "" {
+ repo = dynawidgetsDefaultRepo
+ }
+ if !dynawidgetsSlugPattern.MatchString(slug) || !dynawidgetsRepoPattern.MatchString(repo) {
+ writeJSON(w, http.StatusBadRequest, editorErrorBody{
+ Error: "invalid widget or repo",
+ Code: "dynawidgets_bad_reference",
+ Hint: "A slug is lowercase letters, digits and dashes, a repo is owner/name.",
+ Context: map[string]any{"widget": slug, "repo": repo},
+ })
+ return
+ }
+
+ variables, err := dynawidgetsRequiredVariables(slug, repo)
+ if err != nil {
+ writeJSON(w, http.StatusBadGateway, editorErrorBody{
+ Error: err.Error(),
+ Code: "dynawidgets_upstream_failed",
+ Hint: "The template could not be fetched from the repo. Check the slug, network access and the GitHub rate limit.",
+ Context: map[string]any{"widget": slug, "repo": repo},
+ })
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{"variables": variables})
+}
+
+type editorConvertRequest struct {
+ To string `json:"to"`
+ Value json.RawMessage `json:"value"`
+ Text string `json:"text"`
+}
+
+// Converts a list field between its structured form and its YAML text so the
+// editor can offer both views without a YAML library in the browser.
+func (a *application) handleEditorConvert(w http.ResponseWriter, r *http.Request) {
+ if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) {
+ return
+ }
+ if !a.userCanEditAnything(a.getAuthenticatedUser(w, r)) {
+ writeEditorForbidden(w)
+ return
+ }
+
+ r.Body = http.MaxBytesReader(w, r.Body, editorMaxBodyBytes)
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ writeEditorError(w, http.StatusBadRequest, "editor_body_unreadable", "invalid body",
+ "The request body could not be read, most likely because it exceeded the 1 MiB editor limit.")
+ return
+ }
+
+ var req editorConvertRequest
+ if err := json.Unmarshal(body, &req); err != nil {
+ writeEditorError(w, http.StatusBadRequest, "editor_body_not_json", "invalid JSON",
+ "The editor sent a body the server could not decode into a conversion request.")
+ return
+ }
+
+ switch req.To {
+ case "yaml":
+ var value any = []any{}
+ if len(req.Value) > 0 {
+ if err := json.Unmarshal(req.Value, &value); err != nil {
+ writeEditorError(w, http.StatusBadRequest, "convert_value_not_json", "invalid value",
+ "The value being converted to YAML was not valid JSON.")
+ return
+ }
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"text": nodeToText(valueNode(value))})
+ case "value":
+ node, err := parseYAMLValue(req.Text)
+ if err != nil {
+ writeEditorError(w, http.StatusUnprocessableEntity, "convert_yaml_invalid", err.Error(),
+ "The pasted text is not valid YAML. The message carries the line the parser stopped on.")
+ return
+ }
+ var value any
+ if err := node.Decode(&value); err != nil {
+ writeEditorError(w, http.StatusUnprocessableEntity, "convert_yaml_undecodable", err.Error(),
+ "The YAML parsed but could not be decoded into a plain value. Anchors and custom tags are not supported here.")
+ return
+ }
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ writeEditorError(w, http.StatusUnprocessableEntity, "convert_yaml_unsupported", "unsupported YAML structure",
+ "The YAML decoded into something JSON cannot represent, such as a map with non-string keys.")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]json.RawMessage{"value": encoded})
+ default:
+ writeEditorError(w, http.StatusBadRequest, "convert_unknown_target", "unknown conversion",
+ `The "to" field has to be either "yaml" or "value".`)
+ }
+}
+
+func writeJSON(w http.ResponseWriter, status int, body any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(body)
+}
+
+func writeJSONError(w http.ResponseWriter, status int, message string) {
+ writeJSON(w, status, map[string]string{"error": message})
+}
+
+type editorErrorBody struct {
+ Error string `json:"error"`
+ Code string `json:"code"`
+ Field string `json:"field,omitempty"`
+ Hint string `json:"hint,omitempty"`
+ Context map[string]any `json:"context,omitempty"`
+}
+
+func writeEditorError(w http.ResponseWriter, status int, code, message, hint string) {
+ writeJSON(w, status, editorErrorBody{Error: message, Code: code, Hint: hint})
+}
+
+// Which change was rejected, so the console can name the widget instead of only the config error.
+func mutationContext(m editorMutation) map[string]any {
+ ctx := map[string]any{"op": m.Op, "page": m.Page, "column": m.Column, "index": m.Index}
+ if m.WidgetType != "" {
+ ctx["widgetType"] = m.WidgetType
+ }
+ if len(m.Path) > 0 {
+ ctx["path"] = m.Path
+ }
+ if m.PresetKey != "" {
+ ctx["presetKey"] = m.PresetKey
+ }
+ return ctx
+}
+
+func writeEditorForbidden(w http.ResponseWriter) {
+ writeEditorError(w, http.StatusForbidden, "editor_forbidden", editorNotAllowedMessage,
+ "Your user is not listed in server.editing-users or server.editing-groups, or server.allow-editing is off.")
+}
+
+type editorPreviewRequest struct {
+ URL string `json:"url"`
+ AllowInsecure bool `json:"allow-insecure"`
+ Headers map[string]string `json:"headers"`
+ Subrequests map[string]*CustomAPIRequest `json:"subrequests"`
+ Template string `json:"template"`
+}
+
+type editorPreviewResponse struct {
+ JSON json.RawMessage `json:"json,omitempty"`
+ SubrequestsJSON map[string]json.RawMessage `json:"subrequestsJson,omitempty"`
+ HTML string `json:"html,omitempty"`
+ Error string `json:"error,omitempty"`
+ // Which step failed, since all of them come back as a 200 with an error string.
+ Stage string `json:"stage,omitempty"`
+ Subrequest string `json:"subrequest,omitempty"`
+ Hint string `json:"hint,omitempty"`
+}
+
+var editorPreviewHints = map[string]string{
+ "request": "The primary request never produced a usable response. Check the url, the headers and whether the host needs allow-insecure.",
+ "subrequest": "A subrequest failed, so no data reached the template. The subrequest key is in this payload.",
+ "template-parse": "The template text is not valid Go template syntax. The message carries the offending action.",
+ "template-render": "The template parsed but blew up while rendering, usually a field that is missing or of another type than expected.",
+}
+
+// Fetches a custom-api request and renders its template so the editor can show errors before the widget is saved.
+func (a *application) handleEditorCustomAPIPreview(w http.ResponseWriter, r *http.Request) {
+ if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) {
+ return
+ }
+ if !a.userCanEditAnything(a.getAuthenticatedUser(w, r)) {
+ writeEditorForbidden(w)
+ return
+ }
+
+ r.Body = http.MaxBytesReader(w, r.Body, editorMaxBodyBytes)
+ var req editorPreviewRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeEditorError(w, http.StatusBadRequest, "editor_body_not_json", "invalid JSON",
+ "The widget builder sent a body the server could not decode into a preview request.")
+ return
+ }
+
+ // Fetch and template problems are the point of the preview, so they travel in the body instead of a status code.
+ fail := func(stage, subrequest string, err error) {
+ writeJSON(w, http.StatusOK, editorPreviewResponse{
+ Error: err.Error(),
+ Stage: stage,
+ Subrequest: subrequest,
+ Hint: editorPreviewHints[stage],
+ })
+ }
+
+ primary := &CustomAPIRequest{URL: req.URL, AllowInsecure: req.AllowInsecure, Headers: req.Headers}
+ primaryData, err := fetchEditorPreviewRequest(primary)
+ if err != nil {
+ fail("request", "", err)
+ return
+ }
+
+ response := editorPreviewResponse{
+ JSON: json.RawMessage(primaryData.JSON.Raw),
+ SubrequestsJSON: make(map[string]json.RawMessage, len(req.Subrequests)),
+ }
+ subData := make(map[string]*customAPIResponseData, len(req.Subrequests))
+
+ for key, sub := range req.Subrequests {
+ data, err := fetchEditorPreviewRequest(sub)
+ if err != nil {
+ fail("subrequest", key, fmt.Errorf("subrequest %q: %w", key, err))
+ return
+ }
+ subData[key] = data
+ response.SubrequestsJSON[key] = json.RawMessage(data.JSON.Raw)
+ }
+
+ if req.Template != "" {
+ providers := &widgetProviders{
+ assetResolver: a.StaticAssetPath,
+ imageCache: a.imageCache,
+ baseURL: a.Config.Server.BaseURL,
+ app: a,
+ }
+
+ tmpl, err := template.New("").Funcs(customAPITemplateFuncs(providers)).Parse(req.Template)
+ if err != nil {
+ fail("template-parse", "", err)
+ return
+ }
+
+ html, _, err := renderCustomAPIData(primaryData, subData, customAPIOptions{}, tmpl)
+ if err != nil {
+ fail("template-render", "", err)
+ return
+ }
+ response.HTML = string(html)
+ }
+
+ writeJSON(w, http.StatusOK, response)
+}
+
+// ${VAR} references are deliberately left unexpanded: the preview body is caller-controlled, so
+// expanding them would hand any editor user the process environment.
+func fetchEditorPreviewRequest(req *CustomAPIRequest) (*customAPIResponseData, error) {
+ if req == nil {
+ return nil, errors.New("missing request")
+ }
+
+ if err := req.initialize(); err != nil {
+ return nil, err
+ }
+
+ return fetchCustomAPIResponse(context.Background(), req)
+}
diff --git a/internal/dynacat/embed.go b/internal/dynacat/embed.go
index 255a2b60..53dfa802 100644
--- a/internal/dynacat/embed.go
+++ b/internal/dynacat/embed.go
@@ -28,8 +28,6 @@ var staticFS, _ = fs.Sub(_staticFS, "static")
var templateFS, _ = fs.Sub(_templateFS, "templates")
func readAllFromStaticFS(path string) ([]byte, error) {
- // For some reason fs.FS only works with forward slashes, so in case we're
- // running on Windows or pass paths with backslashes we need to replace them.
path = strings.ReplaceAll(path, "\\", "/")
file, err := staticFS.Open(path)
@@ -92,10 +90,7 @@ func computeFSHash(files fs.FS) (string, error) {
var cssImportPattern = regexp.MustCompile(`(?m)^@import "(.*?)";$`)
var cssSingleLineCommentPattern = regexp.MustCompile(`(?m)^\s*\/\*.*?\*\/$`)
-// Yes, we bundle at runtime, give comptime pls
-var bundledCSSContents = func() []byte {
- const mainFilePath = "css/main.css"
-
+func bundleCSS(mainFilePath string) []byte {
var recursiveParseImports func(path string, depth int) ([]byte, error)
recursiveParseImports = func(path string, depth int) ([]byte, error) {
if depth > 20 {
@@ -107,7 +102,6 @@ var bundledCSSContents = func() []byte {
return nil, err
}
- // Normalize line endings, otherwise the \r's make the regex not match
mainFileContents = bytes.ReplaceAll(mainFileContents, []byte("\r\n"), []byte("\n"))
mainFileDir := filepath.Dir(path)
@@ -149,12 +143,14 @@ var bundledCSSContents = func() []byte {
panic(fmt.Sprintf("building CSS bundle: %v", err))
}
- // We could strip a bunch more unnecessary characters, but the biggest
- // win comes from removing the whitespace at the beginning of lines
- // since that's at least 4 bytes per property, which yielded a ~20% reduction.
contents = cssSingleLineCommentPattern.ReplaceAll(contents, nil)
contents = whitespaceAtBeginningOfLinePattern.ReplaceAll(contents, nil)
contents = bytes.ReplaceAll(contents, []byte("\n"), []byte(""))
return contents
-}()
+}
+
+var bundledCSSContents = bundleCSS("css/main.css")
+
+// Moved away from the main bundle so visitors who never open the editor won't download the file.
+var bundledEditorCSSContents = bundleCSS("css/editor.css")
diff --git a/internal/dynacat/image_cache.go b/internal/dynacat/image_cache.go
index 567bd1c1..e351c879 100644
--- a/internal/dynacat/image_cache.go
+++ b/internal/dynacat/image_cache.go
@@ -123,10 +123,6 @@ func (c *imageCache) findExistingFile(hashHex string, urlPath string) (string, b
return "", false
}
-func (c *imageCache) downloadAndCache(ctx context.Context, rawURL string, hashHex string, urlPath string) (string, error) {
- return c.downloadAndCacheWithClient(ctx, rawURL, hashHex, urlPath, false)
-}
-
func (c *imageCache) downloadAndCacheWithClient(ctx context.Context, rawURL string, hashHex string, urlPath string, allowInsecure bool) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
@@ -152,7 +148,7 @@ func (c *imageCache) downloadAndCacheWithClient(ctx context.Context, rawURL stri
ext = extensionFromContentType(resp.Header.Get("Content-Type"))
}
if ext == "" {
- ext = ".img"
+ return "", fmt.Errorf("unsupported content type %q for %s", resp.Header.Get("Content-Type"), rawURL)
}
tmpPath := filepath.Join(c.dir, hashHex+".tmp")
@@ -161,8 +157,11 @@ func (c *imageCache) downloadAndCacheWithClient(ctx context.Context, rawURL stri
return "", err
}
- _, copyErr := io.Copy(file, resp.Body)
+ written, copyErr := io.Copy(file, io.LimitReader(resp.Body, maxResponseBytes+1))
closeErr := file.Close()
+ if copyErr == nil && written > maxResponseBytes {
+ copyErr = errResponseTooLarge
+ }
if copyErr != nil {
_ = os.Remove(tmpPath)
return "", copyErr
diff --git a/internal/dynacat/logging.go b/internal/dynacat/logging.go
index 2eb17820..2eccead9 100644
--- a/internal/dynacat/logging.go
+++ b/internal/dynacat/logging.go
@@ -83,6 +83,11 @@ func levelColor(level slog.Level) string {
}
}
+// Writes past slog so LOG_LEVEL cannot hide it. For warnings the user must not miss.
+func printUnsuppressableWarning(message string) {
+ fmt.Fprintf(os.Stderr, "%s%sWARN %s%s\n", ansiYellow, ansiBold, ansiReset, message)
+}
+
func (h *prettyHandler) Handle(_ context.Context, r slog.Record) error {
var b strings.Builder
@@ -110,7 +115,7 @@ func (h *prettyHandler) Handle(_ context.Context, r slog.Record) error {
b.WriteString(key)
b.WriteString("=")
b.WriteString(ansiReset)
- b.WriteString(a.Value.String())
+ b.WriteString(redactSecretQueryParams(a.Value.String()))
}
for _, a := range h.attrs {
diff --git a/internal/dynacat/main.go b/internal/dynacat/main.go
index f4c676f0..8d9018ca 100644
--- a/internal/dynacat/main.go
+++ b/internal/dynacat/main.go
@@ -29,7 +29,6 @@ func Main() int {
}
}
- // Resolve config path with fallback to glance.yml for backward compatibility
options.configPath = resolveConfigPath(options.configPath)
switch options.intent {
@@ -41,6 +40,11 @@ func Main() int {
return 1
}
+ if err := serveFirstRunSetupIfNoConfig(options.configPath); err != nil {
+ fmt.Println(err)
+ return 1
+ }
+
if err := serveApp(options.configPath); err != nil {
fmt.Println(err)
return 1
@@ -103,14 +107,11 @@ func Main() int {
return 0
}
-// resolveConfigPath falls back to glance.yml if dynacat.yml doesn't exist,
-// for backward compatibility with legacy Glance configurations
func resolveConfigPath(primaryPath string) string {
if stat, err := os.Stat(primaryPath); err == nil && !stat.IsDir() && stat.Size() > 0 {
return primaryPath
}
- // Only fall back to glance.yml when the primary path ends with dynacat.yml
if filepath.Base(primaryPath) != "dynacat.yml" {
return primaryPath
}
@@ -125,9 +126,7 @@ func resolveConfigPath(primaryPath string) string {
}
func serveApp(configPath string) error {
- // TODO: refactor if this gets any more complex, the current implementation is
- // difficult to reason about due to all of the callbacks and simultaneous operations,
- // use a single goroutine and a channel to initiate synchronous changes to the server
+ // TODO: refactor, hard to reason about with all the callbacks and simultaneous operations
exitChannel := make(chan struct{})
hadValidConfigOnStartup := false
var stopServer func() error
@@ -158,6 +157,7 @@ func serveApp(configPath string) error {
return
}
+ app.configPath = configPath
if !hadValidConfigOnStartup {
hadValidConfigOnStartup = true
@@ -203,6 +203,7 @@ func serveApp(configPath string) error {
if err != nil {
return fmt.Errorf("creating application: %w", err)
}
+ app.configPath = configPath
startServer, _ := app.server()
if err := startServer(); err != nil {
@@ -223,7 +224,6 @@ func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool {
return false
}
- // dynacat.yml wasn't mounted to begin with or was incorrectly mounted as a directory
if stat, err := os.Stat("dynacat.yml"); err != nil || stat.IsDir() {
return false
}
diff --git a/internal/dynacat/request-share.go b/internal/dynacat/request-share.go
index 151fa348..9c1b3bf6 100644
--- a/internal/dynacat/request-share.go
+++ b/internal/dynacat/request-share.go
@@ -3,43 +3,20 @@ package dynacat
import (
"context"
"fmt"
- "io"
"net/http"
+ "slices"
"strings"
"sync"
"time"
)
-// Shared HTTP request layer.
-//
-// Multiple widgets often request the same external endpoint (for example two
-// repository widgets watching the same repo). Routing their GET requests
-// through globalSharedFetcher collapses identical concurrent requests into a
-// single fetch (singleflight) and keeps a short response cache so near
-// sequential requests reuse the result instead of hitting the endpoint again.
-//
-// The cache is requester tolerant: each caller passes its own maxAge (its
-// widget cacheDuration) and only reuses an entry younger than that. A short
-// cache widget refetches on its own schedule while a long cache widget reuses
-// freely, so every widget's configured cache is respected.
-//
-// Only GET requests are shared. POST and other methods (auth, GraphQL) always
-// go out directly.
-
const (
- // defaultSharedFetchMaxAge is used when a caller does not supply a maxAge
- // (no widget cacheDuration in the request context).
defaultSharedFetchMaxAge = 1 * time.Minute
- // sharedFetchMaxPruneAge is the age past which a cached entry is dropped
- // during a sweep. Longer than any realistic widget reuse window.
sharedFetchMaxPruneAge = 1 * time.Hour
- // sharedFetchPruneThreshold triggers a lazy sweep once the map grows past it.
sharedFetchPruneThreshold = 256
- // sharedFetchInfiniteMaxAge is handed to infinite cache widgets so they
- // reuse any entry that has not been pruned yet.
sharedFetchInfiniteMaxAge = sharedFetchMaxPruneAge
)
@@ -59,15 +36,7 @@ type sharedFetcher struct {
var globalSharedFetcher = &sharedFetcher{entries: make(map[string]*sharedFetchEntry)}
-// sharedFetchRelevantHeaders are folded into the cache key so requests that
-// differ in auth, content negotiation or conditional state never share.
-var sharedFetchRelevantHeaders = []string{
- "Authorization",
- "Accept",
- "Content-Type",
- "If-None-Match",
- "If-Modified-Since",
-}
+var sharedFetchIgnoredHeaders = []string{"User-Agent"}
func sharedFetchKey(client requestDoer, req *http.Request) string {
var b strings.Builder
@@ -76,22 +45,26 @@ func sharedFetchKey(client requestDoer, req *http.Request) string {
b.WriteByte(0)
b.WriteString(req.URL.String())
b.WriteByte(0)
- // Client discriminator: requests issued through different clients (secure,
- // insecure TLS, reddit uTLS, per widget proxy) must never share a response.
b.WriteString(fmt.Sprintf("%p", client))
- for _, h := range sharedFetchRelevantHeaders {
+ names := make([]string, 0, len(req.Header))
+ for name := range req.Header {
+ if !slices.Contains(sharedFetchIgnoredHeaders, name) {
+ names = append(names, name)
+ }
+ }
+ slices.Sort(names)
+
+ for _, name := range names {
+ b.WriteByte(0)
+ b.WriteString(name)
b.WriteByte(0)
- b.WriteString(req.Header.Get(h))
+ b.WriteString(strings.Join(req.Header.Values(name), ","))
}
return hashString(b.String())
}
-// do returns a shared response for a GET request. Concurrent identical requests
-// share a single in flight fetch; a completed entry is reused while it is no
-// older than maxAge. The returned body is shared read only across callers and
-// must not be mutated (callers only unmarshal it).
func (f *sharedFetcher) do(client requestDoer, req *http.Request, maxAge time.Duration) (int, http.Header, []byte, error) {
key := sharedFetchKey(client, req)
@@ -101,20 +74,16 @@ func (f *sharedFetcher) do(client requestDoer, req *http.Request, maxAge time.Du
if entry, ok := f.entries[key]; ok {
select {
case <-entry.done:
- // Completed. Reuse if fresh and successful.
if entry.err == nil && time.Since(entry.fetchedAt) <= maxAge {
f.mu.Unlock()
return entry.status, entry.header, entry.body, nil
}
- // Stale or errored. If another caller already replaced it, loop
- // and re evaluate; otherwise claim the refetch ourselves.
if f.entries[key] != entry {
f.mu.Unlock()
continue
}
delete(f.entries, key)
default:
- // In flight. Wait for it, then re evaluate from the top.
f.mu.Unlock()
<-entry.done
continue
@@ -130,7 +99,6 @@ func (f *sharedFetcher) do(client requestDoer, req *http.Request, maxAge time.Du
f.mu.Lock()
if err != nil {
- // Do not cache transport errors; let the next caller retry.
if f.entries[key] == newEntry {
delete(f.entries, key)
}
@@ -151,8 +119,6 @@ func (f *sharedFetcher) do(client requestDoer, req *http.Request, maxAge time.Du
}
}
-// pruneLocked drops entries older than sharedFetchMaxPruneAge once the map has
-// grown past the threshold. Caller must hold f.mu.
func (f *sharedFetcher) pruneLocked() {
if len(f.entries) <= sharedFetchPruneThreshold {
return
@@ -166,13 +132,10 @@ func (f *sharedFetcher) pruneLocked() {
delete(f.entries, key)
}
default:
- // Still in flight; keep it.
}
}
}
-// doRequestReadAll issues a request and reads the full response body. The
-// returned header is a clone so concurrent readers never race the http stack.
func doRequestReadAll(client requestDoer, req *http.Request) (int, http.Header, []byte, error) {
resp, err := client.Do(req)
if err != nil {
@@ -180,7 +143,7 @@ func doRequestReadAll(client requestDoer, req *http.Request) (int, http.Header,
}
defer resp.Body.Close()
- body, err := io.ReadAll(resp.Body)
+ body, err := readLimited(resp.Body)
if err != nil {
return 0, nil, nil, err
}
@@ -199,8 +162,6 @@ func sharedFetchMaxAgeFromContext(ctx context.Context) (time.Duration, bool) {
return d, ok
}
-// sharedFetchMaxAgeForRequest derives the reuse window for a request from its
-// context, falling back to the default when no widget cacheDuration is present.
func sharedFetchMaxAgeForRequest(req *http.Request) time.Duration {
d, ok := sharedFetchMaxAgeFromContext(req.Context())
if !ok {
@@ -208,7 +169,6 @@ func sharedFetchMaxAgeForRequest(req *http.Request) time.Duration {
}
if d < 0 {
- // Infinite cache widget: reuse any entry that has not been pruned.
return sharedFetchInfiniteMaxAge
}
if d == 0 {
diff --git a/internal/dynacat/setup.go b/internal/dynacat/setup.go
new file mode 100644
index 00000000..14efe68b
--- /dev/null
+++ b/internal/dynacat/setup.go
@@ -0,0 +1,320 @@
+package dynacat
+
+import (
+ "context"
+ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync"
+ "unicode"
+
+ "gopkg.in/yaml.v3"
+)
+
+const (
+ firstRunSetupAddr = ":8080"
+ firstRunSetupMaxBodyBytes = 4 << 10
+ firstRunMaxPageNameLength = 40
+ firstRunPresetEmpty = "empty"
+ firstRunDefaultPageName = "Home"
+ firstRunAllowedNameSymbols = " -_.()"
+)
+
+//go:embed starters
+var starterFS embed.FS
+
+var firstRunSetupTemplate = mustParseTemplate("first-run-setup.html")
+
+// starterPreset drives both the setup page and the config it writes.
+type starterPreset struct {
+ Key string
+ Label string
+ DefaultName string
+ Description string
+ Widgets []string
+ Icon template.HTML
+ file string
+}
+
+var starterPresets = []starterPreset{
+ {
+ Key: firstRunPresetEmpty,
+ Label: "Empty page",
+ DefaultName: firstRunDefaultPageName,
+ Description: "One empty column, every widget added by you",
+ Icon: template.HTML(``),
+ },
+ {
+ Key: "startpage",
+ Label: "Startpage",
+ DefaultName: "Startpage",
+ Description: "A slim, centered launcher for your services and links",
+ Widgets: []string{"search", "monitor", "bookmarks"},
+ Icon: template.HTML(``),
+ file: "startpage.yml",
+ },
+ {
+ Key: "markets",
+ Label: "Markets",
+ DefaultName: "Markets",
+ Description: "Indices, crypto and stocks next to financial news",
+ Widgets: []string{"markets", "rss", "reddit", "videos"},
+ Icon: template.HTML(``),
+ file: "markets.yml",
+ },
+ {
+ Key: "gaming",
+ Label: "Gaming",
+ DefaultName: "Gaming",
+ Description: "Twitch charts, gaming subreddits and video channels",
+ Widgets: []string{"twitch", "reddit", "videos"},
+ Icon: template.HTML(``),
+ file: "gaming.yml",
+ },
+}
+
+type firstRunSetupView struct {
+ ConfigPath string
+ MaxNameLength int
+ Presets []starterPreset
+}
+
+func findStarterPreset(key string) (starterPreset, bool) {
+ for _, preset := range starterPresets {
+ if preset.Key == key {
+ return preset, true
+ }
+ }
+
+ return starterPreset{}, false
+}
+
+// configIsMissingOrEmpty reports whether there is nothing usable at path yet.
+func configIsMissingOrEmpty(path string) bool {
+ stat, err := os.Stat(path)
+ if err != nil {
+ return errors.Is(err, fs.ErrNotExist)
+ }
+
+ return !stat.IsDir() && stat.Size() == 0
+}
+
+type firstRunSetup struct {
+ configPath string
+ mu sync.Mutex
+ done bool
+ onDone func()
+}
+
+func (s *firstRunSetup) isDone() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ return s.done
+}
+
+// serveFirstRunSetupIfNoConfig blocks on a temporary server that writes the initial config.
+func serveFirstRunSetupIfNoConfig(configPath string) error {
+ if !configIsMissingOrEmpty(configPath) {
+ return nil
+ }
+
+ slog.Warn(
+ "No config file found, serving the first run setup page - anyone who can reach this server can create the initial config",
+ "path", configPath,
+ )
+
+ mux := http.NewServeMux()
+ server := &http.Server{Addr: firstRunSetupAddr, Handler: mux}
+
+ setup := &firstRunSetup{
+ configPath: configPath,
+ onDone: func() { go server.Shutdown(context.Background()) },
+ }
+
+ mux.HandleFunc("POST /api/setup", setup.handleCreate)
+
+ // The page polls this until the real app answers on the same port.
+ mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ })
+
+ mux.Handle("GET /static/css/bundle.css", gzipTextAssets(http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Content-Type", "text/css; charset=utf-8")
+ w.Write(bundledCSSContents)
+ },
+ )))
+
+ mux.Handle("GET /static/{path...}", gzipTextAssets(http.StripPrefix(
+ "/static",
+ fileServerWithCache(http.FS(staticFS), STATIC_ASSETS_CACHE_DURATION),
+ )))
+
+ view := firstRunSetupView{
+ ConfigPath: configPath,
+ MaxNameLength: firstRunMaxPageNameLength,
+ Presets: starterPresets,
+ }
+
+ mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ firstRunSetupTemplate.Execute(w, view)
+ })
+
+ if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ return fmt.Errorf("serving first run setup page: %w", err)
+ }
+
+ if !setup.isDone() {
+ return errors.New("first run setup stopped before a config was created")
+ }
+
+ slog.Info("Created the initial config file", "path", configPath)
+
+ return nil
+}
+
+func (s *firstRunSetup) handleCreate(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, firstRunSetupMaxBodyBytes)
+
+ var body struct {
+ Name string `json:"name"`
+ Preset string `json:"preset"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeJSONError(w, http.StatusBadRequest, "Could not read the request")
+ return
+ }
+
+ preset, ok := findStarterPreset(body.Preset)
+ if !ok {
+ writeJSONError(w, http.StatusBadRequest, "Unknown starting point")
+ return
+ }
+
+ name, err := sanitizePageName(body.Name)
+ if err != nil {
+ writeJSONError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ contents, err := newStarterConfigYAML(name, preset)
+ if err != nil {
+ writeJSONError(w, http.StatusUnprocessableEntity, fmt.Sprintf("Could not create a valid config: %v", err))
+ return
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.done {
+ writeJSONError(w, http.StatusConflict, "A config file has already been created")
+ return
+ }
+
+ if !configIsMissingOrEmpty(s.configPath) {
+ writeJSONError(w, http.StatusConflict, "A config file already exists, restart the container to load it")
+ return
+ }
+
+ if err := writeStarterConfig(s.configPath, contents); err != nil {
+ slog.Error("Failed to write the initial config file", "path", s.configPath, "error", err)
+ writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("Could not write %s: %v", s.configPath, err))
+ return
+ }
+
+ s.done = true
+ w.WriteHeader(http.StatusNoContent)
+ s.onDone()
+}
+
+func newStarterConfigYAML(pageName string, preset starterPreset) ([]byte, error) {
+ page, err := starterPageNode(preset)
+ if err != nil {
+ return nil, err
+ }
+
+ setMappingKey(page, "name", scalarNode(pageName))
+
+ pages := sequenceNode()
+ pages.Content = append(pages.Content, page)
+
+ root := newMappingNode()
+ addPair(root, "pages", pages)
+ setBlockStyleDeep(root)
+
+ contents := marshalDocument(&yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}})
+
+ if _, err := newConfigFromYAML(contents); err != nil {
+ return nil, err
+ }
+
+ return contents, nil
+}
+
+func starterPageNode(preset starterPreset) (*yaml.Node, error) {
+ if preset.file == "" {
+ return newPageMapping(editorMutation{Layout: []string{"full"}}), nil
+ }
+
+ contents, err := starterFS.ReadFile("starters/" + preset.file)
+ if err != nil {
+ return nil, err
+ }
+
+ var doc yaml.Node
+ if err := yaml.Unmarshal(contents, &doc); err != nil {
+ return nil, err
+ }
+
+ if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.SequenceNode || len(doc.Content[0].Content) == 0 {
+ return nil, fmt.Errorf("starter %s does not contain a page", preset.file)
+ }
+
+ return doc.Content[0].Content[0], nil
+}
+
+// sanitizePageName keeps the name to characters that survive YAML quoting and config variable expansion.
+func sanitizePageName(raw string) (string, error) {
+ name := strings.Join(strings.Fields(raw), " ")
+ if name == "" {
+ name = firstRunDefaultPageName
+ }
+
+ if len([]rune(name)) > firstRunMaxPageNameLength {
+ return "", fmt.Errorf("the page name cannot be longer than %d characters", firstRunMaxPageNameLength)
+ }
+
+ for _, r := range name {
+ if unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune(firstRunAllowedNameSymbols, r) {
+ continue
+ }
+
+ return "", errors.New("the page name can only contain letters, digits, spaces and - _ . ( )")
+ }
+
+ if slices.Contains(reservedPageSlugs, titleToSlug(name)) {
+ return "", fmt.Errorf("%s is a reserved page name", name)
+ }
+
+ return name, nil
+}
+
+func writeStarterConfig(configPath string, contents []byte) error {
+ if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
+ return err
+ }
+
+ return os.WriteFile(configPath, contents, 0o644)
+}
diff --git a/internal/dynacat/sse.go b/internal/dynacat/sse.go
index 7df7f54c..6a861fc3 100644
--- a/internal/dynacat/sse.go
+++ b/internal/dynacat/sse.go
@@ -8,6 +8,8 @@ import (
"net"
"net/http"
"net/url"
+ "strconv"
+ "strings"
"sync"
"time"
)
@@ -18,9 +20,17 @@ type sseClient struct {
user *authenticatedUser
}
+// Dropped once full, widgets re-register their images on the next update.
+const imageProxyMaxEntries = 10000
+
func (a *application) registerImageProxy(hash string, url string, allowInsecure bool) {
a.imageProxyMu.Lock()
defer a.imageProxyMu.Unlock()
+
+ if len(a.imageProxyURLs) >= imageProxyMaxEntries {
+ clear(a.imageProxyURLs)
+ }
+
a.imageProxyURLs[hash] = imageProxyInfo{URL: url, AllowInsecure: allowInsecure}
}
@@ -31,7 +41,7 @@ func (a *application) getImageProxyInfo(hash string) (imageProxyInfo, bool) {
return info, ok
}
-func validateImageProxyURL(rawURL string) error {
+func validatePublicFetchURL(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return err
@@ -63,7 +73,6 @@ func isDisallowedIP(ip net.IP) bool {
ip.IsMulticast() || ip.IsUnspecified() || ip.IsPrivate() {
return true
}
- // Cloud metadata endpoints (169.254.x covered by link-local; include IMDSv2 fd00:ec2::254)
if ip.Equal(net.ParseIP("fd00:ec2::254")) {
return true
}
@@ -87,13 +96,12 @@ func (a *application) handleImageProxyRequest(w http.ResponseWriter, r *http.Req
return
}
- if err := validateImageProxyURL(info.URL); err != nil {
+ if err := validatePublicFetchURL(info.URL); err != nil {
http.Error(w, "Forbidden URL", http.StatusForbidden)
return
}
- // Fetch the image using the stored URL with the appropriate client
- client := ternary(info.AllowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
+ client := ternary(info.AllowInsecure, publicOnlyInsecureHTTPClient, publicOnlyHTTPClient)
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, info.URL, nil)
if err != nil {
http.Error(w, "Failed to fetch image", http.StatusInternalServerError)
@@ -115,16 +123,60 @@ func (a *application) handleImageProxyRequest(w http.ResponseWriter, r *http.Req
return
}
- // Set appropriate headers for the response
- w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
- w.Header().Set("Cache-Control", "public, max-age=2592000, immutable") // 30 days
+ // The proxied URL comes from remote widget content, so reflecting its content type would
+ // let an upstream serve HTML from this origin.
+ contentType := resp.Header.Get("Content-Type")
+ if extensionFromContentType(contentType) == "" {
+ http.Error(w, "Not an image", http.StatusUnsupportedMediaType)
+ return
+ }
+
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Content-Security-Policy", "sandbox")
+ w.Header().Set("Cache-Control", "public, max-age=2592000, immutable")
w.WriteHeader(http.StatusOK)
- // Stream the image to the client
- if _, err := io.Copy(w, resp.Body); err != nil {
- // Error writing response, client may have disconnected
+ io.Copy(w, io.LimitReader(resp.Body, maxResponseBytes))
+}
+
+func (a *application) respondWithOpenSearchSuggestions(w http.ResponseWriter, r *http.Request, requestURL string, client *http.Client) {
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, requestURL, nil)
+ if err != nil {
+ http.Error(w, "Failed to create request", http.StatusInternalServerError)
+ return
+ }
+ setBrowserUserAgentHeader(req)
+
+ resp, err := client.Do(req)
+ if err != nil {
+ http.Error(w, "Failed to fetch suggestions", http.StatusBadGateway)
+ return
+ }
+ defer resp.Body.Close()
+
+ var raw []json.RawMessage
+ if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil || len(raw) < 2 {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("[]"))
+ return
+ }
+
+ var suggestions []string
+ if err := json.Unmarshal(raw[1], &suggestions); err != nil {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("[]"))
return
}
+
+ type phrase struct {
+ Phrase string `json:"phrase"`
+ }
+ result := make([]phrase, len(suggestions))
+ for i, s := range suggestions {
+ result[i] = phrase{Phrase: s}
+ }
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(result)
}
func (a *application) handleSearchAutocompleteRequest(w http.ResponseWriter, r *http.Request) {
@@ -145,44 +197,39 @@ func (a *application) handleSearchAutocompleteRequest(w http.ResponseWriter, r *
if provider == "brave" {
braveURL := "https://search.brave.com/api/suggest?" + url.Values{"q": {query}, "rich": {"false"}}.Encode()
- req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, braveURL, nil)
- if err != nil {
- http.Error(w, "Failed to create request", http.StatusInternalServerError)
- return
- }
- setBrowserUserAgentHeader(req)
+ a.respondWithOpenSearchSuggestions(w, r, braveURL, publicOnlyHTTPClient)
+ return
+ }
- resp, err := defaultHTTPClient.Do(req)
+ if provider == "custom" {
+ widgetID, err := strconv.ParseUint(r.URL.Query().Get("widgetId"), 10, 64)
if err != nil {
- http.Error(w, "Failed to fetch suggestions", http.StatusBadGateway)
- return
- }
- defer resp.Body.Close()
-
- // Brave returns OpenSearch format: ["query", ["s1", "s2", ...]]
- var raw []json.RawMessage
- if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil || len(raw) < 2 {
w.WriteHeader(http.StatusOK)
w.Write([]byte("[]"))
return
}
- var suggestions []string
- if err := json.Unmarshal(raw[1], &suggestions); err != nil {
+ source, ok := a.searchAutocompleteURLs[widgetID]
+ if !ok {
w.WriteHeader(http.StatusOK)
w.Write([]byte("[]"))
return
}
- type phrase struct {
- Phrase string `json:"phrase"`
- }
- result := make([]phrase, len(suggestions))
- for i, s := range suggestions {
- result[i] = phrase{Phrase: s}
+ customURL := strings.ReplaceAll(source.URL, "{QUERY}", url.QueryEscape(query))
+ // Self-hosted instances named in the config are allowed to sit on a private
+ // address, unlike URLs that could otherwise be probed through this endpoint.
+ client := defaultHTTPClient
+ if !source.AllowPrivate {
+ if err := validatePublicFetchURL(customURL); err != nil {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("[]"))
+ return
+ }
+ client = publicOnlyHTTPClient
}
- w.WriteHeader(http.StatusOK)
- json.NewEncoder(w).Encode(result)
+
+ a.respondWithOpenSearchSuggestions(w, r, customURL, client)
return
}
@@ -194,7 +241,7 @@ func (a *application) handleSearchAutocompleteRequest(w http.ResponseWriter, r *
}
setBrowserUserAgentHeader(req)
- resp, err := defaultHTTPClient.Do(req)
+ resp, err := publicOnlyHTTPClient.Do(req)
if err != nil {
http.Error(w, "Failed to fetch suggestions", http.StatusBadGateway)
return
@@ -202,7 +249,7 @@ func (a *application) handleSearchAutocompleteRequest(w http.ResponseWriter, r *
defer resp.Body.Close()
w.WriteHeader(http.StatusOK)
- io.Copy(w, resp.Body)
+ io.Copy(w, io.LimitReader(resp.Body, maxResponseBytes))
}
func (a *application) handleSSEUpdates(w http.ResponseWriter, r *http.Request) {
@@ -239,7 +286,7 @@ func (a *application) handleSSEUpdates(w http.ResponseWriter, r *http.Request) {
a.sseRegisterClient(client)
defer a.sseUnregisterClient(client)
- authRecheck := time.NewTicker(60 * time.Second)
+ authRecheck := time.NewTicker(15 * time.Second)
defer authRecheck.Stop()
for {
diff --git a/internal/dynacat/starters/gaming.yml b/internal/dynacat/starters/gaming.yml
new file mode 100644
index 00000000..68e9e8e2
--- /dev/null
+++ b/internal/dynacat/starters/gaming.yml
@@ -0,0 +1,39 @@
+- name: Gaming
+ columns:
+ - size: small
+ widgets:
+ - type: twitch-top-games
+ limit: 20
+ collapse-after: 13
+ exclude:
+ - just-chatting
+ - pools-hot-tubs-and-beaches
+ - music
+ - art
+ - asmr
+
+ - size: full
+ widgets:
+ - type: group
+ widgets:
+ - type: reddit
+ show-thumbnails: true
+ subreddit: pcgaming
+ - type: reddit
+ subreddit: games
+
+ - type: videos
+ style: grid-cards
+ collapse-after-rows: 3
+ channels:
+ - UCNvzD7Z-g64bPXxGzaQaa4g # gameranx
+ - UCZ7AeeVbyslLM_8-nVy2B8Q # Skill Up
+ - UCHDxYLv8iovIbhrfl16CNyg # GameLinked
+ - UC9PBzalIcEQCsiIkq36PyUA # Digital Foundry
+
+ - size: small
+ widgets:
+ - type: reddit
+ subreddit: gamingnews
+ limit: 7
+ style: vertical-cards
diff --git a/internal/dynacat/starters/markets.yml b/internal/dynacat/starters/markets.yml
new file mode 100644
index 00000000..f375e97e
--- /dev/null
+++ b/internal/dynacat/starters/markets.yml
@@ -0,0 +1,86 @@
+- name: Markets
+ columns:
+ - size: small
+ widgets:
+ - type: markets
+ title: Indices
+ markets:
+ - symbol: SPY
+ name: S&P 500
+ - symbol: DX-Y.NYB
+ name: Dollar Index
+
+ - type: markets
+ title: Crypto
+ markets:
+ - symbol: BTC-USD
+ name: Bitcoin
+ - symbol: ETH-USD
+ name: Ethereum
+
+ - type: markets
+ title: Stocks
+ sort-by: absolute-change
+ markets:
+ - symbol: NVDA
+ name: NVIDIA
+ - symbol: AAPL
+ name: Apple
+ - symbol: MSFT
+ name: Microsoft
+ - symbol: GOOGL
+ name: Google
+ - symbol: AMD
+ name: AMD
+ - symbol: RDDT
+ name: Reddit
+ - symbol: AMZN
+ name: Amazon
+ - symbol: TSLA
+ name: Tesla
+ - symbol: INTC
+ name: Intel
+ - symbol: META
+ name: Meta
+
+ - size: full
+ widgets:
+ - type: rss
+ title: News
+ style: horizontal-cards
+ feeds:
+ - url: https://feeds.bloomberg.com/markets/news.rss
+ title: Bloomberg
+ - url: https://moxie.foxbusiness.com/google-publisher/markets.xml
+ title: Fox Business
+ - url: https://moxie.foxbusiness.com/google-publisher/technology.xml
+ title: Fox Business
+
+ - type: group
+ widgets:
+ - type: reddit
+ show-thumbnails: true
+ subreddit: technology
+ - type: reddit
+ show-thumbnails: true
+ subreddit: wallstreetbets
+
+ - type: videos
+ style: grid-cards
+ collapse-after-rows: 3
+ channels:
+ - UCvSXMi2LebwJEM1s4bz5IBA # New Money
+ - UCV6KDgJskWaEckne5aPA0aQ # Graham Stephan
+ - UCAzhpt9DmG6PnHXjmJTvRGQ # Federal Reserve
+
+ - size: small
+ widgets:
+ - type: rss
+ title: News
+ limit: 30
+ collapse-after: 13
+ feeds:
+ - url: https://www.ft.com/technology?format=rss
+ title: Financial Times
+ - url: https://feeds.a.dj.com/rss/RSSMarketsMain.xml
+ title: Wall Street Journal
diff --git a/internal/dynacat/starters/startpage.yml b/internal/dynacat/starters/startpage.yml
new file mode 100644
index 00000000..a7ed2b4f
--- /dev/null
+++ b/internal/dynacat/starters/startpage.yml
@@ -0,0 +1,58 @@
+- name: Startpage
+ width: slim
+ center-vertically: true
+ columns:
+ - size: full
+ widgets:
+ - type: search
+ autofocus: true
+
+ - type: monitor
+ cache: 1m
+ title: Services
+ sites:
+ - title: Jellyfin
+ url: https://example.com/
+ icon: si:jellyfin
+ - title: Gitea
+ url: https://example.com/
+ icon: si:gitea
+ - title: qBittorrent
+ url: https://example.com/
+ icon: si:qbittorrent
+ - title: Immich
+ url: https://example.com/
+ icon: si:immich
+ - title: AdGuard Home
+ url: https://example.com/
+ icon: si:adguard
+ - title: Vaultwarden
+ url: https://example.com/
+ icon: si:vaultwarden
+
+ - type: bookmarks
+ groups:
+ - title: General
+ links:
+ - title: Gmail
+ url: https://mail.google.com/mail/u/0/
+ - title: Amazon
+ url: https://www.amazon.com/
+ - title: Github
+ url: https://github.com/
+ - title: Entertainment
+ links:
+ - title: YouTube
+ url: https://www.youtube.com/
+ - title: Prime Video
+ url: https://www.primevideo.com/
+ - title: Disney+
+ url: https://www.disneyplus.com/
+ - title: Social
+ links:
+ - title: Reddit
+ url: https://www.reddit.com/
+ - title: Twitter
+ url: https://twitter.com/
+ - title: Instagram
+ url: https://www.instagram.com/
diff --git a/internal/dynacat/static/css/customapi-editor.css b/internal/dynacat/static/css/customapi-editor.css
new file mode 100644
index 00000000..bafc0750
--- /dev/null
+++ b/internal/dynacat/static/css/customapi-editor.css
@@ -0,0 +1,835 @@
+.editor-open-builder {
+ margin-right: auto;
+}
+
+@media (max-width: 1023px) {
+ .editor-open-builder {
+ display: none;
+ }
+}
+
+.capi-editor .editor-input {
+ background-color: var(--color-widget-background-highlight);
+}
+
+.capi-editor .capi-column .editor-input,
+.capi-editor .capi-source .editor-input,
+.capi-editor .capi-inspector .editor-input {
+ background-color: var(--color-background);
+}
+
+.capi-editor {
+ --capi-preview-width: 480px;
+ position: fixed;
+ inset: 0;
+ z-index: 65;
+ display: grid;
+ grid-template-rows: auto 1fr;
+ background: var(--color-background);
+ color: var(--color-text-base);
+}
+
+/* While previewing the field list is out of play, so it reads as locked. */
+.capi-previewing .capi-data {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.capi-previewing .capi-data > * {
+ pointer-events: none;
+}
+
+/* Selection means nothing while the rendered widget is on screen. */
+.capi-previewing .capi-column.capi-column-selected {
+ border-color: var(--color-separator);
+}
+
+/* Confirm dialogs opened from the editor are appended after it, so they need to sit above it. */
+.capi-editor ~ .editor-modal-overlay {
+ z-index: 66;
+}
+
+.capi-topbar {
+ grid-row: 1;
+ display: flex;
+ align-items: center;
+ padding: 9px 20px;
+ border-bottom: 1px solid var(--color-separator);
+}
+
+/* The rule keeps the data tools reading apart from the save actions they sit next to. */
+.capi-topbar-tools {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ padding-right: 12px;
+ border-right: 1px solid var(--color-separator);
+}
+
+.capi-topbar-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex: 1 1 auto;
+ gap: 12px;
+ min-width: 0;
+}
+
+.capi-topbar .editor-btn {
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.capi-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+}
+
+.capi-field-label,
+.capi-pane-title {
+ font-size: 1.1rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--color-text-subdue);
+}
+
+.capi-empty {
+ font-size: 1.2rem;
+ color: var(--color-text-subdue);
+}
+
+.capi-icon-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ border: none;
+ border-radius: var(--border-radius);
+ background: transparent;
+ color: var(--color-text-subdue);
+ font: inherit;
+ cursor: pointer;
+ transition: color 0.15s, background 0.15s;
+}
+
+.capi-icon-btn:hover {
+ color: var(--color-text-highlight);
+ background: var(--color-widget-background-highlight);
+}
+
+.capi-icon-btn svg {
+ display: block;
+ width: 15px;
+ height: 15px;
+}
+
+.capi-icon-btn-danger:hover,
+.capi-cell-remove:hover,
+.capi-source .capi-icon-btn:hover {
+ color: var(--color-negative);
+}
+
+.capi-icon-btn:disabled {
+ opacity: 0.35;
+ cursor: default;
+}
+
+.capi-icon-btn:disabled:hover {
+ color: var(--color-text-subdue);
+ background: transparent;
+}
+
+.capi-mini-btn {
+ padding: 4px 10px;
+ font-size: 1.1rem;
+}
+
+.capi-dragging {
+ opacity: 0.4;
+}
+
+.capi-body {
+ grid-row: 2;
+ display: grid;
+ grid-template-columns: 300px 1fr 320px;
+ min-height: 0;
+}
+
+.capi-pane {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ min-height: 0;
+ padding: 16px;
+ overflow-y: auto;
+ background: var(--color-widget-background);
+ scrollbar-width: thin;
+}
+
+.capi-data {
+ border-right: 1px solid var(--color-separator);
+}
+
+.capi-inspector {
+ border-left: 1px solid var(--color-separator);
+}
+
+.capi-net {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid color-mix(in srgb, var(--color-text-subdue) 45%, var(--color-separator));
+}
+
+.capi-net-collapsed {
+ gap: 4px;
+}
+
+.capi-net-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.capi-net-toggle svg {
+ transition: transform 0.15s ease;
+}
+
+.capi-net-toggle[aria-expanded="true"] svg {
+ transform: rotate(90deg);
+}
+
+.capi-net-summary {
+ font-size: 1.2rem;
+ color: var(--color-text-subdue);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.capi-net-body {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.capi-headers {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.capi-header-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: 6px;
+}
+
+.capi-header-row > .editor-input {
+ min-width: 0;
+ padding: 5px 8px;
+ font-size: 1.2rem;
+}
+
+.capi-header-row > .editor-input:last-child {
+ grid-column: 1 / -1;
+}
+
+.capi-palette {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.capi-palette .capi-pane-title {
+ grid-column: 1 / -1;
+}
+
+.capi-palette-btn {
+ padding: 8px 10px;
+ font-size: 1.2rem;
+ text-align: center;
+ cursor: grab;
+ user-select: none;
+}
+
+.capi-palette-btn:hover {
+ border-color: var(--color-primary);
+ color: var(--color-text-highlight);
+}
+
+.capi-source {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid var(--color-separator);
+ border-radius: var(--border-radius);
+ background: var(--color-widget-background-highlight);
+}
+
+.capi-source .editor-input {
+ padding: 5px 8px;
+ font-size: 1.2rem;
+}
+
+.capi-source .capi-field-label {
+ font-size: 1rem;
+}
+
+/* The pane is narrow, so the remove button rides on the label row instead of taking a column. */
+.capi-source .capi-field-with-action > .capi-icon-btn {
+ position: absolute;
+ top: -4px;
+ right: 0;
+}
+
+.capi-net .capi-mini-btn {
+ width: 100%;
+ color: var(--color-text-subdue);
+}
+
+.capi-net .capi-mini-btn:hover {
+ color: var(--color-text-highlight);
+}
+
+.capi-data-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.capi-data-group + .capi-data-group .capi-data-group-title,
+.capi-fields > .capi-data-group:not(:first-child) .capi-data-group-title {
+ margin-top: 10px;
+}
+
+.capi-data-group-title {
+ font-size: 1.2rem;
+ color: var(--color-text-highlight);
+}
+
+.capi-chip {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 6px 10px;
+ border: 1px solid var(--color-separator);
+ border-radius: var(--border-radius);
+ background: var(--color-widget-background-highlight);
+ cursor: grab;
+ user-select: none;
+ transition: border-color 0.15s, background 0.15s;
+}
+
+.capi-chip:hover {
+ border-color: var(--color-primary);
+ background: color-mix(in srgb, var(--color-primary) 8%, var(--color-widget-background-highlight));
+}
+
+.capi-chip-array {
+ border-style: dashed;
+}
+
+.capi-chip-array .capi-chip-path {
+ color: var(--color-primary);
+}
+
+.capi-chip-path {
+ font-size: 1.2rem;
+ color: var(--color-text-base);
+ overflow-wrap: anywhere;
+}
+
+.capi-chip-value {
+ flex-shrink: 0;
+ max-width: 45%;
+ font-size: 1.1rem;
+ color: var(--color-text-subdue);
+ text-align: right;
+ overflow-wrap: anywhere;
+}
+
+.capi-canvas {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ min-height: 0;
+ padding: 28px 24px 40px;
+ overflow-y: auto;
+ scrollbar-width: none;
+ background-color: var(--color-background);
+ background-image: radial-gradient(var(--color-separator) 1px, transparent 1px);
+ background-size: 16px 16px;
+}
+
+.capi-canvas::-webkit-scrollbar {
+ display: none;
+}
+
+.capi-warning {
+ width: 100%;
+ max-width: var(--capi-preview-width);
+ font-size: 1.2rem;
+ color: var(--color-negative);
+}
+
+.capi-preview .expand-toggle-button.container-expanded {
+ position: static;
+}
+
+.capi-preview {
+ width: 100%;
+ max-width: var(--capi-preview-width);
+}
+
+.capi-row-wrapper + .capi-row-wrapper {
+ margin-top: 1.2rem;
+}
+
+.capi-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px;
+ border: 1px dashed transparent;
+ border-radius: var(--border-radius);
+ text-align: center;
+ transition: border-color 0.15s, background 0.15s;
+}
+
+.capi-row-wrapper .capi-row-selected {
+ border-color: var(--color-separator);
+}
+
+.capi-row-wrapper .capi-row-drop {
+ border-style: solid;
+ border-color: var(--color-primary);
+}
+
+.capi-cell {
+ position: relative;
+ flex: 1 1 0;
+ min-width: 0;
+ padding: 6px;
+ border: 1px solid transparent;
+ border-radius: var(--border-radius);
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+}
+
+/* An icon block never stretches in the output, so it must not stretch here either. */
+.capi-cell-icon {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 62px;
+}
+
+.capi-cell:hover {
+ border-color: color-mix(in srgb, var(--color-text-subdue) 55%, transparent);
+}
+
+.capi-cell.capi-cell-selected {
+ border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
+}
+
+.capi-cell.capi-cell-drop {
+ border-style: dashed;
+ border-color: var(--color-primary);
+}
+
+.capi-cell-remove {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ width: 20px;
+ height: 20px;
+ opacity: 0;
+ transition: opacity 0.15s, color 0.15s;
+}
+
+.capi-cell-remove svg {
+ width: 12px;
+ height: 12px;
+}
+
+.capi-cell:hover .capi-cell-remove,
+.capi-cell-remove:focus-visible {
+ opacity: 1;
+}
+
+.capi-cell-add {
+ flex: 0 0 34px;
+ align-self: stretch;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: 1px dashed var(--color-separator);
+ border-radius: var(--border-radius);
+ background: transparent;
+ color: var(--color-text-subdue);
+ font: inherit;
+ /* The button is icon only, so any label text collapses instead of widening the row. */
+ font-size: 0;
+ cursor: pointer;
+ transition: color 0.15s, border-color 0.15s;
+}
+
+.capi-cell-add:hover {
+ border-color: var(--color-primary);
+ color: var(--color-primary);
+}
+
+.capi-cell-add svg {
+ width: 12px;
+ height: 12px;
+}
+
+/* Both reuse the right-pointing chevron. */
+.capi-row-up svg {
+ transform: rotate(-90deg);
+}
+
+.capi-row-down svg {
+ transform: rotate(90deg);
+}
+
+.capi-row-tools {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 2px;
+ margin-left: auto;
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+
+.capi-row-wrapper:hover .capi-row-tools,
+.capi-row-tools:focus-within {
+ opacity: 1;
+}
+
+/* Relative so the rows draw over the band layer behind them. */
+.capi-list {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ text-align: left;
+}
+
+.capi-list-cell {
+ min-width: 0;
+ padding: 2px 5px;
+ border: 1px solid transparent;
+ border-radius: var(--border-radius);
+ overflow-wrap: anywhere;
+ transition: border-color 0.15s, background 0.15s, opacity 0.15s;
+}
+
+.capi-list-cell:hover {
+ background: color-mix(in srgb, var(--color-text-subdue) 8%, transparent);
+}
+
+/* Fade everything outside the selected column, so the selection reads without any boxes. */
+.capi-list-cell-muted {
+ opacity: 0.35;
+ transition: opacity 0.15s;
+}
+
+.capi-list-cell.capi-list-cell-muted:hover {
+ opacity: 1;
+}
+
+/* A drop replaces the column it lands on, so its cells come back to full strength under the band. */
+.capi-list-cell-over,
+.capi-list-cell.capi-list-cell-over,
+.capi-list-cell.capi-list-cell-muted.capi-list-cell-over {
+ opacity: 1;
+ background: transparent;
+}
+
+/* Header cells are plain divs, so they need the .capi-list-cell padding to line up with the values. */
+.capi-list-head > div {
+ padding: 0 5px;
+}
+
+/* Matches the .ui-icon inside it, so the column takes the same space as in the rendered widget. */
+.capi-list-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.3rem;
+ min-width: 2.3rem;
+ flex: 0 0 2.3rem;
+}
+
+.capi-list-cell.capi-list-icon,
+.capi-list-head > .capi-list-icon {
+ padding: 0;
+}
+
+.capi-list-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: 100%;
+}
+
+.capi-list-body {
+ display: flex;
+ align-items: stretch;
+ gap: 6px;
+}
+
+.capi-list-stack {
+ position: relative;
+ flex: 1 1 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+/* Both layers repeat the column widths of a row, so they line up without measuring anything. */
+.capi-col-layer {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: stretch;
+ gap: 10px;
+}
+
+.capi-col-bands {
+ pointer-events: none;
+}
+
+/* A column is a drop target over its whole height, not only where its text happens to sit. */
+.capi-col-hits {
+ pointer-events: none;
+}
+
+.capi-drag-active .capi-col-hits {
+ pointer-events: auto;
+}
+
+/* One band down the whole column, so the target reads as a single area instead of a box per cell. */
+.capi-col-band {
+ position: relative;
+ opacity: 0;
+ transition: opacity 0.12s;
+}
+
+.capi-col-band::before {
+ content: "";
+ position: absolute;
+ inset: -4px;
+ border-radius: var(--border-radius);
+ background: color-mix(in srgb, var(--color-primary) 18%, transparent);
+}
+
+.capi-col-band.capi-col-band-on {
+ opacity: 1;
+}
+
+/* The header draws over the band layer, so the highlight sits behind the labels. */
+.capi-list-head {
+ position: relative;
+}
+
+/* Adding a column is a drop only affordance, so it appears once a field is in flight. */
+.capi-col-add {
+ display: none;
+ flex: 0 0 34px;
+ align-items: center;
+ justify-content: center;
+ border: 1px dashed var(--color-separator);
+ border-radius: var(--border-radius);
+ color: var(--color-text-subdue);
+ cursor: pointer;
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
+}
+
+.capi-drag-active .capi-col-add {
+ display: flex;
+}
+
+.capi-col-add:hover {
+ border-color: var(--color-primary);
+ color: var(--color-primary);
+}
+
+.capi-col-add.capi-col-add-over {
+ border-style: solid;
+ border-color: var(--color-primary);
+ background: color-mix(in srgb, var(--color-primary) 16%, transparent);
+ color: var(--color-primary);
+}
+
+.capi-col-add svg {
+ width: 12px;
+ height: 12px;
+}
+
+.capi-hidden-by-rule {
+ opacity: 0.45;
+}
+
+.capi-column-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.capi-field-with-action {
+ position: relative;
+}
+
+.capi-field-with-action > .editor-btn {
+ position: absolute;
+ top: 0;
+ right: 0;
+ padding: 0;
+ border: none;
+ background: none;
+ color: var(--color-text-base);
+ font-size: 1.1rem;
+}
+
+.capi-field-with-action > .editor-btn:hover {
+ color: var(--color-negative);
+}
+
+.capi-list-caption {
+ font-size: 1.1rem;
+ color: var(--color-text-subdue);
+}
+
+.capi-data-group-item .capi-chip {
+ border-color: var(--color-separator);
+}
+
+.capi-add-row {
+ width: 100%;
+ max-width: var(--capi-preview-width);
+ padding: 9px;
+ border-style: dashed;
+ border-color: var(--color-text-subdue);
+ background: transparent;
+ color: var(--color-text-base);
+ font-size: 1.25rem;
+}
+
+.capi-add-row:hover {
+ border-color: var(--color-primary);
+ color: var(--color-primary);
+}
+
+.capi-value {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.capi-static {
+ padding: 6px 10px;
+ border: 1px solid var(--color-separator);
+ border-radius: var(--border-radius);
+ font-size: 1.2rem;
+ color: var(--color-text-subdue);
+ overflow-wrap: anywhere;
+}
+
+.capi-column {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 10px;
+ border: 1px solid var(--color-separator);
+ border-radius: var(--border-radius);
+ background: var(--color-widget-background-highlight);
+ transition: border-color 0.15s, background 0.15s;
+}
+
+.capi-column.capi-column-selected {
+ border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-background));
+}
+
+.capi-column-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.capi-column-title {
+ min-width: 0;
+ font-size: 1.35rem;
+ font-weight: 500;
+ color: var(--color-text-base);
+ overflow-wrap: anywhere;
+}
+
+.capi-column-toggle svg {
+ transition: transform 0.15s ease;
+}
+
+.capi-column-toggle[aria-expanded="true"] svg {
+ transform: rotate(90deg);
+}
+
+.capi-column-body {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.capi-column-collapsed .capi-column-body {
+ display: none;
+}
+
+.capi-check {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 10px;
+ font-size: 1.2rem;
+ color: var(--color-text-base);
+ cursor: pointer;
+}
+
+.capi-status {
+ flex: 1 1 auto;
+ min-width: 0;
+ font-size: 1.2rem;
+ color: var(--color-text-subdue);
+ text-align: right;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.capi-status-warn {
+ color: var(--color-upcoming);
+}
+
+.capi-status-error {
+ color: var(--color-negative);
+}
diff --git a/internal/dynacat/static/css/editor-toggle.css b/internal/dynacat/static/css/editor-toggle.css
new file mode 100644
index 00000000..979f4a3c
--- /dev/null
+++ b/internal/dynacat/static/css/editor-toggle.css
@@ -0,0 +1,23 @@
+@media (max-width: 768px) {
+ #editor-toggle {
+ display: none;
+ }
+}
+
+.editor-toggle-icon {
+ width: 1.8rem;
+ height: 1.8rem;
+ stroke: currentColor;
+ fill: currentColor;
+ color: var(--color-text-subdue);
+ transition: color 0.2s;
+}
+
+#editor-toggle:hover .editor-toggle-icon {
+ color: var(--color-text-highlight);
+}
+
+.editor-toggle-icon-back {
+ width: 1.5rem;
+ height: 1.5rem;
+}
diff --git a/internal/dynacat/static/css/editor.css b/internal/dynacat/static/css/editor.css
new file mode 100644
index 00000000..c9a30268
--- /dev/null
+++ b/internal/dynacat/static/css/editor.css
@@ -0,0 +1,1160 @@
+@import "customapi-editor.css";
+
+.editor-palette-dock {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 40;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ pointer-events: none;
+}
+
+.editor-palette-search {
+ --palette-notch: 12px;
+ position: relative;
+ display: flex;
+ padding: 10px 10px 6px;
+ pointer-events: auto;
+ background: var(--color-popover-background);
+ border-top-right-radius: var(--border-radius);
+}
+
+.editor-palette-search::after {
+ content: "";
+ position: absolute;
+ left: 100%;
+ bottom: 0;
+ width: var(--palette-notch);
+ height: var(--palette-notch);
+ background: var(--color-popover-background);
+ mask: radial-gradient(circle var(--palette-notch) at 100% 0, transparent 99%, #000 100%);
+ -webkit-mask: radial-gradient(circle var(--palette-notch) at 100% 0, transparent 99%, #000 100%);
+}
+
+.editor-palette-search .editor-input {
+ width: 190px;
+ max-width: 50vw;
+ padding: 4px 9px;
+ font-size: 1.2rem;
+ border-radius: var(--border-radius);
+ border: 1px solid var(--color-separator);
+ background-color: var(--color-background);
+ color: var(--color-text-subdue);
+ font-family: inherit;
+}
+
+.editor-palette-search .editor-input::placeholder {
+ color: var(--color-text-subdue);
+}
+
+.editor-palette-search .editor-input:focus {
+ outline: none;
+}
+
+.editor-palette-empty {
+ align-self: center;
+ padding: 8px 6px;
+ font-size: 1.1rem;
+ color: var(--color-text-subdue);
+}
+
+.editor-palette {
+ width: 100%;
+ display: flex;
+ gap: 6px;
+ overflow-x: auto;
+ padding: 10px 16px;
+ pointer-events: auto;
+ background: var(--color-popover-background);
+ scrollbar-width: thin;
+}
+
+.editor-palette::-webkit-scrollbar {
+ height: 6px;
+}
+
+.editor-palette::-webkit-scrollbar-thumb {
+ background: var(--color-separator);
+ border-radius: 3px;
+}
+
+.editor-palette-item {
+ flex: 0 0 auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 5px;
+ width: 76px;
+ padding: 8px 6px;
+ border-radius: var(--border-radius);
+ cursor: grab;
+ user-select: none;
+ opacity: 0.75;
+ transition: opacity 0.15s, background 0.15s;
+}
+
+.editor-palette-item[hidden],
+.editor-palette-empty[hidden] {
+ display: none;
+}
+
+.editor-palette-item:hover {
+ opacity: 1;
+ background: var(--color-widget-background);
+}
+
+.editor-palette-icon {
+ width: 22px;
+ height: 22px;
+ background: var(--color-text-highlight);
+ mask: var(--icon-url) center / contain no-repeat;
+ -webkit-mask: var(--icon-url) center / contain no-repeat;
+}
+
+.editor-palette-name {
+ width: 100%;
+ font-size: 1.05rem;
+ line-height: 1.2;
+ text-align: center;
+ color: var(--color-text-highlight);
+ overflow-wrap: anywhere;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+body.editing {
+ padding-bottom: 170px;
+}
+
+/* The username crowds the header enough to squeeze out the editor toggle. */
+body.editing .nav-username {
+ display: none;
+}
+
+body.editing .footer {
+ display: none;
+}
+
+body.editing .widget + .widget {
+ margin-top: 0;
+}
+
+body.editing .page-column {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-height: 120px;
+ align-self: flex-start;
+ position: relative;
+ padding: 8px;
+ border-radius: var(--border-radius);
+ background: var(--color-widget-background);
+}
+
+body.editing .page-column:not(:has(> .widget)) {
+ align-items: center;
+ justify-content: center;
+}
+
+body.editing .page-column:not(:has(> .widget))::after {
+ content: "Drop widgets here";
+ color: var(--color-text-subdue);
+ font-size: 1.2rem;
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.editor-widget {
+ display: flex;
+ flex-direction: column;
+ padding: 12px 14px;
+ border-radius: var(--border-radius);
+ background: var(--color-widget-background-highlight);
+ border: 1px solid var(--color-separator);
+ cursor: grab;
+ transition: border-color 0.15s;
+}
+
+.editor-widget:hover {
+ border-color: var(--color-text-subdue);
+}
+
+.editor-widget-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.editor-nested {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 10px;
+ padding: 8px;
+ min-height: 46px;
+ border-radius: var(--border-radius);
+ background: var(--color-widget-background);
+}
+
+.editor-nested:not(:has(> .widget)) {
+ align-items: center;
+ justify-content: center;
+}
+
+.editor-nested:not(:has(> .widget))::after {
+ content: "Drop widgets here";
+ color: var(--color-text-subdue);
+ font-size: 1.1rem;
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.editor-nested-split:has(> .widget) {
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ align-content: flex-start;
+}
+
+.editor-nested-split > .editor-widget {
+ flex: 1 1 100px;
+ min-width: 100px;
+ align-self: flex-start;
+}
+
+.editor-widget.editor-dragging {
+ opacity: 0.4;
+}
+
+.editor-widget-card {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.editor-widget-icon {
+ flex-shrink: 0;
+ width: 22px;
+ height: 22px;
+ background: var(--color-text-highlight);
+ mask: var(--icon-url) center / contain no-repeat;
+ -webkit-mask: var(--icon-url) center / contain no-repeat;
+}
+
+.editor-widget-title {
+ font-size: 1.3rem;
+ color: var(--color-text-highlight);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.editor-widget-tools {
+ margin-left: auto;
+ display: flex;
+ gap: 2px;
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+
+.editor-widget:hover .editor-widget-tools {
+ opacity: 1;
+}
+
+.editor-tool {
+ display: flex;
+ padding: 3px;
+ border: none;
+ border-radius: var(--border-radius);
+ background: transparent;
+ color: var(--color-text-subdue);
+ cursor: pointer;
+}
+
+.editor-tool:hover {
+ color: var(--color-text-highlight);
+}
+
+.editor-tool svg {
+ width: 15px;
+ height: 15px;
+}
+
+.editor-column-tools {
+ display: flex;
+ justify-content: center;
+ padding-top: 8px;
+}
+
+.editor-column-remove {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 10px;
+ border: none;
+ border-radius: var(--border-radius);
+ background: transparent;
+ color: var(--color-text-subdue);
+ font: inherit;
+ font-size: 1.1rem;
+ cursor: pointer;
+ transition: color 0.15s;
+}
+
+.editor-column-remove:hover {
+ color: var(--color-negative);
+}
+
+.editor-icon-inline {
+ display: flex;
+}
+
+.editor-icon-inline svg {
+ width: 14px;
+ height: 14px;
+}
+
+.editor-drop-marker {
+ position: absolute;
+ left: 8px;
+ right: 8px;
+ height: 2px;
+ border-radius: 2px;
+ background: var(--color-primary);
+ opacity: 0.7;
+ pointer-events: none;
+}
+
+.editor-drop-marker-v {
+ left: auto;
+ right: auto;
+ top: 8px;
+ bottom: 8px;
+ width: 2px;
+ height: auto;
+}
+
+.editor-add-column {
+ position: relative;
+ flex: 0 0 14px;
+ align-self: stretch;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.editor-add-column::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background: var(--color-primary);
+ opacity: 0;
+ transition: opacity 0.15s ease;
+}
+
+.editor-add-column-plus {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background: var(--color-primary);
+ color: var(--color-background);
+ transform: scale(0);
+ transition: transform 0.15s ease;
+}
+
+.editor-add-column-plus svg {
+ width: 13px;
+ height: 13px;
+}
+
+.editor-add-column:hover::before {
+ opacity: 0.5;
+}
+
+.editor-add-column:hover .editor-add-column-plus {
+ transform: scale(1);
+}
+
+.editor-page-tools {
+ display: inline-flex;
+ align-self: center;
+ align-items: center;
+ gap: 10px;
+}
+
+.editor-page-tools-divider {
+ width: 1px;
+ height: 16px;
+ background: var(--color-separator);
+}
+
+.editor-page-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--color-text-subdue);
+ font-size: 0.82em;
+ line-height: 1;
+ white-space: nowrap;
+ cursor: pointer;
+ transition: color 0.15s;
+}
+
+.editor-page-btn .editor-icon-inline svg {
+ width: 14px;
+ height: 14px;
+}
+
+.editor-page-btn:hover {
+ color: var(--color-text-highlight);
+}
+
+.editor-add-page:hover {
+ color: var(--color-primary);
+}
+
+.editor-remove-page:hover {
+ color: var(--color-negative);
+}
+
+body.editing .editor-styling-trigger {
+ cursor: pointer;
+}
+
+.editor-modal-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 60;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ background: rgba(0, 0, 0, 0.5);
+}
+
+.editor-modal {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ width: 100%;
+ max-width: 520px;
+ max-height: 85vh;
+ padding: 20px;
+ border-radius: var(--border-radius);
+ background: var(--color-widget-background);
+ border: 1px solid var(--color-separator);
+}
+
+.editor-modal-title {
+ font-size: 1.6rem;
+ color: var(--color-text-highlight);
+}
+
+.editor-modal-title-link {
+ align-self: flex-start;
+ text-decoration: underline;
+ text-decoration-color: var(--color-text-highlight);
+ text-underline-offset: 4px;
+}
+
+.editor-theme-picker {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1.8rem;
+ padding-block: 0.4rem 0.2rem;
+}
+
+.editor-theme-picker .theme-choices {
+ justify-content: center;
+}
+
+.editor-theme-badge {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.6rem;
+ height: 2.6rem;
+ padding: 0;
+ border: none;
+ border-radius: 0.3rem;
+ background: var(--color-widget-background-highlight);
+ color: var(--color-text-subdue);
+ cursor: pointer;
+ transition: color 0.15s;
+}
+
+.editor-theme-badge::before {
+ content: '';
+ position: absolute;
+ inset: -.4rem;
+ border-radius: .7rem;
+ border: 2px solid transparent;
+ transition: border-color .3s;
+}
+
+.editor-theme-badge:hover {
+ color: var(--color-text-base);
+}
+
+.editor-theme-badge:hover::before {
+ border-color: var(--color-text-subdue);
+}
+
+.editor-theme-badge.current {
+ color: var(--color-text-highlight);
+}
+
+.editor-theme-badge.current::before {
+ border-color: var(--color-text-base);
+}
+
+.editor-theme-badge svg {
+ width: 17px;
+ height: 17px;
+}
+
+.editor-modal-body {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ min-height: 0;
+ padding-bottom: 4px;
+ overflow-y: auto;
+}
+
+.editor-fields {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.editor-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.editor-field-label {
+ font-size: 1.3rem;
+ color: var(--color-text-base);
+}
+
+.editor-required {
+ margin-left: 3px;
+ color: var(--color-primary);
+ font-weight: bold;
+}
+
+.editor-field-hint {
+ font-size: 1.1rem;
+ color: var(--color-text-subdue);
+ margin: 0;
+}
+
+.editor-modal .editor-input,
+.capi-editor .editor-input {
+ width: 100%;
+ padding: 8px 10px;
+ border-radius: var(--border-radius);
+ border: 1px solid var(--color-separator);
+ background-color: var(--color-widget-background-highlight);
+ color: var(--color-text-base);
+ font: inherit;
+ color-scheme: dark;
+}
+
+.editor-modal .editor-input:focus,
+.capi-editor .editor-input:focus {
+ outline: none;
+ border-color: var(--color-primary);
+}
+
+.editor-check {
+ flex-direction: row;
+ align-items: center;
+ gap: 10px;
+ cursor: pointer;
+}
+
+.editor-check-label {
+ font-size: 1.3rem;
+ line-height: 1;
+ color: var(--color-text-base);
+}
+
+.editor-modal .editor-check input[type="checkbox"],
+.capi-editor input[type="checkbox"] {
+ appearance: none;
+ -webkit-appearance: none;
+ position: relative;
+ flex-shrink: 0;
+ width: 18px;
+ height: 18px;
+ margin: 0;
+ border: 1.5px solid var(--color-text-subdue);
+ border-radius: 5px;
+ background: transparent;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.editor-modal .editor-check input[type="checkbox"]:hover,
+.capi-editor input[type="checkbox"]:hover {
+ border-color: var(--color-primary);
+}
+
+.editor-modal .editor-check input[type="checkbox"]:checked,
+.capi-editor input[type="checkbox"]:checked {
+ background: var(--color-primary);
+ border-color: var(--color-primary);
+}
+
+.editor-modal .editor-check input[type="checkbox"]:checked::after,
+.capi-editor input[type="checkbox"]:checked::after {
+ content: "";
+ position: absolute;
+ left: 6px;
+ top: 2px;
+ width: 4px;
+ height: 9px;
+ border: solid #fff;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+}
+
+.editor-modal select.editor-input,
+.capi-editor select.editor-input {
+ appearance: none;
+ -webkit-appearance: none;
+ padding-right: 30px;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%23888888' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.75 4.5 6 7.75 9.25 4.5'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+ background-size: 12px 12px;
+ cursor: pointer;
+}
+
+.editor-section-title {
+ font-size: 1.35rem;
+ color: var(--color-text-highlight);
+ margin-bottom: -4px;
+}
+
+.editor-color-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.editor-color-row .editor-color-text {
+ flex: 1;
+ font-family: monospace;
+ text-transform: uppercase;
+}
+
+.editor-modal .editor-color-swatch {
+ flex-shrink: 0;
+ width: 46px;
+ height: 34px;
+ padding: 3px;
+ border: 1px solid var(--color-separator);
+ border-radius: var(--border-radius);
+ background: var(--color-widget-background-highlight);
+ cursor: pointer;
+}
+
+.editor-modal .editor-color-swatch::-webkit-color-swatch {
+ border: none;
+ border-radius: 6px;
+}
+
+.editor-modal .editor-color-swatch::-webkit-color-swatch-wrapper {
+ padding: 0;
+}
+
+.editor-modal .editor-color-swatch::-moz-color-swatch {
+ border: none;
+ border-radius: 6px;
+}
+
+.editor-textarea {
+ min-height: 90px;
+ resize: vertical;
+ font-family: monospace;
+}
+
+.editor-textarea-tall {
+ min-height: 180px;
+}
+
+.editor-paste-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--color-separator);
+}
+
+.editor-paste-hint {
+ color: var(--color-text-subdue);
+ font-size: 1.2rem;
+}
+
+.editor-paste-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+ padding: 6px 12px;
+}
+
+.editor-paste-btn .editor-icon-inline svg {
+ width: 1.4rem;
+ height: 1.4rem;
+}
+
+.editor-paste-area {
+ min-height: 260px;
+ white-space: pre;
+ overflow-wrap: normal;
+ overflow-x: auto;
+}
+
+.editor-notice {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.editor-env-vars {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 6px;
+}
+
+.editor-env-var,
+.editor-code-block {
+ background: var(--color-widget-background-highlight);
+ color: var(--color-text-base);
+ font-family: monospace;
+ font-size: 1.2rem;
+}
+
+.editor-env-var {
+ max-width: 100%;
+ padding: 4px 9px;
+ border-radius: calc(var(--border-radius) * 0.75);
+ text-align: center;
+ overflow-wrap: anywhere;
+}
+
+.editor-code-card {
+ position: relative;
+ display: flex;
+}
+
+.editor-code-block {
+ flex: 1;
+ min-width: 0;
+ margin: 0;
+ padding: 12px 44px 12px 14px;
+ border-radius: var(--border-radius);
+ line-height: 1.6;
+ overflow-x: auto;
+}
+
+.editor-code-copy {
+ position: absolute;
+ top: 7px;
+ right: 7px;
+ display: flex;
+ padding: 5px;
+ border: none;
+ border-radius: calc(var(--border-radius) * 0.6);
+ background: transparent;
+ color: var(--color-text-subdue);
+ cursor: pointer;
+}
+
+.editor-code-copy:hover {
+ color: var(--color-text-highlight);
+ background: color-mix(in srgb, var(--color-text-subdue) 14%, transparent);
+}
+
+.editor-code-copy svg {
+ width: 1.6rem;
+ height: 1.6rem;
+}
+
+.editor-code-inline {
+ padding: 1px 5px;
+ white-space: nowrap;
+ border-radius: calc(var(--border-radius) * 0.6);
+ background: color-mix(in srgb, var(--color-text-subdue) 16%, transparent);
+ color: var(--color-text-base);
+ font-family: monospace;
+ font-size: 0.95em;
+}
+
+.editor-notice-steps {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ counter-reset: notice-step;
+}
+
+.editor-notice-steps li {
+ position: relative;
+ padding-left: 22px;
+ font-size: 1.25rem;
+ line-height: 1.55;
+ color: var(--color-text-base);
+}
+
+.editor-notice-steps li::before {
+ counter-increment: notice-step;
+ content: counter(notice-step) ".";
+ position: absolute;
+ left: 0;
+ color: var(--color-text-subdue);
+}
+
+.editor-notice-note {
+ padding-left: 11px;
+ border-left: 2px solid var(--color-separator);
+ font-size: 1.15rem;
+ line-height: 1.5;
+ color: var(--color-text-subdue);
+}
+
+.editor-icon-preview {
+ width: 26px;
+ height: 26px;
+ margin-top: 4px;
+}
+
+.editor-list-label {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.editor-list-toggle {
+ padding: 0;
+ border: none;
+ background: none;
+ color: var(--color-text-subdue);
+ font: inherit;
+ font-size: 1.2rem;
+ cursor: pointer;
+}
+
+.editor-list-toggle:hover {
+ color: var(--color-primary);
+}
+
+.editor-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+/* Author `display` beats the UA `[hidden]` rule, so hiding these needs an explicit override. */
+.editor-btn[hidden],
+.editor-list[hidden],
+.editor-list-add[hidden] {
+ display: none;
+}
+
+.editor-list-card {
+ border: 1px solid var(--color-separator);
+ border-radius: var(--border-radius);
+ background: var(--color-widget-background-highlight);
+}
+
+.editor-list-dragging {
+ opacity: 0.4;
+}
+
+.editor-list-marker {
+ height: 2px;
+ margin: -4px 0;
+ border-radius: 2px;
+ background: var(--color-primary);
+}
+
+.editor-list-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 9px;
+ cursor: pointer;
+}
+
+.editor-list-grip {
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+ color: var(--color-text-subdue);
+ cursor: grab;
+}
+
+.editor-list-grip:hover {
+ color: var(--color-text-highlight);
+}
+
+.editor-list-card[draggable="true"] .editor-list-grip {
+ cursor: grabbing;
+}
+
+.editor-list-grip svg,
+.editor-list-chevron svg {
+ width: 100%;
+ height: 100%;
+}
+
+.editor-list-thumb {
+ flex-shrink: 0;
+ width: 20px;
+ height: 20px;
+ object-fit: contain;
+}
+
+.editor-list-summary {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 1.3rem;
+ color: var(--color-text-highlight);
+}
+
+.editor-list-chevron {
+ flex-shrink: 0;
+ width: 14px;
+ height: 14px;
+ color: var(--color-text-subdue);
+ transition: transform 0.15s;
+}
+
+.editor-list-card-open .editor-list-chevron {
+ transform: rotate(90deg);
+}
+
+.editor-list-remove:hover {
+ color: var(--color-negative);
+}
+
+.editor-list-body {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 4px 10px 12px;
+ border-top: 1px solid var(--color-separator);
+}
+
+/* Inputs sit on the card background, so they need a darker fill to read as inputs. */
+.editor-modal .editor-list-body .editor-input,
+.editor-modal .editor-list-row .editor-input {
+ background-color: var(--color-background);
+}
+
+.editor-list-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.editor-list-row-field {
+ flex: 1;
+}
+
+.editor-list-add {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ width: 100%;
+ padding: 8px;
+ border: 1px dashed var(--color-separator);
+ border-radius: var(--border-radius);
+ /* Same reason as the inputs above: the separator border matches the card background exactly. */
+ background: var(--color-background);
+ color: var(--color-text-subdue);
+ font: inherit;
+ font-size: 1.25rem;
+ cursor: pointer;
+ transition: color 0.15s, border-color 0.15s;
+}
+
+.editor-list-add:hover {
+ border-color: var(--color-primary);
+ color: var(--color-primary);
+}
+
+.editor-list-add svg {
+ width: 12px;
+ height: 12px;
+}
+
+/* Nested adds sit inside an already busy card, so they drop to a plain text link. */
+.editor-list-body .editor-list-add {
+ align-self: flex-start;
+ justify-content: flex-start;
+ width: auto;
+ padding: 0;
+ border: none;
+ background: none;
+ transition: color 0.15s;
+}
+
+.editor-collapsible summary {
+ cursor: pointer;
+ padding: 6px 0;
+ color: var(--color-text-subdue);
+ font-size: 1.2rem;
+}
+
+.editor-modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.editor-btn {
+ padding: 8px 16px;
+ border: 1px solid var(--color-separator);
+ border-radius: var(--border-radius);
+ background: transparent;
+ color: var(--color-text-base);
+ cursor: pointer;
+ font: inherit;
+}
+
+.editor-btn:hover {
+ border-color: var(--color-text-subdue);
+}
+
+.editor-btn-primary {
+ background: var(--color-primary);
+ color: var(--color-background);
+ border-color: var(--color-primary);
+}
+
+.editor-btn-danger {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--color-negative);
+ border-color: color-mix(in srgb, var(--color-negative) 45%, transparent);
+}
+
+.editor-btn-danger:hover {
+ background: color-mix(in srgb, var(--color-negative) 14%, transparent);
+ color: var(--color-negative);
+ border-color: var(--color-negative);
+}
+
+.editor-btn-icon-danger {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: auto;
+ padding: 8px;
+ border-color: transparent;
+ color: var(--color-text-subdue);
+}
+
+.editor-btn-icon-danger .editor-icon-inline svg {
+ width: 1.5rem;
+ height: 1.5rem;
+}
+
+.editor-btn-icon-danger:hover {
+ border-color: transparent;
+ color: var(--color-negative);
+}
+
+.editor-confirm-message {
+ font-size: 1.35rem;
+ color: var(--color-text-base);
+}
+
+.editor-confirm-note {
+ margin-top: -8px;
+ font-size: 1.2rem;
+ color: var(--color-text-subdue);
+}
+
+.editor-layout-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+}
+
+.editor-layout-option {
+ display: flex;
+ gap: 3px;
+ height: 54px;
+ padding: 6px;
+ border-radius: var(--border-radius);
+ border: 2px solid var(--color-separator);
+ cursor: pointer;
+}
+
+.editor-layout-option.selected {
+ border-color: var(--color-primary);
+}
+
+.editor-layout-cell {
+ background: var(--color-text-subdue);
+ border-radius: 2px;
+}
+
+.editor-layout-cell-small {
+ flex: 0 0 20%;
+}
+
+.editor-layout-cell-full {
+ flex: 1;
+}
+
+.editor-toasts {
+ position: fixed;
+ top: 16px;
+ right: 16px;
+ z-index: 70;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.editor-toast {
+ padding: 9px 15px;
+ font-size: 1.2rem;
+ border-radius: var(--border-radius);
+ background: var(--color-popover-background);
+ border: 1px solid var(--color-separator);
+}
diff --git a/internal/dynacat/static/css/main.css b/internal/dynacat/static/css/main.css
index 4f79d07a..ac638385 100644
--- a/internal/dynacat/static/css/main.css
+++ b/internal/dynacat/static/css/main.css
@@ -28,6 +28,7 @@
--color-primary: hsl(278.01, 79.31%, 60.2%);
--color-positive: var(--color-primary);
--color-negative: hsl(0, 70%, 70%);
+ --color-upcoming: hsl(43, 74%, 64%);
--color-background: hsl(var(--bghs), var(--bgl));
--color-widget-background-hsl-values: var(--bghs), calc(var(--bgl) + 1%);
--color-widget-background: hsl(var(--color-widget-background-hsl-values));
@@ -63,4 +64,5 @@
@import "widgets.css";
@import "popover.css";
@import "utils.css";
+@import "editor-toggle.css";
@import "mobile.css";
diff --git a/internal/dynacat/static/css/mobile.css b/internal/dynacat/static/css/mobile.css
index 4b368c59..4a973908 100644
--- a/internal/dynacat/static/css/mobile.css
+++ b/internal/dynacat/static/css/mobile.css
@@ -149,7 +149,7 @@
}
.ios .search-input {
- /* so that iOS Safari does not zoom the page when the input is focused */
+ /* ios safari zoom fix */
font-size: 16px;
}
}
diff --git a/internal/dynacat/static/css/site.css b/internal/dynacat/static/css/site.css
index d6d39717..d98a8089 100644
--- a/internal/dynacat/static/css/site.css
+++ b/internal/dynacat/static/css/site.css
@@ -179,8 +179,7 @@ body {
@keyframes loadingContainerEntrance {
from {
- /* Using 0.001 instead of 0 fixes a random 1s freeze on Chrome on page load when all */
- /* elements have opacity 0 and are animated in. I don't want to be a web dev anymore. */
+ /* chrome opacity freeze fix */
opacity: 0.001;
}
}
diff --git a/internal/dynacat/static/css/utils.css b/internal/dynacat/static/css/utils.css
index fe7dffc6..9c5f15c8 100644
--- a/internal/dynacat/static/css/utils.css
+++ b/internal/dynacat/static/css/utils.css
@@ -95,7 +95,7 @@ details[open] .summary::after {
rotate: -90deg;
}
-/* TODO: refactor, otherwise I hope I never have to change dynamic columns again */
+/* TODO: refactor dynamic columns */
.dynamic-columns {
--list-half-gap: 0.5rem;
gap: var(--widget-content-vertical-padding) var(--widget-content-horizontal-padding);
@@ -315,7 +315,7 @@ details[open] .summary::after {
.expand-toggle-button.container-expanded {
position: sticky;
- /* -1px to hide 1px gap on chrome */
+ /* chrome gap fix */
bottom: -1px;
}
@@ -349,6 +349,11 @@ details[open] .summary::after {
padding-bottom: 0;
}
+.widget-content-frameless .expand-toggle-button,
+.widget-content-frameless .cards-grid.collapsible-container + .expand-toggle-button {
+ background: transparent;
+}
+
.carousel-container {
position: relative;
}
@@ -403,7 +408,6 @@ details[open] .summary::after {
gap: 2px;
padding: 2px;
height: 1.5rem;
- /* naughty, but oh so beautiful */
margin-inline: -3px;
}
@@ -472,7 +476,6 @@ details[open] .summary::after {
scrollbar-width: none;
}
-/* Hide on Safari and Chrome */
.hide-scrollbars::-webkit-scrollbar {
display: none;
}
@@ -558,6 +561,7 @@ details[open] .summary::after {
.cursor-help { cursor: help; }
.rounded { border-radius: var(--border-radius); }
.break-all { word-break: break-all; }
+.wrap-anywhere { overflow-wrap: anywhere; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.text-center { text-align: center; }
@@ -568,6 +572,8 @@ details[open] .summary::after {
.shrink { flex-shrink: 1; }
.shrink-0 { flex-shrink: 0; }
.min-width-0 { min-width: 0; }
+.width-ui-icon { width: 2.3rem; }
+.nowrap { white-space: nowrap; }
.max-width-100 { max-width: 100%; }
.block { display: block; }
.inline-block { display: inline-block; }
diff --git a/internal/dynacat/static/css/widget-bookmarks.css b/internal/dynacat/static/css/widget-bookmarks.css
index 7f2dabd3..19c5ad24 100644
--- a/internal/dynacat/static/css/widget-bookmarks.css
+++ b/internal/dynacat/static/css/widget-bookmarks.css
@@ -6,6 +6,10 @@
color: var(--bookmarks-group-color);
}
+.bookmarks-link:hover {
+ color: var(--bookmarks-group-color);
+}
+
.bookmarks-link:not(.bookmarks-link-no-arrow)::after {
content: '↗' / "";
margin-left: 0.5em;
diff --git a/internal/dynacat/static/css/widget-calendar.css b/internal/dynacat/static/css/widget-calendar.css
index 9e4c11f6..48977427 100644
--- a/internal/dynacat/static/css/widget-calendar.css
+++ b/internal/dynacat/static/css/widget-calendar.css
@@ -70,8 +70,6 @@
margin-left: 0.7rem;
}
-/* The release trigger covers the whole day cell so hovering anywhere on the
- date (not just the underline) opens the popover. */
.calendar-release {
position: absolute;
inset: 0;
@@ -89,13 +87,41 @@
background: var(--color-primary);
}
+.calendar-release-indicator-available {
+ background: var(--color-positive);
+}
+
+.calendar-release-indicator-released {
+ background: var(--color-negative);
+}
+
+.calendar-release-indicator-upcoming {
+ background: var(--color-upcoming);
+}
+
.calendar-release-card {
text-decoration: none;
text-align: left;
}
-/* Fixed poster thumbnail keeping the standard 2:3 movie/series ratio. */
+.calendar-release-type-icon {
+ flex-shrink: 0;
+ align-self: center;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin-top: -0.3rem;
+ color: var(--color-text-subdue);
+}
+
+.calendar-release-type-icon svg {
+ width: 1.15rem;
+ height: 1.15rem;
+ display: block;
+}
+
.calendar-release-thumb {
+ position: relative;
width: 3.4rem;
aspect-ratio: 2 / 3;
flex-shrink: 0;
@@ -107,3 +133,27 @@
height: 100%;
object-fit: cover;
}
+
+.calendar-thumb-spinner {
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--color-popover-border);
+}
+
+.calendar-thumb-spinner::before {
+ content: '';
+ width: 1.2rem;
+ height: 1.2rem;
+ border-radius: 50%;
+ border: 2px solid var(--color-text-subdue);
+ border-top-color: var(--color-primary);
+ animation: calendarThumbSpin 700ms infinite linear;
+}
+
+@keyframes calendarThumbSpin {
+ to { transform: rotate(360deg); }
+}
diff --git a/internal/dynacat/static/css/widget-contribution-graph.css b/internal/dynacat/static/css/widget-contribution-graph.css
deleted file mode 100644
index 120e97dd..00000000
--- a/internal/dynacat/static/css/widget-contribution-graph.css
+++ /dev/null
@@ -1,70 +0,0 @@
-.contribution-graph {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.contribution-graph-months {
- position: relative;
- height: 1.6rem;
- font-size: var(--font-size-h6);
- color: var(--color-text-subdue);
-}
-
-.contribution-graph-months span {
- position: absolute;
- white-space: nowrap;
-}
-
-.contribution-graph-grid {
- display: flex;
- gap: 2px;
-}
-
-.contribution-week {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.contribution-day {
- width: 10px;
- height: 10px;
- border-radius: 2px;
- cursor: pointer;
- flex-shrink: 0;
-}
-
-.contribution-day[data-level="0"] {
- background-color: var(--color-graph-gridlines);
-}
-
-.contribution-day[data-level="1"] {
- background-color: color-mix(in srgb, var(--color-primary) 25%, transparent);
-}
-
-.contribution-day[data-level="2"] {
- background-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
-}
-
-.contribution-day[data-level="3"] {
- background-color: color-mix(in srgb, var(--color-primary) 75%, transparent);
-}
-
-.contribution-day[data-level="4"] {
- background-color: var(--color-primary);
-}
-
-.contribution-graph-footer {
- margin-top: 2px;
-}
-
-@container widget (max-width: 380px) {
- .contribution-week:nth-child(-n+27) {
- display: none;
- }
-
- .contribution-graph-months {
- display: none;
- }
-}
diff --git a/internal/dynacat/static/css/widget-docker-controller.css b/internal/dynacat/static/css/widget-docker-controller.css
index 5410db87..4c0bb04b 100644
--- a/internal/dynacat/static/css/widget-docker-controller.css
+++ b/internal/dynacat/static/css/widget-docker-controller.css
@@ -1,6 +1,6 @@
.widget-type-docker-controller input {
background: none;
- border: 1px solid var(--color-text-subdue);
+ border: 1px solid var(--color-popover-border);
border-radius: var(--border-radius);
color: var(--color-text-highlight);
font: inherit;
@@ -39,7 +39,7 @@
.docker-ctrl-pull-btn {
background: none;
- border: 1px solid var(--color-text-subdue);
+ border: 1px solid var(--color-popover-border);
border-radius: var(--border-radius);
color: var(--color-text-base);
cursor: pointer;
diff --git a/internal/dynacat/static/css/widget-monitor.css b/internal/dynacat/static/css/widget-monitor.css
index 08d95928..79fa0ec9 100644
--- a/internal/dynacat/static/css/widget-monitor.css
+++ b/internal/dynacat/static/css/widget-monitor.css
@@ -45,4 +45,45 @@
.monitor-site-response-time-compact {
white-space: nowrap;
+ flex: 0 0 auto;
+ min-width: 3.6rem;
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+.monitor-history {
+ display: flex;
+ justify-content: flex-end;
+ gap: 2px;
+ height: 1.6rem;
+ flex: 0 1 9rem;
+ min-width: 3rem;
+ margin-left: auto;
+ overflow: hidden;
+}
+
+/* The auto margin above already pushes everything to the end of the row */
+.monitor-history + .monitor-site-status-icon,
+.monitor-history + .monitor-site-status-cluster-compact {
+ margin-left: 0;
+}
+
+.monitor-history-point {
+ flex: 1 1 0;
+ min-width: 2px;
+ max-width: 6px;
+ border-radius: 2px;
+ background-color: var(--color-positive);
+ opacity: 0.4;
+ transition: opacity .2s;
+}
+
+.monitor-history-point-error {
+ background-color: var(--color-negative);
+ opacity: 0.8;
+}
+
+.monitor-history-point:hover,
+.monitor-history-point.popover-active {
+ opacity: 1;
}
diff --git a/internal/dynacat/static/css/widget-rss.css b/internal/dynacat/static/css/widget-rss.css
index 34b7114d..dd58175f 100644
--- a/internal/dynacat/static/css/widget-rss.css
+++ b/internal/dynacat/static/css/widget-rss.css
@@ -28,7 +28,7 @@
width: 100%;
height: 100%;
object-fit: cover;
- /* +1px is required to fix some weird graphical bug where the image overflows on the bottom in firefox */
+ /* firefox overflow fix */
border-radius: calc(var(--border-radius) + 1px);
opacity: 0.9;
z-index: 1;
diff --git a/internal/dynacat/static/css/widget-search.css b/internal/dynacat/static/css/widget-search.css
index a065e43e..2508a458 100644
--- a/internal/dynacat/static/css/widget-search.css
+++ b/internal/dynacat/static/css/widget-search.css
@@ -22,7 +22,6 @@
flex-shrink: 0;
}
-/* gives a wider hit area for the 3 people that will notice the animation : ) */
.search-icon-container::before {
content: '';
position: absolute;
@@ -76,6 +75,7 @@
}
.search-bangs { display: none; }
+.search-targets { display: none; }
.search-bang {
border-radius: calc(var(--border-radius) * 2);
@@ -97,7 +97,6 @@
display: none;
}
-/* Widget border adjustments when dropdown is open */
.search.search-suggestions-below {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
@@ -110,13 +109,11 @@
border-top-width: 0;
}
-/* Dropdown base — positioned + sized by JS */
.search-autocomplete {
position: fixed;
z-index: 200;
box-sizing: border-box;
overflow-y: auto;
- scrollbar-gutter: stable;
background: var(--color-widget-background);
border: 1px solid var(--color-widget-content-border);
border-top-width: 0;
@@ -130,7 +127,6 @@
transition: opacity 0.18s ease, transform 0.18s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.2s;
}
-/* Dropdown shown above input */
.search-autocomplete.search-autocomplete-above {
border-bottom-width: 0;
border-top-width: 1px;
@@ -156,6 +152,9 @@
}
.search-autocomplete-item {
+ --search-autocomplete-icon-size: 16px;
+ --search-autocomplete-icon-gap: 0.7em;
+
padding: 0.7rem 1.4rem;
cursor: pointer;
color: var(--color-text-base);
@@ -163,8 +162,33 @@
transition: background 0.1s;
}
+/* Indent iconless items so every label in the list starts at the same x. */
+.search-autocomplete-item:not(.search-autocomplete-item-target) {
+ padding-left: calc(1.4rem + var(--search-autocomplete-icon-size) + var(--search-autocomplete-icon-gap));
+}
+
.search-autocomplete-item:hover,
.search-autocomplete-item.selected {
background: var(--color-widget-background-highlight);
color: var(--color-text-highlight);
}
+
+.search-autocomplete-item-target {
+ display: flex;
+ align-items: center;
+ gap: var(--search-autocomplete-icon-gap);
+}
+
+.search-autocomplete-item-target-icon {
+ width: var(--search-autocomplete-icon-size);
+ height: var(--search-autocomplete-icon-size);
+ flex-shrink: 0;
+ opacity: 0.8;
+}
+
+.search-autocomplete-item-target-fallback {
+ flex-shrink: 0;
+ width: var(--search-autocomplete-icon-size);
+ text-align: center;
+ color: var(--color-primary);
+}
diff --git a/internal/dynacat/static/css/widget-speedtest.css b/internal/dynacat/static/css/widget-speedtest.css
index 316f8095..26acdfdf 100644
--- a/internal/dynacat/static/css/widget-speedtest.css
+++ b/internal/dynacat/static/css/widget-speedtest.css
@@ -36,6 +36,23 @@
line-height: 1;
}
+.speedtest-metric-change {
+ display: flex;
+ align-items: center;
+ align-self: center;
+ gap: 0.15rem;
+}
+
+.speedtest-change-arrow {
+ width: 0.9em;
+ height: 0.9em;
+ flex-shrink: 0;
+}
+
+.speedtest-change-arrow-down {
+ transform: scaleY(-1);
+}
+
@container widget (min-width: 450px) {
.speedtest-metrics {
flex-direction: row;
@@ -55,4 +72,17 @@
.speedtest-metric-label {
margin-right: 0;
}
+
+ /* Restore value → unit → change order */
+ .speedtest-metric-value {
+ order: 1;
+ }
+
+ .speedtest-metric-unit {
+ order: 2;
+ }
+
+ .speedtest-metric-change {
+ order: 3;
+ }
}
diff --git a/internal/dynacat/static/css/widget-todo.css b/internal/dynacat/static/css/widget-todo.css
index b5a35a50..5e0ae9f8 100644
--- a/internal/dynacat/static/css/widget-todo.css
+++ b/internal/dynacat/static/css/widget-todo.css
@@ -165,7 +165,8 @@
}
.todo-item.is-being-dragged .todo-item-delete {
- opacity: 0;
+ opacity: 0.3;
+ pointer-events: none;
}
@media (pointer: fine) {
@@ -201,7 +202,7 @@
@media (pointer: coarse) {
.todo-item {
- padding: 0.55rem 0;
+ padding: 0.55rem 0 0.55rem 1rem;
}
.todo-item-checkbox {
diff --git a/internal/dynacat/static/css/widget-weather.css b/internal/dynacat/static/css/widget-weather.css
index 84d50ba1..a4dfbe74 100644
--- a/internal/dynacat/static/css/widget-weather.css
+++ b/internal/dynacat/static/css/widget-weather.css
@@ -69,9 +69,7 @@
.weather-column-rain::before {
content: '';
position: absolute;
- /* TODO: figure out a way to make it look continuous between columns, right now */
- /* depending on the width of the page the rain inside two columns next to each other */
- /* can overlap and look bad */
+ /* TODO: rain can overlap between adjacent columns */
background: radial-gradient(circle at 4px 4px, hsl(200, 90%, 70%, 0.4) 1px, transparent 0);
background-size: 8px 8px;
transform: rotate(45deg) translate(-50%, 25%);
diff --git a/internal/dynacat/static/js/calendar.js b/internal/dynacat/static/js/calendar.js
index 8021142f..220b3cee 100644
--- a/internal/dynacat/static/js/calendar.js
+++ b/internal/dynacat/static/js/calendar.js
@@ -30,10 +30,26 @@ const [datesEntranceLeft, datesEntranceRight] = directions(
const undoEntrance = slideFade({ direction: "left", distance: "100%", duration: 300 });
+const releaseTypes = {
+ digital: {
+ label: "Digital release",
+ path: "M21,16H3V4H21M21,2H3C1.89,2 1,2.89 1,4V16A2,2 0 0,0 3,18H10V20H8V22H16V20H14V18H21A2,2 0 0,0 23,16V4C23,2.89 22.1,2 21,2Z",
+ },
+ physical: {
+ label: "Physical release",
+ path: "M5,3C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3H5M12,5C15.09,5 17.82,7.04 18.7,10H16A1,1 0 0,0 15,11V13A1,1 0 0,0 16,14H18.71C17.82,16.97 15.09,19 12,19A7,7 0 0,1 5,12A7,7 0 0,1 12,5M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10Z",
+ },
+ cinema: {
+ label: "In cinemas",
+ path: "M20.84 2.18L16.91 2.96L19.65 6.5L21.62 6.1L20.84 2.18M13.97 3.54L12 3.93L14.75 7.46L16.71 7.07L13.97 3.54M9.07 4.5L7.1 4.91L9.85 8.44L11.81 8.05L9.07 4.5M4.16 5.5L3.18 5.69C2.1 5.9 1.39 6.96 1.61 8.04L2 10L6.9 9.03L4.16 5.5M20 12V20H4V12H20M22 10H2V20C2 21.11 2.9 22 4 22H20C21.11 22 22 21.11 22 20V10Z",
+ },
+ episode: {
+ label: "Episode",
+ path: "M8.16,3L6.75,4.41L9.34,7H4C2.89,7 2,7.89 2,9V19C2,20.11 2.89,21 4,21H20C21.11,21 22,20.11 22,19V9C22,7.89 21.11,7 20,7H14.66L17.25,4.41L15.84,3L12,6.84L8.16,3M4,9H17V19H4V9M19.5,9A1,1 0 0,1 20.5,10A1,1 0 0,1 19.5,11A1,1 0 0,1 18.5,10A1,1 0 0,1 19.5,9M19.5,12A1,1 0 0,1 20.5,13A1,1 0 0,1 19.5,14A1,1 0 0,1 18.5,13A1,1 0 0,1 19.5,12Z",
+ },
+};
+
export default function(element) {
- // Guard against double-initialization: a built calendar still carries the
- // "calendar" class, so if setup runs again it must not rebuild it (which would
- // create a second instance and replay the month animation).
if (element.querySelector(".calendar-dates")) return;
const widgetElement = element.closest("[data-widget-id]");
@@ -43,14 +59,26 @@ export default function(element) {
? Releases(widgetElement.dataset.widgetId, Number(element.dataset.calendarReleasesInterval) || 0)
: null;
+ const showReleaseState = element.dataset.calendarReleaseState === "true";
+
element.swapWith(Calendar(
Number(element.dataset.firstDayOfWeek ?? 1),
- releases
+ releases,
+ showReleaseState
));
}
-// Releases manages fetching + caching per-month Sonarr/Radarr release data and
-// rendering it as markers/popovers inside the calendar day cells.
+function aggregateReleaseState(items) {
+ let hasReleased = false, hasUpcoming = false;
+ for (const item of items) {
+ if (item.state === "released") hasReleased = true;
+ else if (item.state === "upcoming") hasUpcoming = true;
+ }
+ if (hasReleased) return "released";
+ if (hasUpcoming) return "upcoming";
+ return "available";
+}
+
function Releases(widgetId, intervalMs) {
const base = (typeof pageData !== "undefined" && pageData.baseURL) || "";
const cache = new Map();
@@ -84,7 +112,7 @@ function Releases(widgetId, intervalMs) {
}
// TODO: when viewing the previous/next month, display the current date if it's within the spill-over days
-function Calendar(firstDay, releases) {
+function Calendar(firstDay, releases, showReleaseState) {
let header, dates;
let advanceTimeTicker;
let releaseTicker;
@@ -122,14 +150,12 @@ function Calendar(firstDay, releases) {
const calendar = elem().classes("calendar").append(
header = Header(nextClicked, prevClicked, undoClicked),
- dates = Dates(firstDay, releases)
+ dates = Dates(firstDay, releases, showReleaseState)
);
update(now);
autoAdvanceNow();
- // Only poll for live updates when the page has dynamic updates enabled, matching
- // SSE/widget polling. The initial fetch above still runs so markers show either way.
const dynamicUpdatesEnabled = typeof pageData !== "undefined" && pageData.dynamicUpdateEnabled;
if (releases && releases.intervalMs > 0 && dynamicUpdatesEnabled) {
releaseTicker = setInterval(() => loadReleases(activeDate, true), releases.intervalMs);
@@ -196,11 +222,9 @@ function Header(nextClicked, prevClicked, undoClicked) {
});
}
-function Dates(firstDay, releases) {
- let dates, lastRenderedDate;
+function Dates(firstDay, releases, showReleaseState) {
+ let dates, lastRenderedDate, animating = false;
- // applyMarkers (re)draws release indicators + popovers onto the day cells for
- // the given month using whatever release data is currently cached.
const applyMarkers = function(newDate) {
if (!releases) return;
@@ -217,9 +241,13 @@ function Dates(firstDay, releases) {
if (existing) existing.remove();
const cellDate = new Date(firstCellDate.getFullYear(), firstCellDate.getMonth(), firstCellDate.getDate() + i);
+ if (cellDate.getMonth() !== newDate.getMonth() || cellDate.getFullYear() !== newDate.getFullYear()) {
+ continue;
+ }
+
const items = data[isoDate(cellDate)];
if (items && items.length) {
- cell.append(releaseMarker(items));
+ cell.append(releaseMarker(items, showReleaseState));
}
}
@@ -260,23 +288,22 @@ function Dates(firstDay, releases) {
lastRenderedDate = newDate;
- // Day numbers are set via .text() which wipes any previously appended
- // markers, so re-apply them after rendering the grid.
+ // .text() wipes appended markers, so re-apply after rendering the grid.
applyMarkers(newDate);
};
const update = function(now, newDate) {
- if (lastRenderedDate === undefined || datesWithinSameMonth(newDate, lastRenderedDate)) {
+ if (lastRenderedDate === undefined || datesWithinSameMonth(newDate, lastRenderedDate) || animating) {
updateFullMonth(now, newDate);
return;
}
const next = newDate > lastRenderedDate;
- dates.animateUpdate(
- () => updateFullMonth(now, newDate),
- next ? datesExitLeft : datesExitRight,
- next ? datesEntranceRight : datesEntranceLeft,
- );
+ animating = true;
+ dates.animate(next ? datesExitLeft : datesExitRight, () => {
+ updateFullMonth(now, newDate);
+ dates.animate(next ? datesEntranceRight : datesEntranceLeft, () => { animating = false; });
+ });
}
return elem().append(
@@ -292,21 +319,27 @@ function Dates(firstDay, releases) {
).component({ update, applyMarkers });
}
-function releaseMarker(items) {
+function releaseMarker(items, showReleaseState) {
const list = elem().classes("list", "list-gap-10");
for (const item of items) {
list.append(releaseCard(item));
}
+ const indicator = elem().classes("calendar-release-indicator");
+ if (showReleaseState) {
+ indicator.classes("calendar-release-indicator-" + aggregateReleaseState(items));
+ }
+
return elem()
.classes("calendar-release")
.attrs({
"data-popover-type": "html",
"data-popover-position": "above",
"data-popover-max-width": "340px",
+ "data-popover-hide-delay": "80",
})
.append(
- elem().classes("calendar-release-indicator"),
+ indicator,
elem().attr("data-popover-html", "").append(list)
);
}
@@ -316,7 +349,17 @@ function releaseCard(item) {
elem().classes("size-h6", "color-subdue").text(item.source)
);
- body.append(elem().classes("color-highlight", "text-truncate").text(item.title));
+ const titleRow = elem().classes("flex", "items-center", "gap-7", "min-width-0");
+ const type = releaseTypes[item.type];
+ if (type) {
+ titleRow.append(
+ elem().classes("calendar-release-type-icon")
+ .attr("title", type.label)
+ .html(``)
+ );
+ }
+ titleRow.append(elem().classes("color-highlight", "text-truncate").text(item.title));
+ body.append(titleRow);
if (item.description) {
body.append(elem().classes("color-base", "text-truncate-2-lines", "margin-top-3").text(item.description));
@@ -328,10 +371,15 @@ function releaseCard(item) {
).classes("calendar-release-card", "flex", "items-center", "gap-10");
if (item.thumbnail) {
+ const spinner = elem().classes("calendar-thumb-spinner");
+ const img = elem("img").classes("thumbnail").attrs({ src: item.thumbnail, loading: "lazy", alt: "" });
+
+ const reveal = (cls) => { img.classes(cls); spinner.hide(); };
+ img.on("load", () => reveal("loaded")).on("error", () => spinner.hide());
+ if (img.complete) img.naturalWidth > 0 ? reveal("cached") : spinner.hide();
+
card.append(
- elem().classes("calendar-release-thumb", "thumbnail-container").append(
- elem("img").classes("thumbnail").attrs({ src: item.thumbnail, loading: "lazy", alt: "" })
- )
+ elem().classes("calendar-release-thumb", "thumbnail-container").append(spinner, img)
);
}
diff --git a/internal/dynacat/static/js/customapi-editor.js b/internal/dynacat/static/js/customapi-editor.js
new file mode 100644
index 00000000..9869ba23
--- /dev/null
+++ b/internal/dynacat/static/js/customapi-editor.js
@@ -0,0 +1,2102 @@
+import { logFailure, responseDetail } from "./editor-log.js";
+
+const PD = typeof pageData !== "undefined" ? pageData : window.pageData;
+const API = `${PD.baseURL}/api/editor`;
+
+const PICK_FIELD = "[ pick a field ]";
+const MAX_CELLS = 4;
+const MAX_FIELDS = 200;
+const MAX_DEPTH = 4;
+
+const COLORS = [
+ "color-highlight",
+ "color-primary",
+ "color-positive",
+ "color-negative",
+ "color-subdue",
+ "color-paragraph",
+ "color-base",
+];
+
+const FORMATS = [
+ ["raw", "Raw value"],
+ ["number", "Number, grouped"],
+ ["approx", "Number, short"],
+ ["bytes", "Bytes"],
+ ["decimals", "Decimals"],
+ ["percent", "Percent"],
+ ["percent-change", "Percent change"],
+ ["date", "Date"],
+ ["relative", "Relative time"],
+];
+
+const MATH_OPS = [
+ ["", "None"],
+ ["add", "Add"],
+ ["sub", "Subtract"],
+ ["mul", "Multiply"],
+ ["div", "Divide"],
+];
+
+const NUMBER_RULE_OPS = [
+ ["gt", "greater than"],
+ ["lt", "less than"],
+ ["ge", "at least"],
+ ["le", "at most"],
+ ["eq", "equal to"],
+];
+
+const COLUMN_OVERFLOWS = [
+ ["crop", "Crop to one line"],
+ ["wrap", "Wrap over two lines"],
+];
+
+const COLUMN_ALIGNS = [
+ ["", "Automatic"],
+ ["left", "Left"],
+ ["center", "Center"],
+ ["right", "Right"],
+];
+
+const TEXT_RULE_OPS = [
+ ["is", "is"],
+ ["isnot", "is not"],
+];
+
+let ctx = null;
+let state = null;
+let ui = null;
+
+export async function openCustomAPIEditor(context) {
+ ctx = context;
+ state = await loadState();
+ buildUI();
+ renderNetwork();
+ renderAll();
+ fetchData();
+}
+
+function newValue(scope) {
+ return {
+ src: "json",
+ scope: scope || "root",
+ path: "",
+ op: "",
+ by: 2,
+ format: "raw",
+ digits: 2,
+ against: "",
+ parse: "unix",
+ layout: "DateOnly",
+ color: "color-highlight",
+ rule: null,
+ when: null,
+ icon: "",
+ iconSide: "left",
+ label: "",
+ align: "",
+ overflow: "crop",
+ kind: "value",
+ };
+}
+
+function newIconColumn() {
+ return { kind: "icon", scope: "item", src: "json", path: "", op: "is", than: "true", icon: "", elseIcon: "", label: "" };
+}
+
+function newBlock(kind) {
+ if (kind === "text") return { kind: "text", text: "Label", color: "color-paragraph", icon: "", iconSide: "left" };
+ if (kind === "icon") return { kind: "icon", icon: "si:docker" };
+ if (kind === "list") return { kind: "list", src: "json", path: "", limit: 5, sort: "", sortOrder: "desc", columns: [newValue("item")] };
+ return { kind: "stat", label: "", icon: "", iconSide: "left", value: newValue() };
+}
+
+// Only keys that differ from a fresh block are stored, so the config stays readable.
+function compactValue(value) {
+ const base = newValue(value.scope);
+ const out = {};
+
+ for (const [key, current] of Object.entries(value)) {
+ if (JSON.stringify(current) !== JSON.stringify(base[key])) out[key] = current;
+ }
+
+ out.path = value.path;
+ return out;
+}
+
+function compactRows(rows) {
+ return rows.map((row) => ({
+ blocks: row.blocks.map((block) => {
+ if (block.kind === "list") {
+ return { ...block, columns: block.columns.map((c) => (c.kind === "icon" ? c : compactValue(c))) };
+ }
+ return block.kind === "stat" ? { ...block, value: compactValue(block.value) } : block;
+ }),
+ }));
+}
+
+function expandRows(rows) {
+ return rows.map((row) => ({
+ blocks: (row.blocks || []).map((block) => {
+ if (block.kind === "list") {
+ const columns = (block.columns || []).map((c) => (c.kind === "icon" ? { ...newIconColumn(), ...c } : { ...newValue("item"), ...c }));
+ return { ...newBlock("list"), ...block, columns };
+ }
+ if (block.kind === "stat") {
+ return { ...newBlock("stat"), ...block, value: { ...newValue(block.value?.scope), ...block.value } };
+ }
+ return { ...newBlock(block.kind), ...block };
+ }),
+ }));
+}
+
+async function loadState() {
+ const template = ctx.read("template") || "";
+ const stored = ctx.hiddenValues.builder;
+ let rows = [];
+ let restored = false;
+
+ if (typeof stored === "string" && stored.trim() !== "") {
+ try {
+ rows = expandRows(JSON.parse(stored).rows || []);
+ restored = true;
+ } catch {
+ rows = [];
+ }
+ }
+
+ const url = ctx.read("url") || "";
+
+ return {
+ url,
+ headers: pairsFrom(await readConfig("headers")),
+ subrequests: await readSubrequests(),
+ netOpen: url.trim() === "",
+ rows,
+ data: null,
+ filter: "",
+ collapsed: new Set(),
+ opened: null,
+ reveal: false,
+ rendered: null,
+ sel: null,
+ dirty: false,
+ warn: !restored && template.trim() !== "",
+ };
+}
+
+async function readConfig(name) {
+ const text = ctx.read(name);
+ if (!text) return {};
+
+ try {
+ return (typeof text === "string" ? await ctx.toValue(text) : text) || {};
+ } catch {
+ return {};
+ }
+}
+
+function pairsFrom(map) {
+ return Object.entries(map || {}).map(([key, value]) => ({ key, value: String(value ?? "") }));
+}
+
+function pairsObject(pairs) {
+ const out = {};
+ for (const pair of pairs) {
+ if (pair.key.trim() !== "") out[pair.key.trim()] = pair.value;
+ }
+ return out;
+}
+
+async function readSubrequests() {
+ return Object.entries(await readConfig("subrequests")).map(([name, req]) => ({
+ name,
+ url: (req && req.url) || "",
+ headers: pairsFrom(req && req.headers),
+ }));
+}
+
+function subrequestsObject() {
+ const out = {};
+ for (const sub of state.subrequests) {
+ if (sub.name.trim() === "") continue;
+ const headers = pairsObject(sub.headers);
+ out[sub.name.trim()] = Object.keys(headers).length > 0 ? { url: sub.url, headers } : { url: sub.url };
+ }
+ return out;
+}
+
+const iconTrash = ``;
+const iconPlus = ``;
+const iconChevron = ``;
+
+let drag = null;
+let statusTimer = null;
+
+function el(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+}
+
+function div(className, text) {
+ return el("div", className, text);
+}
+
+function button(label, className) {
+ const btn = el("button", className, label);
+ btn.type = "button";
+ return btn;
+}
+
+function iconBtn(title, svg, onClick) {
+ const btn = button("", "capi-icon-btn");
+ btn.title = title;
+ btn.innerHTML = svg;
+ btn.addEventListener("click", (event) => {
+ event.stopPropagation();
+ onClick();
+ });
+ return btn;
+}
+
+function iconInput(value, onInput) {
+ let timer = null;
+ return input(value, "si:docker", (next) => {
+ clearTimeout(timer);
+ timer = setTimeout(() => onInput(next), 600);
+ });
+}
+
+function input(value, placeholder, onInput) {
+ const field = el("input", "editor-input");
+ field.type = "text";
+ field.value = value ?? "";
+ if (placeholder) field.placeholder = placeholder;
+ field.addEventListener("input", () => onInput(field.value));
+ return field;
+}
+
+function select(options, value, onChange) {
+ const field = el("select", "editor-input");
+ for (const [id, label] of options) field.append(new Option(label, id));
+ field.value = value;
+ field.addEventListener("change", () => onChange(field.value));
+ return field;
+}
+
+const iconElements = new Map();
+
+function iconImg(icon) {
+ const { url, autoInvert } = ctx.resolveIcon(icon);
+ let img = iconElements.get(url);
+
+ if (!img) {
+ img = el("img", `ui-icon${autoInvert ? " flat-icon" : ""}`);
+ img.src = url;
+ img.alt = "";
+ iconElements.set(url, img);
+ }
+
+ return img.cloneNode();
+}
+
+function labeled(label, control) {
+ const wrapper = div("capi-field");
+ wrapper.append(div("capi-field-label", label), control);
+ return wrapper;
+}
+
+function buildUI() {
+ const overlay = div("editor-ui capi-editor");
+
+ const topbar = div("capi-topbar");
+
+ const previewToggle = button("Preview", "editor-btn capi-preview-toggle");
+ previewToggle.addEventListener("click", togglePreview);
+
+ const refresh = button("Refresh data", "editor-btn");
+ refresh.addEventListener("click", fetchData);
+ const tools = div("capi-topbar-tools");
+ tools.append(previewToggle, refresh);
+
+ const net = div("capi-net");
+
+ const data = div("capi-pane capi-data");
+ const palette = div("capi-palette");
+ const filter = input(state.filter, "Filter fields", (value) => {
+ state.filter = value;
+ renderFields();
+ });
+ const fields = div("capi-fields");
+ data.append(net, palette, labeled("Find a field", filter), fields);
+
+ const canvas = div("capi-canvas");
+ const inspector = div("capi-pane capi-inspector");
+ const body = div("capi-body");
+ body.append(data, canvas, inspector);
+
+ const status = div("capi-status");
+ const cancel = button("Cancel", "editor-btn");
+ cancel.addEventListener("click", requestClose);
+ const apply = button("Apply and save", "editor-btn editor-btn-primary");
+ apply.addEventListener("click", () => applyChanges(apply));
+ const actions = div("capi-topbar-actions");
+ actions.append(status, tools, cancel, apply);
+ topbar.append(actions);
+
+ overlay.append(topbar, body);
+ document.body.append(overlay);
+
+ ui = { overlay, net, previewToggle, palette, fields, canvas, inspector, status };
+
+ dropTarget(canvas, "capi-canvas-drop", (d) => d.type === "block", (d) => {
+ state.rows.push({ blocks: [] });
+ addBlock(d.kind, state.rows[state.rows.length - 1]);
+ });
+
+ renderPalette();
+ document.addEventListener("keydown", onKeyDown);
+}
+
+function onKeyDown(event) {
+ if (event.key === "Escape" && ui) requestClose();
+}
+
+function closeEditor() {
+ document.removeEventListener("keydown", onKeyDown);
+ clearTimeout(statusTimer);
+ ui.overlay.remove();
+ ui = null;
+ state = null;
+ drag = null;
+}
+
+function requestClose() {
+ if (!state.dirty) return closeEditor();
+ ctx.confirmAction("Leave the editor without applying your changes?", closeEditor, {
+ confirmLabel: "Leave without saving",
+ note: "The saved widget stays exactly as it is.",
+ });
+}
+
+// Routine notes clear themselves after a few seconds, errors stay until something replaces them.
+function setStatus(message, level) {
+ clearTimeout(statusTimer);
+ ui.status.textContent = message || "";
+ ui.status.className = `capi-status${level ? ` capi-status-${level}` : ""}`;
+ if (message && !level) statusTimer = setTimeout(() => setStatus(""), 4000);
+}
+
+function startDrag(element, payload) {
+ element.draggable = true;
+ element.addEventListener("dragstart", (event) => {
+ drag = payload();
+ event.dataTransfer.effectAllowed = "copy";
+ event.dataTransfer.setData("text/plain", "");
+ element.classList.add("capi-dragging");
+ ui.overlay.classList.add("capi-drag-active");
+ });
+ element.addEventListener("dragend", () => {
+ drag = null;
+ element.classList.remove("capi-dragging");
+ ui.overlay.classList.remove("capi-drag-active");
+ });
+}
+
+function dropTarget(element, className, accepts, onDrop, onHover) {
+ const mark = (active) => {
+ element.classList.toggle(className, active);
+ onHover?.(active);
+ };
+
+ element.addEventListener("dragover", (event) => {
+ if (!drag || !accepts(drag)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ mark(true);
+ });
+ element.addEventListener("dragleave", (event) => {
+ if (!element.contains(event.relatedTarget)) mark(false);
+ });
+ element.addEventListener("drop", (event) => {
+ if (!drag || !accepts(drag)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ mark(false);
+ onDrop(drag);
+ });
+}
+
+async function postPreview(extra) {
+ const url = `${API}/custom-api/preview`;
+ const sent = { url: state.url, headers: pairsObject(state.headers), subrequests: subrequestsObject(), ...extra };
+ // Header values are the one part of a preview that tends to hold a token.
+ const sentSummary = { ...sent, headers: sent.headers && Object.keys(sent.headers) };
+
+ let res;
+ try {
+ res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(sent),
+ });
+ } catch (err) {
+ logFailure("preview never reached the server", { request: `POST ${url}`, sent: sentSummary, error: err });
+ throw new Error("The server did not answer");
+ }
+
+ const payload = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ logFailure("preview was rejected", { ...responseDetail("POST", url, res, payload), sent: sentSummary });
+ throw new Error(payload.error || "Request failed");
+ }
+
+ if (payload.error) {
+ logFailure(`preview failed while handling the ${payload.stage || "request"}`, {
+ stage: payload.stage,
+ subrequest: payload.subrequest,
+ message: payload.error,
+ hint: payload.hint,
+ sent: sentSummary,
+ });
+ }
+
+ return payload;
+}
+
+async function fetchData() {
+ setStatus("Fetching data...");
+
+ try {
+ const payload = await postPreview({});
+ if (payload.error) throw new Error(payload.error);
+
+ state.data = { json: payload.json ?? null, subs: payload.subrequestsJson || {} };
+ setStatus(state.url ? "Data loaded" : "Set a request url to load fields", state.url ? "" : "warn");
+ } catch (err) {
+ state.data = null;
+ setStatus(err.message || "Could not fetch data", "error");
+ }
+
+ renderFields();
+ renderCanvas();
+}
+
+function flatten(value, prefix, depth, out) {
+ if (out.length >= MAX_FIELDS || depth > MAX_DEPTH || value === null || value === undefined) return out;
+
+ if (Array.isArray(value)) {
+ out.push({ path: prefix, label: prefix, value, kind: "array" });
+ if (value.length > 0 && typeof value[0] === "object") flatten(value[0], prefix ? `${prefix}.0` : "0", depth + 1, out);
+ return out;
+ }
+
+ if (typeof value === "object") {
+ for (const [key, child] of Object.entries(value)) {
+ const path = prefix ? `${prefix}.${key}` : key;
+ if (child !== null && typeof child === "object") flatten(child, path, depth + 1, out);
+ else out.push({ path, label: path, value: child, kind: typeof child });
+ if (out.length >= MAX_FIELDS) break;
+ }
+ return out;
+ }
+
+ out.push({ path: prefix, label: prefix, value, kind: typeof value });
+ return out;
+}
+
+function sampleText(entry) {
+ if (entry.kind === "array") return `${entry.value.length} items`;
+ if (typeof entry.value === "number") return entry.value.toLocaleString("en-US");
+ const text = String(entry.value);
+ return text.length > 40 ? `${text.slice(0, 40)}...` : text;
+}
+
+function renderPalette() {
+ ui.palette.replaceChildren(div("capi-pane-title", "Add a block"));
+
+ for (const [kind, label] of [["stat", "Stat"], ["text", "Text"], ["list", "Row list"], ["icon", "Icon"]]) {
+ const btn = button(label, "editor-btn capi-palette-btn");
+ btn.addEventListener("click", () => addBlock(kind));
+ startDrag(btn, () => ({ type: "block", kind }));
+ ui.palette.append(btn);
+ }
+}
+
+function renderFields() {
+ ui.fields.replaceChildren();
+
+ if (!state.data) {
+ ui.fields.append(div("capi-empty", "Set a url above, then hit Refresh data."));
+ return;
+ }
+
+ const block = selectedBlock();
+ if (block && block.kind === "list" && block.path) appendItemGroup(block);
+
+ appendDataGroup("Response", "json", state.data.json);
+ for (const [name, json] of Object.entries(state.data.subs)) appendDataGroup(name, name, json);
+}
+
+// Show the fields of the first array item, since that is what a row list column binds to.
+function appendItemGroup(block) {
+ const first = listItems(block)[0];
+ if (!first || typeof first !== "object") return;
+
+ const group = div("capi-data-group capi-data-group-item");
+ group.append(div("capi-data-group-title", `Fields in each row of ${block.path}`));
+
+ const needle = (state.filter || "").toLowerCase();
+ const entries = flatten(first, "", 0, []).filter((entry) => entry.path.toLowerCase().includes(needle));
+ if (entries.length === 0) group.append(div("capi-empty", "No fields match."));
+
+ for (const entry of entries) {
+ const chip = div("capi-chip");
+ chip.append(div("capi-chip-path", entry.label || "(row)"), div("capi-chip-value", sampleText(entry)));
+ chip.addEventListener("click", () => bindItemField(block, entry));
+ startDrag(chip, () => ({ type: "item-field", block, entry }));
+ group.append(chip);
+ }
+
+ ui.fields.append(group);
+}
+
+function bindItemField(block, entry) {
+ const index = state.sel.col === null ? 0 : state.sel.col;
+ const column = block.columns[index];
+ if (!column) return;
+
+ column.path = entry.path;
+
+ if (column.kind !== "icon") {
+ column.format = guessFormat(entry.value);
+ if (column.format === "date") column.parse = typeof entry.value === "number" ? "unix" : "rfc3339";
+ }
+ state.sel = { ...state.sel, col: index };
+ state.dirty = true;
+ renderAll();
+}
+
+function appendDataGroup(title, src, json) {
+ const group = div("capi-data-group");
+ group.append(div("capi-data-group-title", title));
+
+ const block = selectedBlock();
+ const arraysOnly = block && block.kind === "list";
+ const needle = (state.filter || "").toLowerCase();
+ const entries = flatten(json, "", 0, [])
+ .filter((entry) => !arraysOnly || entry.kind === "array")
+ .filter((entry) => entry.path.toLowerCase().includes(needle));
+ if (entries.length === 0) group.append(div("capi-empty", arraysOnly ? "No arrays in this response." : "No fields match."));
+
+ for (const entry of entries) {
+ const chip = div(`capi-chip${entry.kind === "array" ? " capi-chip-array" : ""}`);
+ chip.append(div("capi-chip-path", entry.label || "(root)"), div("capi-chip-value", sampleText(entry)));
+ chip.addEventListener("click", () => bindField(src, entry, selectedTarget()));
+ startDrag(chip, () => ({ type: "field", src, entry }));
+ group.append(chip);
+ }
+
+ ui.fields.append(group);
+}
+
+function addRow() {
+ state.rows.push({ blocks: [] });
+ state.sel = { r: state.rows.length - 1, c: null, col: null };
+ state.dirty = true;
+ renderCanvas();
+}
+
+function rowForNewBlock(kind) {
+ let row = state.sel ? state.rows[state.sel.r] : state.rows[state.rows.length - 1];
+ if (row && row.blocks.length === 0) return row;
+
+ // A row list spans the whole widget, so it never shares a row with other blocks.
+ if (!row || row.blocks.length >= MAX_CELLS || kind === "list" || row.blocks.some((b) => b.kind === "list")) {
+ row = { blocks: [] };
+ state.rows.push(row);
+ }
+
+ return row;
+}
+
+function addBlock(kind, targetRow) {
+ if (state.rows.length === 0) state.rows.push({ blocks: [] });
+
+ const fits = targetRow && targetRow.blocks.length < MAX_CELLS
+ && (targetRow.blocks.length === 0 || (kind !== "list" && !targetRow.blocks.some((b) => b.kind === "list")));
+ const row = fits ? targetRow : rowForNewBlock(kind);
+
+ row.blocks.push(newBlock(kind));
+ state.sel = { r: state.rows.indexOf(row), c: row.blocks.length - 1, col: null };
+ state.dirty = true;
+ renderAll();
+}
+
+function selectedBlock() {
+ if (!state.sel || state.sel.c === null) return null;
+ return state.rows[state.sel.r]?.blocks[state.sel.c] || null;
+}
+
+function selectedTarget() {
+ const block = selectedBlock();
+ return block ? { block, col: state.sel.col } : null;
+}
+
+function bindField(src, entry, target) {
+ if (!target) return ctx.toast("Pick a block first, then click a field", "negative");
+
+ const { block } = target;
+
+ if (block.kind === "list" && (target.col === null || target.col === undefined)) {
+ if (entry.kind !== "array") return ctx.toast("A row list needs an array field", "negative");
+ block.src = src;
+ block.path = entry.path;
+ state.dirty = true;
+ return renderAll();
+ }
+
+ const value = block.kind === "list" ? block.columns[target.col] : block.kind === "stat" ? block.value : null;
+ if (!value) return ctx.toast("This block takes no data", "negative");
+
+ if (value.scope === "item") {
+ const prefix = `${block.path}.0.`;
+ if (src !== block.src || !entry.path.startsWith(prefix)) {
+ return ctx.toast("Pick a field from inside the list array", "negative");
+ }
+ value.path = entry.path.slice(prefix.length);
+ } else {
+ value.src = src;
+ value.path = entry.path;
+ }
+
+ value.format = guessFormat(entry.value);
+ if (value.format === "date") value.parse = typeof entry.value === "number" ? "unix" : "rfc3339";
+ state.dirty = true;
+ renderAll();
+}
+
+function guessFormat(sample) {
+ if (typeof sample === "number") return Number.isInteger(sample) ? "number" : "decimals";
+ if (typeof sample === "string" && /^\d{4}-\d{2}-\d{2}T/.test(sample)) return "date";
+ return "raw";
+}
+
+let previewApplying = false;
+let previewTimer = null;
+
+async function renderTemplate() {
+ const payload = await postPreview({ template: generateTemplate() });
+ if (payload.error) throw new Error(payload.error);
+ return payload.html || "";
+}
+
+async function togglePreview() {
+ if (state.rendered !== null) {
+ clearTimeout(previewTimer);
+ state.rendered = null;
+ ui.previewToggle.textContent = "Preview";
+ return renderCanvas();
+ }
+
+ setStatus("Rendering...");
+
+ try {
+ const html = await renderTemplate();
+ previewApplying = true;
+ state.rendered = html;
+ ui.previewToggle.textContent = "Back to editing";
+ setStatus("");
+ } catch (err) {
+ setStatus(err.message || "The template could not be rendered", "error");
+ }
+
+ renderCanvas();
+ previewApplying = false;
+}
+
+function renderCanvas() {
+ renderCanvasBody();
+ if (state.rendered !== null && !previewApplying) schedulePreview();
+}
+
+function schedulePreview() {
+ clearTimeout(previewTimer);
+ previewTimer = setTimeout(refreshPreview, 400);
+}
+
+async function refreshPreview() {
+ try {
+ const html = await renderTemplate();
+ previewApplying = true;
+ state.rendered = html;
+ renderCanvasBody();
+ setStatus("");
+ } catch (err) {
+ setStatus(err.message || "The template could not be rendered", "error");
+ } finally {
+ previewApplying = false;
+ }
+}
+
+function renderCanvasBody() {
+ ui.overlay.classList.toggle("capi-previewing", state.rendered !== null);
+ ui.canvas.replaceChildren();
+
+ const content = div("widget-content");
+ const widget = div("widget");
+ widget.append(content);
+ const preview = div("capi-preview");
+ preview.append(widget);
+
+ if (state.rendered !== null) {
+ content.innerHTML = state.rendered;
+ ui.canvas.append(preview);
+ window.dynacatSetupCollapsibleLists?.();
+ return;
+ }
+
+ if (state.warn) {
+ ui.canvas.append(div("capi-warning", "This widget has a hand written template. Applying here replaces it."));
+ }
+
+ state.rows.forEach((row, r) => {
+ const wrapper = div("capi-row-wrapper");
+ const rowEl = div("capi-row");
+ if (state.sel && state.sel.r === r && state.sel.c === null) rowEl.classList.add("capi-row-selected");
+
+ wrapper.addEventListener("click", () => {
+ state.sel = { r, c: null, col: null };
+ renderAll();
+ });
+
+ row.blocks.forEach((block, c) => rowEl.append(buildCell(block, row, r, c)));
+
+ if (row.blocks.length === 0) {
+ rowEl.append(div("capi-empty", "Drop a block here or pick one on the left."));
+ } else if (row.blocks.length < MAX_CELLS && !row.blocks.some((b) => b.kind === "list")) {
+ const add = div("capi-cell-add");
+ add.innerHTML = iconPlus;
+ add.title = "Add a block to this row";
+ add.addEventListener("click", (event) => {
+ event.stopPropagation();
+ addBlock("stat", row);
+ });
+ rowEl.append(add);
+ }
+
+ dropTarget(rowEl, "capi-row-drop", (d) => d.type === "block", (d) => addBlock(d.kind, row));
+
+ const tools = div("capi-row-tools");
+ const moveUp = iconBtn("Move row up", iconChevron, () => moveRow(r, -1));
+ moveUp.classList.add("capi-row-up");
+ moveUp.disabled = r === 0;
+ const moveDown = iconBtn("Move row down", iconChevron, () => moveRow(r, 1));
+ moveDown.classList.add("capi-row-down");
+ moveDown.disabled = r === state.rows.length - 1;
+
+ const removeRow = iconBtn("Remove row", iconTrash, () => {
+ state.rows.splice(r, 1);
+ state.sel = null;
+ state.dirty = true;
+ renderAll();
+ });
+ removeRow.classList.add("capi-icon-btn-danger");
+ tools.append(moveUp, moveDown, removeRow);
+
+ wrapper.append(rowEl, tools);
+ content.append(wrapper);
+ });
+
+ if (state.rows.length === 0) {
+ content.append(div("capi-empty", "Pick a block on the left to start this widget."));
+ }
+
+ const addRowBtn = button("Add row", "editor-btn capi-add-row");
+ addRowBtn.addEventListener("click", addRow);
+
+ ui.canvas.append(preview, addRowBtn);
+}
+
+// The selection travels with the row so the inspector keeps showing the same block.
+function moveRow(r, step) {
+ const target = r + step;
+ if (target < 0 || target >= state.rows.length) return;
+
+ [state.rows[r], state.rows[target]] = [state.rows[target], state.rows[r]];
+ if (state.sel && state.sel.r === r) state.sel = { ...state.sel, r: target };
+ else if (state.sel && state.sel.r === target) state.sel = { ...state.sel, r };
+ state.dirty = true;
+ renderAll();
+}
+
+function buildCell(block, row, r, c) {
+ const cell = div(`capi-cell${block.kind === "icon" ? " capi-cell-icon" : ""}`);
+ if (state.sel && state.sel.r === r && state.sel.c === c) cell.classList.add("capi-cell-selected");
+
+ cell.addEventListener("click", (event) => {
+ event.stopPropagation();
+ state.sel = { r, c, col: null };
+ renderAll();
+ });
+
+ if (block.kind !== "list") {
+ cell.append(iconBtn("Remove block", iconTrash, () => {
+ row.blocks.splice(c, 1);
+ if (row.blocks.length === 0) state.rows.splice(state.rows.indexOf(row), 1);
+ state.sel = null;
+ state.dirty = true;
+ renderAll();
+ }));
+ cell.firstChild.classList.add("capi-cell-remove");
+ }
+
+ cell.append(previewBlock(block, r, c));
+ dropTarget(cell, "capi-cell-drop", () => true, (d) => {
+ if (d.type === "field") bindField(d.src, d.entry, { block, col: null });
+ else addBlock(d.kind, row);
+ });
+
+ return cell;
+}
+
+function iconRow(node, icon, side) {
+ const row = div("flex items-center justify-center gap-7");
+ row.append(...(side === "right" ? [node, iconImg(icon)] : [iconImg(icon), node]));
+ return row;
+}
+
+function previewBlock(block, r, c) {
+ const blockIcon = (block.icon || "").trim();
+
+ if (block.kind === "text") {
+ const text = div(`size-h4 ${block.color}`, block.text || "Text");
+ return blockIcon ? iconRow(text, blockIcon, block.iconSide) : text;
+ }
+
+ if (block.kind === "icon") return iconImg(block.icon);
+
+ if (block.kind === "stat") {
+ const wrapper = div("text-center");
+ const value = previewValue(block.value, null);
+ const line = div(`size-h3 ${previewColor(block.value, value.raw)}`, value.text);
+
+ wrapper.append(blockIcon ? iconRow(line, blockIcon, block.iconSide) : line);
+ if ((block.label || "").trim()) wrapper.append(div("size-h6 uppercase color-subdue", block.label));
+ if (!conditionHolds(block.value, null)) wrapper.classList.add("capi-hidden-by-rule");
+ return wrapper;
+ }
+
+ const wrapper = div("capi-list-wrapper");
+ const body = div("capi-list-body");
+ const stack = div("capi-list-stack");
+ const list = div("capi-list list list-gap-10");
+ const limit = block.limit || 5;
+
+ if (!block.path) wrapper.append(div("capi-list-caption", "Pick an array field on the left, then bind each column"));
+
+ // Fade the other columns so the selected one stands out without extra boxes.
+ const selectedCol = state.sel && state.sel.r === r && state.sel.c === c ? state.sel.col : null;
+ const muted = (index) => (selectedCol !== null && selectedCol !== index ? " capi-list-cell-muted" : "");
+ const columnCells = block.columns.map(() => []);
+ const bands = div("capi-col-layer capi-col-bands");
+ const hits = div("capi-col-layer capi-col-hits");
+ stack.append(bands);
+
+ const markColumn = (index, active) => {
+ columnCells[index].forEach((cell) => cell.classList.toggle("capi-list-cell-over", active));
+ bands.children[index]?.classList.toggle("capi-col-band-on", active);
+ };
+
+ if (block.columns.some((column) => (column.label || "").trim())) {
+ const head = div("capi-list-head flex items-center gap-10 size-h6 uppercase color-subdue");
+ block.columns.forEach((column, index) => {
+ const align = columnAlign(column, index, block.columns.length) + muted(index);
+ const cell = column.kind === "icon" ? div(`${align} flex items-center justify-end`) : div(align, column.label || "");
+ if (column.kind === "icon") cell.append(el("span", "nowrap", column.label || ""));
+
+ columnCells[index].push(cell);
+ head.append(cell);
+ });
+ stack.append(head);
+ }
+
+ sortItems(block, listItems(block)).slice(0, limit).forEach((item) => {
+ const line = div("flex items-center gap-10");
+ block.columns.forEach((column, index) => {
+ const cell = column.kind === "icon" ? previewIconColumn(column, item) : previewColumn(column, item);
+ cell.className += ` ${columnAlign(column, index, block.columns.length)}${muted(index)}`;
+ columnCells[index].push(cell);
+
+ cell.addEventListener("click", (event) => {
+ event.stopPropagation();
+ state.sel = { r, c, col: index };
+ state.reveal = true;
+ renderAll();
+ });
+
+ line.append(cell);
+ });
+ list.append(line);
+ });
+
+ block.columns.forEach((column, index) => {
+ const align = columnAlign(column, index, block.columns.length);
+ bands.append(div(`capi-col-band ${align}`));
+
+ const hit = div(`capi-col-hit ${align}`);
+ hit.title = "Drop a field here to replace this column";
+ dropTarget(hit, "capi-col-hit-over", (d) => d.type === "field" || d.type === "item-field", (d) => {
+ state.sel = { r, c, col: index };
+ state.reveal = true;
+ if (d.type === "item-field") bindItemField(block, d.entry);
+ else bindField(d.src, d.entry, { block, col: index });
+ }, (active) => markColumn(index, active));
+
+ hits.append(hit);
+ });
+
+ stack.append(list, hits);
+ body.append(stack, columnAddZone(block, r, c));
+ wrapper.append(body);
+ return wrapper;
+}
+
+// The counterpart to dropping on a column: this one appends a column instead of replacing one.
+function columnAddZone(block, r, c) {
+ const zone = div("capi-col-add");
+ zone.innerHTML = iconPlus;
+ zone.title = "Drop a field here to add a column";
+
+ dropTarget(zone, "capi-col-add-over", (d) => d.type === "field" || d.type === "item-field", (d) => {
+ if (d.type === "field" && !columnFieldFits(block, d)) {
+ return ctx.toast("Pick a field from inside the list array", "negative");
+ }
+
+ block.columns.push(newValue("item"));
+ const index = block.columns.length - 1;
+ state.sel = { r, c, col: index };
+ state.reveal = true;
+
+ if (d.type === "item-field") bindItemField(block, d.entry);
+ else bindField(d.src, d.entry, { block, col: index });
+ });
+
+ return zone;
+}
+
+function columnFieldFits(block, d) {
+ return Boolean(block.path) && d.src === block.src && d.entry.path.startsWith(`${block.path}.0.`);
+}
+
+function previewColumn(column, item) {
+ const value = previewValue(column, item);
+ const cell = div(`capi-list-cell ${previewColor(column, value.raw)}`);
+ const text = el("span", "", value.text);
+
+ if ((column.icon || "").trim()) {
+ cell.classList.add("flex", "items-center", "gap-7");
+ cell.append(...(column.iconSide === "right" ? [text, iconImg(column.icon)] : [iconImg(column.icon), text]));
+ } else {
+ cell.append(text);
+ }
+
+ if (!conditionHolds(column, item)) cell.classList.add("capi-hidden-by-rule");
+ return cell;
+}
+
+function previewIconColumn(column, item) {
+ const cell = div("capi-list-cell capi-list-icon");
+
+ if (!column.path || !column.icon) {
+ cell.title = "Pick a field and an icon for this column";
+ cell.append(el("span", "color-subdue", "?"));
+ return cell;
+ }
+
+ const raw = getPath(item, column.path);
+ const shown = matches({ op: column.op, than: column.than }, raw) ? column.icon : column.elseIcon;
+ if ((shown || "").trim()) cell.append(iconImg(shown));
+ return cell;
+}
+
+function conditionHolds(value, item) {
+ if (!value.when) return true;
+ return matches(value.when, previewValue(value, item).raw);
+}
+
+function matches(rule, raw) {
+ if (ruleIsText(rule)) {
+ const same = String(raw) === String(rule.than);
+ return rule.op === "is" ? same : !same;
+ }
+
+ return compare(Number(raw), rule);
+}
+
+function sortItems(block, items) {
+ if (!block.sort) return items;
+
+ const column = block.columns.find((c) => c.path === block.sort && c.kind !== "icon");
+ const numeric = column && isNumericFormat(column.format);
+ const direction = block.sortOrder === "asc" ? 1 : -1;
+
+ return [...items].sort((a, b) => {
+ const left = getPath(a, block.sort);
+ const right = getPath(b, block.sort);
+ if (numeric) return (Number(left) - Number(right)) * direction;
+ return String(left ?? "").localeCompare(String(right ?? "")) * direction;
+ });
+}
+
+function sourceJSON(src) {
+ if (!state.data) return null;
+ return src === "json" ? state.data.json : state.data.subs[src];
+}
+
+function listItems(block) {
+ const value = getPath(sourceJSON(block.src), block.path);
+ return Array.isArray(value) ? value : [];
+}
+
+function getPath(source, path) {
+ if (source === null || source === undefined) return undefined;
+ if (!path) return source;
+
+ let current = source;
+ for (const part of path.split(".")) {
+ if (current === null || current === undefined) return undefined;
+ current = Array.isArray(current) ? current[Number(part)] : current[part];
+ }
+ return current;
+}
+
+function previewValue(value, item) {
+ if (!value.path) return { text: PICK_FIELD, raw: null };
+
+ const source = value.scope === "item" ? item : sourceJSON(value.src);
+ let raw = getPath(source, value.path);
+ if (raw === undefined) return { text: "-", raw: null };
+
+ if (value.op) raw = applyMath(Number(raw), value);
+ return { text: formatSample(value, raw, item), raw };
+}
+
+function applyMath(number, value) {
+ const by = Number(value.by);
+ if (value.op === "add") return number + by;
+ if (value.op === "sub") return number - by;
+ if (value.op === "mul") return number * by;
+ if (value.op === "div") return by === 0 ? 0 : number / by;
+ return number;
+}
+
+function formatSample(value, raw, item) {
+ const number = Number(raw);
+
+ switch (value.format) {
+ case "number":
+ return Number.isFinite(number) ? Math.trunc(number).toLocaleString("en-US") : String(raw);
+ case "approx":
+ return approxNumber(number);
+ case "bytes":
+ return formatBytes(number);
+ case "decimals":
+ return number.toFixed(Number(value.digits));
+ case "percent":
+ return `${number.toFixed(Number(value.digits))}%`;
+ case "percent-change": {
+ const source = value.scope === "item" ? item : sourceJSON(value.src);
+ const previous = Number(getPath(source, value.against));
+ const change = previous === 0 ? 0 : ((number - previous) / previous) * 100;
+ return `${change.toFixed(Number(value.digits))}%`;
+ }
+ case "date":
+ return formatDate(value, raw);
+ case "relative":
+ return relativeTime(toDate(value, raw));
+ default:
+ return String(raw);
+ }
+}
+
+function approxNumber(number) {
+ if (!Number.isFinite(number)) return "0";
+ if (number >= 1000000) return `${Math.round(number / 100000) / 10}m`;
+ if (number >= 1000) return `${Math.round(number / 100) / 10}k`;
+ return String(Math.trunc(number));
+}
+
+function formatBytes(number) {
+ if (!Number.isFinite(number)) return "?";
+ const units = ["B", "KB", "MB", "GB", "TB", "PB"];
+ let value = number;
+ let i = 0;
+ while (value >= 1024 && i < units.length - 1) {
+ value /= 1024;
+ i++;
+ }
+ return `${i === 0 ? Math.round(value) : value.toFixed(1)} ${units[i]}`;
+}
+
+function toDate(value, raw) {
+ return value.parse === "unix" ? new Date(Number(raw) * 1000) : new Date(String(raw));
+}
+
+function formatDate(value, raw) {
+ const date = toDate(value, raw);
+ if (Number.isNaN(date.getTime())) return "-";
+ const iso = date.toISOString();
+ return value.layout === "DateTime" ? iso.slice(0, 19).replace("T", " ") : iso.slice(0, 10);
+}
+
+function relativeTime(date) {
+ if (Number.isNaN(date.getTime())) return "-";
+ const seconds = Math.max(1, Math.round((Date.now() - date.getTime()) / 1000));
+ if (seconds < 60) return `${seconds}s`;
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
+ if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
+ if (seconds < 2592000) return `${Math.round(seconds / 86400)}d`;
+ return `${Math.round(seconds / 2592000)}mo`;
+}
+
+function previewColor(value, raw) {
+ if (!value.rule || raw === null) return value.color;
+ return matches(value.rule, raw) ? value.rule.then : value.rule.else;
+}
+
+function compare(number, rule) {
+ const than = Number(rule.than);
+ if (rule.op === "gt") return number > than;
+ if (rule.op === "lt") return number < than;
+ if (rule.op === "ge") return number >= than;
+ if (rule.op === "le") return number <= than;
+ return number === than;
+}
+
+function networkSummary() {
+ const parts = [state.url.trim() || "No request url"];
+ if (state.headers.length > 0) parts.push(`${state.headers.length} headers`);
+ if (state.subrequests.length > 0) parts.push(`${state.subrequests.length} subrequests`);
+ return parts.join(" - ");
+}
+
+function headersEditor(pairs) {
+ const wrapper = div("capi-headers");
+ wrapper.append(div("capi-field-label", "Headers"));
+
+ pairs.forEach((pair, index) => {
+ const row = div("capi-header-row");
+ const remove = iconBtn("Remove header", iconTrash, () => {
+ pairs.splice(index, 1);
+ state.dirty = true;
+ renderNetwork();
+ });
+
+ // The name and its remove button share a line, the longer value gets the next one.
+ row.append(
+ input(pair.key, "Authorization", (value) => {
+ pair.key = value;
+ state.dirty = true;
+ }),
+ remove,
+ input(pair.value, "Bearer ${API_KEY}", (value) => {
+ pair.value = value;
+ state.dirty = true;
+ })
+ );
+ wrapper.append(row);
+ });
+
+ const add = button("Add header", "editor-btn capi-mini-btn");
+ add.addEventListener("click", () => {
+ pairs.push({ key: "", value: "" });
+ state.dirty = true;
+ renderNetwork();
+ });
+ wrapper.append(add);
+
+ return wrapper;
+}
+
+function renderNetwork() {
+ ui.net.className = `capi-net${state.netOpen ? "" : " capi-net-collapsed"}`;
+ ui.net.replaceChildren();
+
+ const toggle = () => {
+ state.netOpen = !state.netOpen;
+ renderNetwork();
+ };
+
+ const chevron = iconBtn(state.netOpen ? "Collapse network" : "Expand network", iconChevron, toggle);
+ chevron.classList.add("capi-net-toggle");
+ chevron.setAttribute("aria-expanded", String(state.netOpen));
+
+ const head = div("capi-net-head");
+ head.append(div("capi-pane-title", "Network"), chevron);
+ head.addEventListener("click", toggle);
+ ui.net.append(head);
+
+ if (!state.netOpen) return ui.net.append(div("capi-net-summary", networkSummary()));
+
+ const body = div("capi-net-body");
+ body.append(
+ labeled("Request url", input(state.url, "https://api.example.com/stats", (value) => {
+ state.url = value;
+ state.dirty = true;
+ })),
+ headersEditor(state.headers),
+ div("capi-field-label", "Subrequests")
+ );
+
+ state.subrequests.forEach((sub, index) => {
+ const card = div("capi-source");
+ const remove = iconBtn("Remove subrequest", iconTrash, () => {
+ state.subrequests.splice(index, 1);
+ state.dirty = true;
+ renderNetwork();
+ });
+
+ card.append(withRemove(labeled("Name", input(sub.name, "tags", (value) => {
+ sub.name = value;
+ state.dirty = true;
+ })), remove));
+
+ card.append(
+ labeled("Url", input(sub.url, "https://api.example.com/other", (value) => {
+ sub.url = value;
+ state.dirty = true;
+ })),
+ headersEditor(sub.headers)
+ );
+ body.append(card);
+ });
+
+ const add = button("Add subrequest", "editor-btn capi-mini-btn");
+ add.addEventListener("click", () => {
+ state.subrequests.push({ name: "", url: "", headers: [] });
+ state.dirty = true;
+ renderNetwork();
+ });
+ body.append(add);
+
+ ui.net.append(body);
+}
+
+function renderInspector() {
+ ui.inspector.replaceChildren();
+
+ const block = selectedBlock();
+ if (!block) {
+ ui.inspector.append(div("capi-empty", "Select a block to edit it."));
+ return;
+ }
+
+ if (block.kind === "text") {
+ ui.inspector.append(
+ labeled("Text", input(block.text, "Storage", (value) => {
+ block.text = value;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+ ui.inspector.append(...iconFields(block, "Icon - optional", () => {
+ state.dirty = true;
+ renderAll();
+ }));
+ ui.inspector.append(labeled("Color", colorSelect(block, "color")));
+ return;
+ }
+
+ if (block.kind === "icon") {
+ ui.inspector.append(
+ labeled("Icon", iconInput(block.icon, (value) => {
+ block.icon = value;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+ return;
+ }
+
+ if (block.kind === "stat") {
+ ui.inspector.append(
+ labeled("Label - optional", input(block.label, "Downloads", (value) => {
+ block.label = value;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+ ui.inspector.append(...iconFields(block, "Icon - optional", () => {
+ state.dirty = true;
+ renderAll();
+ }));
+ ui.inspector.append(valueEditor(block.value));
+ return;
+ }
+
+ ui.inspector.append(labeled("Array to repeat", staticText(block.path || "click an array field on the left")));
+ ui.inspector.append(
+ labeled("Rows to show", numberInput(block.limit, (value) => {
+ block.limit = value;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+
+ const sortable = block.columns.filter((column) => column.path && column.kind !== "icon");
+ if (sortable.length > 0) {
+ const options = [["", "Keep the API order"], ...sortable.map((column) => {
+ const position = block.columns.indexOf(column) + 1;
+ return [column.path, `Column ${position} - ${column.label || column.path}`];
+ })];
+
+ ui.inspector.append(
+ labeled("Sort rows by", select(options, block.sort || "", (value) => {
+ block.sort = value;
+ state.dirty = true;
+ renderAll();
+ }))
+ );
+ }
+
+ if (block.sort) {
+ ui.inspector.append(
+ labeled("Direction", select([["desc", "Highest first"], ["asc", "Lowest first"]], block.sortOrder || "desc", (value) => {
+ block.sortOrder = value;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+ }
+
+ block.columns.forEach((column, index) => {
+ const key = `${state.sel.r}:${state.sel.c}:${index}`;
+ const selected = state.sel.col === index;
+
+ // Selecting a value opens its card without disturbing the ones the user collapsed.
+ if (selected && state.reveal) state.opened = key;
+ const collapsed = state.collapsed.has(key) && !(selected && state.opened === key);
+
+ const section = div(`capi-column${selected ? " capi-column-selected" : ""}${collapsed ? " capi-column-collapsed" : ""}`);
+ section.addEventListener("click", () => {
+ state.sel.col = index;
+ renderCanvas();
+ });
+
+ const toggle = () => {
+ if (collapsed) state.collapsed.delete(key);
+ else state.collapsed.add(key);
+ if (state.opened === key) state.opened = null;
+ renderInspector();
+ };
+
+ const chevron = iconBtn(collapsed ? "Expand column" : "Collapse column", iconChevron, toggle);
+ chevron.classList.add("capi-column-toggle");
+ chevron.setAttribute("aria-expanded", String(!collapsed));
+
+ const head = div("capi-column-head");
+ head.append(div("capi-column-title", `Column ${index + 1}${column.label ? ` - ${column.label}` : ""}`), chevron);
+ head.addEventListener("click", (event) => {
+ event.stopPropagation();
+ toggle();
+ });
+
+ const remove = button("Remove", "editor-btn capi-mini-btn");
+ remove.addEventListener("click", () => {
+ block.columns.splice(index, 1);
+ state.sel.col = null;
+ state.dirty = true;
+ renderAll();
+ });
+
+ const body = div("capi-column-body");
+ body.append(column.kind === "icon" ? iconColumnEditor(column, remove) : valueColumnEditor(column, remove));
+ section.append(head, body);
+ ui.inspector.append(section);
+
+ if (selected && state.reveal) requestAnimationFrame(() => revealColumn(section));
+ });
+
+ state.reveal = false;
+
+ const addColumn = button("+ Column", "editor-btn capi-mini-btn");
+ addColumn.addEventListener("click", () => {
+ block.columns.push(newValue("item"));
+ state.sel.col = block.columns.length - 1;
+ state.dirty = true;
+ renderAll();
+ });
+
+ const addIcon = button("+ Icon column", "editor-btn capi-mini-btn");
+ addIcon.addEventListener("click", () => {
+ block.columns.push(newIconColumn());
+ state.sel.col = block.columns.length - 1;
+ state.dirty = true;
+ renderAll();
+ });
+
+ const columnActions = div("capi-column-actions");
+ columnActions.append(addColumn, addIcon);
+ ui.inspector.append(columnActions);
+}
+
+// A card taller than the pane scrolls to its top, a shorter one is only nudged into view.
+function revealColumn(section) {
+ const pane = ui.inspector.getBoundingClientRect();
+ const card = section.getBoundingClientRect();
+ const delta = card.height > pane.height || card.top < pane.top
+ ? card.top - pane.top - 8
+ : Math.max(0, card.bottom - pane.bottom + 8);
+
+ if (delta) ui.inspector.scrollTo({ top: ui.inspector.scrollTop + delta, behavior: "smooth" });
+}
+
+// The remove button sits on the first field, so the card needs no heading of its own.
+function withRemove(field, remove) {
+ field.classList.add("capi-field-with-action");
+ field.firstChild.after(remove);
+ return field;
+}
+
+function valueColumnEditor(column, remove) {
+ const wrapper = div("capi-value");
+
+ wrapper.append(withRemove(labeled("Column label - optional", input(column.label, "Name", (value) => {
+ column.label = value;
+ state.dirty = true;
+ renderCanvas();
+ })), remove));
+
+ wrapper.append(
+ labeled("Text alignment", select(COLUMN_ALIGNS, column.align, (value) => {
+ column.align = value;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+
+ wrapper.append(
+ labeled("Long values", select(COLUMN_OVERFLOWS, column.overflow, (value) => {
+ column.overflow = value;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+
+ wrapper.append(...iconFields(column, "Icon - optional", () => {
+ state.dirty = true;
+ renderAll();
+ }));
+
+ wrapper.append(valueEditor(column));
+ return wrapper;
+}
+
+function iconColumnEditor(column, remove) {
+ const wrapper = div("capi-value");
+
+ wrapper.append(withRemove(labeled("Column label - optional", input(column.label, "Status", (value) => {
+ column.label = value;
+ state.dirty = true;
+ renderAll();
+ })), remove));
+
+ wrapper.append(labeled("Field", staticText(column.path || "click a field on the left")));
+
+ wrapper.append(
+ labeled("Show the first icon when the value", select([...TEXT_RULE_OPS, ...NUMBER_RULE_OPS], column.op, (value) => {
+ column.op = value;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+
+ wrapper.append(
+ labeled("Compared with", input(column.than, "true", (value) => {
+ column.than = value;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+
+ wrapper.append(
+ labeled("Icon when it matches", iconInput(column.icon, (value) => {
+ column.icon = value;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+
+ wrapper.append(
+ labeled("Icon otherwise - leave empty to hide it", iconInput(column.elseIcon, (value) => {
+ column.elseIcon = value;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+
+ return wrapper;
+}
+
+function staticText(text) {
+ return div("capi-static", text);
+}
+
+function numberInput(value, onChange) {
+ const field = el("input", "editor-input");
+ field.type = "number";
+ field.value = value;
+ field.addEventListener("input", () => onChange(Number(field.value)));
+ return field;
+}
+
+function colorSelect(target, key) {
+ return select(COLORS.map((c) => [c, c.replace("color-", "")]), target[key], (value) => {
+ target[key] = value;
+ state.dirty = true;
+ renderCanvas();
+ });
+}
+
+function valueEditor(value) {
+ const wrapper = div("capi-value");
+
+ wrapper.append(labeled("Field", staticText(value.path || "click a field on the left")));
+
+ wrapper.append(
+ labeled("Math", select(MATH_OPS, value.op, (op) => {
+ value.op = op;
+ state.dirty = true;
+ renderAll();
+ }))
+ );
+
+ if (value.op) {
+ wrapper.append(
+ labeled("By", numberInput(value.by, (by) => {
+ value.by = by;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+ }
+
+ wrapper.append(
+ labeled("Format", select(FORMATS, value.format, (format) => {
+ value.format = format;
+ state.dirty = true;
+ renderAll();
+ }))
+ );
+
+ if (["decimals", "percent", "percent-change"].includes(value.format)) {
+ wrapper.append(
+ labeled("Decimals", numberInput(value.digits, (digits) => {
+ value.digits = digits;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+ }
+
+ if (value.format === "percent-change") {
+ wrapper.append(
+ labeled("Compared to field", input(value.against, "previous", (path) => {
+ value.against = path;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+ }
+
+ if (value.format === "date" || value.format === "relative") {
+ wrapper.append(
+ labeled("Source format", select([["unix", "Unix seconds"], ["rfc3339", "RFC3339"]], value.parse, (parse) => {
+ value.parse = parse;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+ }
+
+ if (value.format === "date") {
+ wrapper.append(
+ labeled("Shown as", select([["DateOnly", "Date"], ["DateTime", "Date and time"]], value.layout, (layout) => {
+ value.layout = layout;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+ }
+
+ const numeric = isNumericFormat(value.format) || Boolean(value.op);
+ const ops = numeric ? NUMBER_RULE_OPS : TEXT_RULE_OPS;
+ const defaults = numeric
+ ? { op: "gt", than: 0, then: "color-positive", else: "color-negative" }
+ : { op: "is", than: "true", then: "color-positive", else: "color-negative" };
+
+ if (value.rule && ruleIsText(value.rule) !== !numeric) value.rule = { ...defaults };
+
+ if (!value.rule) wrapper.append(labeled("Color", colorSelect(value, "color")));
+
+ wrapper.append(checkbox("Color by a rule", value.rule !== null, (on) => {
+ value.rule = on ? { ...defaults } : null;
+ state.dirty = true;
+ renderAll();
+ }));
+
+ if (value.rule) {
+ const rule = value.rule;
+ wrapper.append(
+ labeled("When the value", select(ops, rule.op, (op) => {
+ rule.op = op;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+ wrapper.append(labeled("Compared with", thresholdInput(rule, numeric)));
+ wrapper.append(labeled("Then", colorSelect(rule, "then")));
+ wrapper.append(labeled("Otherwise", colorSelect(rule, "else")));
+ }
+
+ wrapper.append(checkbox("Show only when it matches", value.when !== null, (on) => {
+ value.when = on ? { op: numeric ? "gt" : "is", than: numeric ? 0 : "true" } : null;
+ state.dirty = true;
+ renderAll();
+ }));
+
+ if (value.when) {
+ const when = value.when;
+ wrapper.append(
+ labeled("Show when the value", select(ops, when.op, (op) => {
+ when.op = op;
+ state.dirty = true;
+ renderCanvas();
+ }))
+ );
+ wrapper.append(labeled("Compared with", thresholdInput(when, numeric)));
+ }
+
+ return wrapper;
+}
+
+function iconFields(target, label, onChange) {
+ const fields = [labeled(label, iconInput(target.icon, (value) => {
+ target.icon = value;
+ onChange();
+ }))];
+
+ if ((target.icon || "").trim()) {
+ fields.push(labeled("Icon position", select([["left", "Before the value"], ["right", "After the value"]], target.iconSide || "left", (value) => {
+ target.iconSide = value;
+ onChange();
+ })));
+ }
+
+ return fields;
+}
+
+function thresholdInput(target, numeric) {
+ if (numeric) {
+ return numberInput(target.than, (value) => {
+ target.than = value;
+ state.dirty = true;
+ renderCanvas();
+ });
+ }
+
+ return input(String(target.than ?? ""), "true", (value) => {
+ target.than = value;
+ state.dirty = true;
+ renderCanvas();
+ });
+}
+
+function checkbox(label, checked, onChange) {
+ const row = el("label", "capi-check editor-check");
+ const box = el("input");
+ box.type = "checkbox";
+ box.checked = checked;
+ box.addEventListener("change", () => onChange(box.checked));
+ row.append(box, el("span", "editor-check-label", label));
+ return row;
+}
+
+function renderAll() {
+ renderFields();
+ renderCanvas();
+ renderInspector();
+}
+
+let varCounter = 0;
+
+function isNumericFormat(format) {
+ return ["number", "approx", "bytes", "decimals", "percent", "percent-change"].includes(format);
+}
+
+function ruleIsText(rule) {
+ return rule.op === "is" || rule.op === "isnot";
+}
+
+function methodFor(value) {
+ if (value.op) return "Float";
+ if (value.rule) return ruleIsText(value.rule) ? "String" : "Float";
+ if (["number", "approx", "bytes"].includes(value.format)) return "Int";
+ if (["decimals", "percent", "percent-change"].includes(value.format)) return "Float";
+ return "String";
+}
+
+function accessor(value, path, method) {
+ if (value.scope === "item") return `$item.${method} "${path}"`;
+ if (value.src === "json") return `$.JSON.${method} "${path}"`;
+ return `($.Subrequest "${value.src}").JSON.${method} "${path}"`;
+}
+
+function floatLiteral(number) {
+ return Number.isInteger(Number(number)) ? `${Number(number)}.0` : String(Number(number));
+}
+
+function baseExpression(value) {
+ const method = methodFor(value);
+ let expr = `(${accessor(value, value.path, method)})`;
+ if (value.op) expr = `(${value.op} ${expr} ${floatLiteral(value.by)})`;
+ return expr;
+}
+
+// The whole-number helpers need an int, so a float expression is narrowed first.
+function formatExpression(value, base, isFloat) {
+ const whole = isFloat ? `(toInt ${base})` : base;
+
+ switch (value.format) {
+ case "number":
+ return `formatNumber ${whole}`;
+ case "approx":
+ return `formatApproxNumber ${whole}`;
+ case "bytes":
+ return `formatBytes ${base}`;
+ case "decimals":
+ return `printf "%.${value.digits}f" ${base}`;
+ case "percent":
+ return `printf "%.${value.digits}f%%" ${base}`;
+ case "percent-change":
+ if (!value.against) return `printf "%.${value.digits}f%%" ${base}`;
+ return `printf "%.${value.digits}f%%" (percentChange ${base} (${accessor(value, value.against, "Float")}))`;
+ case "date":
+ return `formatTime "${value.layout}" (parseTime "${value.parse}" ${base})`;
+ default:
+ return base;
+ }
+}
+
+function ruleClass(rule, variable) {
+ return `{{ if ${comparison(rule, variable)} }}${rule.then}{{ else }}${rule.else}{{ end }}`;
+}
+
+function comparison(rule, variable) {
+ if (ruleIsText(rule)) return `${rule.op === "is" ? "eq" : "ne"} ${variable} "${String(rule.than).replace(/"/g, "")}"`;
+ return `${rule.op} ${variable} ${floatLiteral(rule.than)}`;
+}
+
+// A condition reads its own copy of the field so it never depends on the value pipeline.
+function conditionExpr(value, condition) {
+ const method = ruleIsText(condition) ? "String" : "Float";
+ return comparison(condition, `(${accessor(value, value.path, method)})`);
+}
+
+// Returns a value as its class name, the lines to emit before it, and the printed expression.
+function valueParts(value) {
+ if (!value.path) return { before: [], className: "color-subdue", output: `"${PICK_FIELD}"` };
+
+ const base = baseExpression(value);
+ const isFloat = methodFor(value) === "Float";
+
+ if (!value.rule) {
+ return { before: [], className: value.color, output: formatExpression(value, base, isFloat) };
+ }
+
+ const variable = `$v${varCounter++}`;
+
+ return {
+ before: [`{{ ${variable} := ${base} }}`],
+ className: ruleClass(value.rule, variable),
+ output: formatExpression({ ...value, op: "" }, variable, isFloat),
+ };
+}
+
+// Wraps markup in a condition when the value is set to show only in some cases.
+function guarded(value, indent, lines) {
+ if (!value.when || !value.path) return lines;
+ return [`${indent}{{ if ${conditionExpr(value, value.when)} }}`, ...lines, `${indent}{{ end }}`];
+}
+
+function iconTag(icon) {
+ const resolved = ctx.resolveIcon(icon);
+ return ``;
+}
+
+function statMarkup(block, indent) {
+ const label = (block.label || "").trim();
+ const icon = (block.icon || "").trim();
+ const labelLine = label ? [`${indent}
+
+
+
+
+
+
diff --git a/internal/dynacat/templates/monitor-compact.html b/internal/dynacat/templates/monitor-compact.html
index dec077d9..aaf56012 100644
--- a/internal/dynacat/templates/monitor-compact.html
+++ b/internal/dynacat/templates/monitor-compact.html
@@ -6,7 +6,7 @@
{{ if not (and .ShowFailingOnly (not .HasFailing)) }}
{{ range .Sites }}
- {{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }}{{ continue }}{{ end }}
+ {{ if and $.ShowFailingOnly (ne .StatusStyle "error" ) }}{{ continue }}{{ end }}
{{ template "site" . }}
@@ -29,7 +29,16 @@
{{ .Description }}
{{- end }}
+{{ if .History }}{{ template "monitor-history" . }}{{ end }}