diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index edaaf293..7523be55 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,7 +16,9 @@ on: jobs: deploy: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest + outputs: + is_latest: ${{ steps.latest_tag.outputs.is_latest }} env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} steps: @@ -107,7 +109,7 @@ jobs: release-binaries: if: startsWith(github.ref, 'refs/tags/') - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest strategy: matrix: include: @@ -143,3 +145,48 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release upload "${GITHUB_REF_NAME}" dynacat-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz --clobber + + notify-discord: + needs: [deploy, release-binaries] + if: startsWith(github.ref, 'refs/tags/') && needs.deploy.outputs.is_latest == 'true' + runs-on: ubuntu-slim + permissions: {} + steps: + - name: Announce the release on Discord + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + TAG: ${{ github.ref_name }} + run: | + if [ -z "${DISCORD_WEBHOOK_URL}" ]; then + echo "DISCORD_WEBHOOK_URL is not set, skipping the announcement" + exit 0 + fi + + payload="$(jq -n \ + --arg version "${TAG#v}" \ + --arg timestamp "$(TZ=Europe/Warsaw date +%Y-%m-%dT%H:%M:%S.%3N%:z)" \ + '{ + username: "Github Update", + avatar_url: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/github-light.svg", + embeds: [{ + title: "New Dynacat Release - \($version)", + url: "https://github.com/Panonim/dynacat/releases/latest", + color: 5793266, + timestamp: $timestamp, + footer: { text: "Europe/Warsaw" } + }] + }')" + + status="$(printf 'url = "%s"\n' "${DISCORD_WEBHOOK_URL}" | curl --config - \ + --silent --show-error --output response.txt --write-out '%{http_code}' \ + --request POST \ + --header 'Content-Type: application/json' \ + --data "${payload}")" + + if [ "${status}" -lt 200 ] || [ "${status}" -ge 300 ]; then + echo "Discord webhook failed with HTTP ${status}" + cat response.txt + exit 1 + fi + + echo "Announced ${TAG#v} on Discord (HTTP ${status})" diff --git a/Dockerfile b/Dockerfile index ed3199b3..42547958 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25.0-alpine3.21 AS builder +FROM golang:1.26-alpine AS builder WORKDIR /app diff --git a/README.md b/README.md index ada8c788..c2a4eaf2 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

Configuration • Discord • - Sponsor + Sponsor

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).** - - - - - Star History Chart - - +
+ + + + + Star History Chart + +
\ No newline at end of file diff --git a/docs/assets/app.js b/docs/assets/app.js index d208ce48..e4e89cbf 100644 --- a/docs/assets/app.js +++ b/docs/assets/app.js @@ -1648,7 +1648,9 @@ async function renderHome() { { page: 'themes', title: 'Themes', desc: 'Browse dark and light theme options', icon: `` }, { page: 'preconfigured-pages', title: 'Preconfigured Pages', desc: 'Ready-to-use page layouts', icon: `` }, { page: 'custom-api', title: 'Custom API', desc: 'Build widgets from any HTTP JSON API', icon: `` }, - { page: 'extensions', title: 'Extensions', desc: 'Extend Dynacat with custom HTML and scripts', icon: `` } + { page: 'widget-editor', title: 'Widget Editor', desc: 'Build custom-api widgets visually, no template needed', icon: `` }, + { page: 'authentication', title: 'Authentication', desc: 'Secure your dashboard with password or OIDC login', icon: `` }, + { page: 'ui-editor', title: 'UI Editor', desc: 'Drag, drop and theme the dashboard from the page itself', icon: `` } ]; const featureCards = features.map((f) => ` @@ -1764,7 +1766,13 @@ async function renderHome() {
- Sponsor + Paypal + + +
+ +
+ Ko-fi
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 ![](images/server-stats-flame-icon.png) +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 +![screenshot](images/themes/degoog-dark.png) +```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 +![screenshot](images/themes/degoog-light.png) +```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. + +![](images/ui-editor-overview.png) + +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. + +![](images/ui-editor-toggle.png) + +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. + +![](images/ui-editor-palette.png) + +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. + +![](images/ui-editor-add-column.png) + +To remove a column, click **Remove column** under it. Its widgets go with it, so you are asked to confirm first. + +![](images/ui-editor-column-remove.png) + +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. + +![](images/ui-editor-page-tools.png) + +**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. + +![](images/ui-editor-new-page.png) + +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. + +![](images/ui-editor-page-options.png) + +| 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. + +![](images/ui-editor-theme-trigger.png) + +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. + +![](images/ui-editor-theme-modal.png) + +**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. + +![](images/ui-editor-theme-actions.png) + +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. + +![](images/ui-editor-branding-badge.png) + +![](images/ui-editor-branding.png) + +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. + +![](images/ui-editor-open-editor.png) + +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. + +![](images/widget-editor-overview.png) + +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 + +![](images/widget-editor-example-widget.png) + +## 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. + +![](images/widget-editor-value-options.png) + +#### 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. + +![](images/widget-editor-color-rule.png) + +#### 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. + +![](images/widget-editor-row-list.png) + +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. + +![](images/widget-editor-icon-column.png) + +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. + +![](images/widget-editor-preview.png) + +## 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}
${escapeText(label)}
`] : []; + const parts = block.value.format === "relative" && block.value.path ? null : valueParts(block.value); + + const valueLine = parts + ? `${indent}
{{ ${parts.output} }}
` + : `${indent}
`; + + const inner = icon + ? [ + `${indent}
`, + ...(block.iconSide === "right" + ? [`${indent} ${valueLine.trim()}`, `${indent} ${iconTag(icon)}`] + : [`${indent} ${iconTag(icon)}`, `${indent} ${valueLine.trim()}`]), + `${indent}
`, + ] + : [valueLine]; + + return guarded(block.value, indent, [ + ...(parts ? parts.before.map((line) => indent + line) : []), + `${indent}
`, + ...inner, + ...labelLine, + `${indent}
`, + ]); +} + +function listArray(block) { + const source = block.src === "json" ? "$.JSON" : `($.Subrequest "${block.src}").JSON`; + const array = `${source}.Array "${block.path}"`; + + if (!block.sort) return array; + + const column = block.columns.find((c) => c.path === block.sort && c.kind !== "icon"); + const order = block.sortOrder === "asc" ? "asc" : "desc"; + + if (column && column.format === "date") return `sortByTime "${block.sort}" "${column.parse}" "${order}" (${array})`; + if (column && isNumericFormat(column.format)) return `sortByFloat "${block.sort}" "${order}" (${array})`; + return `sortByString "${block.sort}" "${order}" (${array})`; +} + +// Value columns share the row evenly, an icon column keeps the width of its icon. +function columnAlign(column, index, total) { + if (column.kind === "icon") return "shrink-0 width-ui-icon"; + + return `flex-1 min-width-0 ${overflowClass(column)} text-${column.align || edgeAlign(index, total)}`; +} + +// A long label spills the way its column faces, so an edge column never spills out of the widget. +function edgeAlign(index, total) { + if (index === 0) return "left"; + return index === total - 1 ? "right" : "center"; +} + +// Wrapping is capped at two lines so a long value cannot push the row list out of shape. +function overflowClass(column) { + return column.overflow === "wrap" ? "text-truncate-2-lines wrap-anywhere" : "text-truncate"; +} + +function iconColumnMarkup(column, indent, align) { + const wrap = `${align} flex items-center justify-center`; + if (!column.path || !column.icon) return [`${indent}
`]; + + const match = comparison({ op: column.op, than: column.than }, `(${accessor(column, column.path, ruleIsText({ op: column.op }) ? "String" : "Float")})`); + const fallback = (column.elseIcon || "").trim() ? iconTag(column.elseIcon) : ""; + + return [`${indent}
{{ if ${match} }}${iconTag(column.icon)}{{ else }}${fallback}{{ end }}
`]; +} + +function columnMarkup(column, indent, align) { + if (column.kind === "icon") return iconColumnMarkup(column, indent, align); + + const icon = (column.icon || "").trim(); + const wrap = icon ? `${align} flex items-center gap-7` : align; + + const withIcon = (inner) => (column.iconSide === "right" ? inner + iconTag(icon) : iconTag(icon) + inner); + + if (column.format === "relative" && column.path) { + const attrs = `{{ toRelativeTime (parseTime "${column.parse}" (${accessor(column, column.path, "String")})) }}`; + const line = icon + ? `${indent}
${withIcon(``)}
` + : `${indent}
`; + return guarded(column, indent, [line]); + } + + const parts = valueParts(column); + const before = parts.before.map((line) => `${indent}${line}`); + const line = icon + ? `${indent}
${withIcon(`{{ ${parts.output} }}`)}
` + : `${indent}
{{ ${parts.output} }}
`; + + return guarded(column, indent, [...before, line]); +} + +function listMarkup(block, indent) { + if (!block.path) return [`${indent}
${escapeText(PICK_FIELD)}
`]; + + const limit = block.limit || 5; + const labels = block.columns.filter((column) => (column.label || "").trim()); + const lines = []; + + if (labels.length > 0) { + lines.push(`${indent}
`); + block.columns.forEach((column, index) => { + const align = columnAlign(column, index, block.columns.length); + const label = escapeText(column.label || ""); + lines.push(column.kind === "icon" + ? `${indent}
${label}
` + : `${indent}
${label}
`); + }); + lines.push(`${indent}
`); + } + + lines.push( + `${indent}
    `, + `${indent} {{ range $item := ${listArray(block)} }}`, + `${indent}
  • ` + ); + + block.columns.forEach((column, index) => { + lines.push(...columnMarkup(column, `${indent} `, columnAlign(column, index, block.columns.length))); + }); + + lines.push(`${indent}
  • `, `${indent} {{ end }}`, `${indent}
`); + return lines; +} + +function blockMarkup(block, indent) { + if (block.kind === "text") { + const icon = (block.icon || "").trim(); + if (!icon) return [`${indent}
${escapeText(block.text)}
`]; + + const text = `${escapeText(block.text)}`; + return [ + `${indent}
`, + `${indent} ${block.iconSide === "right" ? text + iconTag(icon) : iconTag(icon) + text}`, + `${indent}
`, + ]; + } + + if (block.kind === "icon") return [`${indent}${iconTag(block.icon)}`]; + if (block.kind === "stat") return statMarkup(block, indent); + return listMarkup(block, indent); +} + +function escapeText(text) { + return String(text ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\{/g, "{"); +} + +export function generateTemplate() { + varCounter = 0; + + const rows = state.rows.filter((row) => row.blocks.length > 0); + const stacked = rows.length > 1; + const pad = stacked ? " " : ""; + const lines = []; + + // Stacked rows need their own gap, a bare sequence of divs renders cramped. + if (stacked) lines.push('
'); + + for (const row of rows) { + if (row.blocks.length === 1) { + lines.push(...blockMarkup(row.blocks[0], pad)); + continue; + } + + lines.push(`${pad}
`); + for (const block of row.blocks) { + lines.push(`${pad}
`); + lines.push(...blockMarkup(block, `${pad} `)); + lines.push(`${pad}
`); + } + lines.push(`${pad}
`); + } + + if (stacked) lines.push("
"); + + return `${lines.join("\n")}\n`; +} + +function validate() { + const problems = []; + const blocks = state.rows.flatMap((row) => row.blocks); + + if (blocks.length === 0) problems.push("Add at least one block"); + + for (const block of blocks) { + if (block.kind === "text" && !block.text.trim()) problems.push("A text block has no text"); + if (block.kind === "icon" && !block.icon.trim()) problems.push("An icon block has no icon"); + if (block.kind === "stat") { + if (!block.value.path) problems.push("A stat block has no field"); + if (block.value.format === "percent-change" && !block.value.against) { + problems.push("A percent change needs a field to compare against"); + } + } + if (block.kind === "list") { + if (!block.path) problems.push("A row list has no array field"); + if (block.columns.length === 0) problems.push("A row list has no columns"); + if (block.columns.some((column) => !column.path)) problems.push("A row list column has no field"); + if (block.columns.some((column) => column.kind === "icon" && !column.icon)) { + problems.push("An icon column has no icon"); + } + } + } + + const names = new Set(["json", ...state.subrequests.map((sub) => sub.name.trim())]); + const sources = blocks.flatMap((block) => { + if (block.kind === "stat") return [block.value.src]; + if (block.kind === "list") return [block.src]; + return []; + }); + if (sources.some((src) => !names.has(src))) problems.push("A block uses a subrequest that no longer exists"); + + const needsData = blocks.some((block) => block.kind === "stat" || block.kind === "list"); + if (needsData && !state.url.trim()) problems.push("Set a request url"); + + for (const sub of state.subrequests) { + if (!sub.name.trim() || !sub.url.trim()) problems.push("Every subrequest needs a name and a url"); + } + + const headers = [state.headers, ...state.subrequests.map((sub) => sub.headers)].flat(); + if (headers.some((header) => !header.key.trim())) problems.push("Every header needs a name"); + + return [...new Set(problems)]; +} + +async function applyChanges(applyBtn) { + const problems = validate(); + if (problems.length > 0) return setStatus(problems[0], "error"); + + const template = generateTemplate(); + applyBtn.disabled = true; + setStatus("Checking the template..."); + + try { + const payload = await postPreview({ template }); + if (payload.error) throw new Error(payload.error); + + ctx.write("url", state.url); + ctx.write("template", template); + + const headers = pairsObject(state.headers); + if (Object.keys(headers).length > 0) await ctx.write("headers", headers); + else ctx.clear("headers"); + + if (state.subrequests.length > 0) await ctx.write("subrequests", subrequestsObject()); + else ctx.clear("subrequests"); + ctx.hiddenValues.builder = JSON.stringify({ v: 1, rows: compactRows(state.rows) }).replaceAll("$", "\\u0024"); + + await ctx.submit(); + closeEditor(); + } catch (err) { + setStatus(err.message || "The template could not be rendered", "error"); + } finally { + applyBtn.disabled = false; + } +} diff --git a/internal/dynacat/static/js/docker-controller.js b/internal/dynacat/static/js/docker-controller.js index 366bbab9..a531415c 100644 --- a/internal/dynacat/static/js/docker-controller.js +++ b/internal/dynacat/static/js/docker-controller.js @@ -1,11 +1,8 @@ (function () { const base = (typeof pageData !== 'undefined' && pageData.baseURL) || ''; - // activePulls: widgetId -> [{pullId, image, statusText, isError}] const activePulls = new Map(); - // MutationObservers watching widgets for content replacements const widgetObservers = new Map(); - // confirmingItems: "widgetId:type:id" -> {origHTML, origTitle, timeoutId} const confirmingItems = new Map(); function escapeHtml(str) { @@ -16,7 +13,6 @@ .replace(/"/g, '"'); } - // Find the images
    list inside a widget (the section that has the pull input) function getImagesListForWidget(widgetId) { const widget = document.querySelector('.widget[data-widget-id="' + widgetId + '"]'); if (!widget) return null; @@ -115,8 +111,6 @@ updateNoImagesState(widgetId); } - // Watch a widget's direct children for replacements (i.e. widget-content swaps) - // and re-inject active pull entries after each swap. function watchWidget(widgetId) { if (widgetObservers.has(widgetId)) return; const widget = document.querySelector('.widget[data-widget-id="' + widgetId + '"]'); @@ -148,8 +142,6 @@ updateNoImagesState(widgetId); } - // Fetch fresh widget content and replace only the row for the given container ID. - // For 'remove' actions, the row is simply deleted from the DOM without a server fetch. async function updateContainerRow(widgetId, id, action) { const widget = document.querySelector('.widget[data-widget-id="' + widgetId + '"]'); if (!widget) return; @@ -203,7 +195,6 @@ btn.disabled = false; return; } - // Give Docker a moment to apply the state change before fetching fresh state await new Promise(function (r) { setTimeout(r, 400); }); await updateContainerRow(widgetId, id, action); }; @@ -246,7 +237,6 @@ return row ? row.querySelector('.docker-ctrl-icon-btn.remove') : null; } - // After a widget update, re-apply confirming visuals to any buttons that were in confirm state. document.addEventListener('dynacat:widget-updated', function (event) { const widget = event.detail?.widget; const widgetId = event.detail?.widgetId || widget?.dataset?.widgetId; @@ -263,7 +253,6 @@ const key = widgetId + ':' + type + ':' + id; if (confirmingItems.has(key)) { - // Second click — perform the removal const state = confirmingItems.get(key); clearTimeout(state.timeoutId); confirmingItems.delete(key); diff --git a/internal/dynacat/static/js/editor-log.js b/internal/dynacat/static/js/editor-log.js new file mode 100644 index 00000000..d03fbe3c --- /dev/null +++ b/internal/dynacat/static/js/editor-log.js @@ -0,0 +1,33 @@ +// The editor and the widget builder only have room for one short line of feedback, so the full +// picture - what was sent, what came back and which config key the server blamed - goes here. + +const BADGE = "background:#6d28d9;color:#fff;padding:1px 6px;border-radius:3px;font-weight:600"; +const LABEL = "font-weight:600"; + +function isEmpty(value) { + if (value === undefined || value === null || value === "") return true; + return typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0; +} + +// One collapsed group per failure +export function logFailure(action, detail) { + console.groupCollapsed(`%cdynacat editor%c ${action}`, BADGE, ""); + for (const [key, value] of Object.entries(detail)) { + if (!isEmpty(value)) console.log(`%c${key}:`, LABEL, value); + } + console.groupEnd(); +} + +// The parts of a rejected editor response worth printing +export function responseDetail(method, url, res, body) { + return { + request: `${method} ${url}`, + status: `${res.status} ${res.statusText}`, + message: body?.error, + code: body?.code, + field: body?.field, + hint: body?.hint, + context: body?.context, + response: body, + }; +} diff --git a/internal/dynacat/static/js/editor.js b/internal/dynacat/static/js/editor.js new file mode 100644 index 00000000..5f8a593f --- /dev/null +++ b/internal/dynacat/static/js/editor.js @@ -0,0 +1,2245 @@ +import { logFailure, responseDetail } from "./editor-log.js"; + +const PD = typeof pageData !== "undefined" ? pageData : window.pageData; +const API = `${PD.baseURL}/api/editor`; + +const state = { + active: false, + schemas: [], + schemaByType: {}, + config: null, + pageIndex: -1, + drag: null, +}; + +export async function toggleEditor() { + if (state.active) return exitEditor(); + await enterEditor(); +} + +// The editor's styles ship as their own bundle, so they load here instead of on every page view. +function loadEditorStyles() { + const href = new URL("../css/editor-bundle.css", import.meta.url).href; + if (document.querySelector(`link[href="${href}"]`)) return Promise.resolve(); + + return new Promise((resolve) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = href; + link.addEventListener("load", resolve, { once: true }); + link.addEventListener("error", resolve, { once: true }); + document.head.append(link); + }); +} + +async function enterEditor() { + try { + const [schemas, config] = await Promise.all([apiGet("/schema"), apiGet("/config"), loadEditorStyles()]); + state.schemas = schemas; + state.schemaByType = Object.fromEntries(schemas.map((s) => [s.type, s])); + state.config = config; + state.pageIndex = config.pages.findIndex((p) => p.slug === PD.slug); + } catch (err) { + logFailure("editor could not start", { + "loaded from": `${API}/schema and ${API}/config`, + status: err.status, + error: err, + }); + toast(err.status === 401 ? "Log in to edit this page" : "Could not load editor", "negative"); + return; + } + + if (state.pageIndex < 0) { + toast("This page is not editable here", "negative"); + return; + } + + state.active = true; + sessionStorage.setItem("dynacat-editing", "1"); + document.body.classList.add("editing"); + const toggleBtn = document.getElementById("editor-toggle"); + toggleBtn?.classList.add("editor-toggle-active"); + if (toggleBtn) { + toggleBtn.title = "Exit editor"; + toggleBtn.querySelector(".editor-toggle-icon").outerHTML = + ''; + } + + buildPalette(); + buildAddPageButton(); + setupStylingTrigger(); + prepareCanvas(); + renderCanvas(); + guardCanvas(); +} + +function prepareCanvas() { + const page = document.getElementById("page"); + if (page) { + page.classList.add("content-ready"); + page.setAttribute("aria-busy", "false"); + } + + const content = document.getElementById("page-content"); + if (content && !content.querySelector(".page-columns")) content.append(div("page-columns")); +} + +function guardCanvas() { + const content = document.getElementById("page-content"); + if (!content) return; + new MutationObserver(() => { + const columns = document.querySelector(".page-columns"); + if (state.active && columns && !columns.dataset.editorCanvas) renderCanvas(); + }).observe(content, { childList: true, subtree: true }); +} + +function exitEditor() { + sessionStorage.removeItem("dynacat-editing"); + location.reload(); +} + +// +// API +// + +async function apiGet(path) { + const res = await fetch(API + path); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + logFailure(`GET ${path} was rejected`, responseDetail("GET", API + path, res, body)); + throw { status: res.status }; + } + return res.json(); +} + +async function apiPost(path, body) { + const res = await fetch(API + path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + logFailure(`POST ${path} was rejected`, { ...responseDetail("POST", API + path, res, data), sent: body }); + throw new Error(data.error || "Request failed"); + } + return data; +} + +const convertToYAML = async (value) => (await apiPost("/convert", { to: "yaml", value })).text; +const convertToValue = async (text) => (await apiPost("/convert", { to: "value", text })).value; + +async function commit(mutation) { + let res; + try { + res = await fetch(`${API}/config`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(mutation), + }); + } catch (err) { + logFailure("save never reached the server", { request: `POST ${API}/config`, mutation, error: err }); + toast("Save failed, the server did not answer", "negative"); + return false; + } + + if (res.status === 204) return true; + + const body = await res.json().catch(() => ({})); + logFailure(`save "${mutation.op}" was rejected`, { + ...responseDetail("POST", `${API}/config`, res, body), + mutation, + }); + toast(body.error || "Save failed", "negative"); + return false; +} + +async function save(mutation) { + if (!(await commit(mutation))) return false; + state.config = await apiGet("/config"); + renderCanvas(); + return true; +} + +async function commitAndNavigate(mutation, navigate) { + const before = await serverGeneration(); + if (!(await commit(mutation))) return false; + await waitForServerReload(before); + await navigate(); + return true; +} + +async function serverGeneration() { + try { + return (await apiGet("/status")).generation; + } catch { + return 0; + } +} + +function waitForServerReload(before, timeout = 6000) { + const started = Date.now(); + return new Promise((resolve) => { + const tick = async () => { + if ((await serverGeneration()) !== before || Date.now() - started > timeout) return resolve(); + setTimeout(tick, 300); + }; + setTimeout(tick, 300); + }); +} + +// +// Palette dock +// +const NAME_BREAKS = { Dynawidgets: ["Dyna", "widgets"] }; + +function paletteName(label) { + const el = div("editor-palette-name"); + (NAME_BREAKS[label] || [label]).forEach((part, i) => { + if (i) el.append(document.createElement("wbr")); + el.append(part); + }); + return el; +} + +function buildPalette() { + const dock = div("editor-ui editor-palette-dock"); + const list = div("editor-palette"); + list.addEventListener("wheel", (e) => { + if (!e.deltaY) return; + e.preventDefault(); + list.scrollLeft += e.deltaY; + }, { passive: false }); + + const items = []; + const sorted = [...state.schemas].sort((a, b) => a.label.localeCompare(b.label)); + for (const schema of sorted) { + if (schema.hidden) continue; + const item = div("editor-palette-item"); + item.draggable = true; + item.title = schema.label; + if (schema.icon) item.append(maskIcon("editor-palette-icon", schema.icon)); + item.append(paletteName(schema.label)); + item.addEventListener("dragstart", () => (state.drag = { type: schema.type })); + list.append(item); + items.push({ el: item, haystack: `${schema.label} ${schema.type}`.toLowerCase() }); + } + + const empty = div("editor-palette-empty", "No widgets found"); + empty.hidden = true; + list.append(empty); + + const search = div("editor-palette-search"); + const input = inputEl("text"); + input.placeholder = "Search..."; + input.autocomplete = "off"; + input.spellcheck = false; + input.addEventListener("input", () => { + const query = input.value.trim().toLowerCase(); + let matches = 0; + for (const { el, haystack } of items) { + const hit = !query || haystack.includes(query); + el.hidden = !hit; + if (hit) matches++; + } + empty.hidden = matches > 0; + list.scrollLeft = 0; + }); + search.append(input); + + dock.append(search, list); + document.body.append(dock); +} + +// +// Canvas: columns and widget cards rendered from the config +// + +function maxColumnsFor(page) { + return page.width === "slim" ? 2 : 3; +} + +function renderCanvas() { + const container = document.querySelector(".page-columns"); + if (!container) return; + container.innerHTML = ""; + + const page = state.config.pages[state.pageIndex]; + const canAddColumn = page.columns.length < maxColumnsFor(page); + + page.columns.forEach((col, colIndex) => { + if (canAddColumn) container.append(addColumnLine(colIndex)); + const colEl = div(`page-column page-column-${col.size}`); + setupDropTarget(colEl, [colIndex]); + (col.widgets || []).forEach((w, i) => colEl.append(buildWidgetCard(w, [colIndex, i]))); + colEl.append(buildColumnTools(colIndex)); + container.append(colEl); + }); + if (canAddColumn) container.append(addColumnLine(page.columns.length)); + container.dataset.editorCanvas = "1"; +} + +function buildColumnTools(colIndex) { + const tools = div("editor-ui editor-column-tools"); + const btn = button("Remove column", "editor-column-remove"); + btn.prepend(iconSpan(iconTrash)); + btn.addEventListener("click", () => removeColumn(colIndex)); + tools.append(btn); + return tools; +} + +function iconSpan(svg) { + const span = document.createElement("span"); + span.className = "editor-icon-inline"; + span.innerHTML = svg; + return span; +} + +function removeColumn(colIndex) { + confirmAction("Remove this column and its widgets?", () => + save({ op: "removeColumn", page: state.pageIndex, column: colIndex }) + ); +} + +const CONTAINER_TYPES = ["group", "split-column"]; + +function buildWidgetCard(w, path) { + const schema = state.schemaByType[w.type]; + const card = div("widget editor-widget"); + card.draggable = true; + card.addEventListener("dragstart", (e) => { + e.stopPropagation(); + state.drag = { fromPath: path }; + card.classList.add("editor-dragging"); + }); + card.addEventListener("dragend", () => card.classList.remove("editor-dragging")); + + const header = div("editor-widget-header"); + const body = div("editor-widget-card"); + if (schema?.icon) body.append(maskIcon("editor-widget-icon", schema.icon)); + body.append(div("editor-widget-title", w.title || schema?.label || w.type)); + header.append(body); + + const tools = div("editor-ui editor-widget-tools"); + tools.append(toolButton("Edit", iconPencil, () => openWidgetModal(path))); + tools.append(toolButton("Delete", iconTrash, () => removeWidget(path))); + header.append(tools); + card.append(header); + + if (CONTAINER_TYPES.includes(w.type)) { + card.classList.add("editor-container"); + const horizontal = w.type === "split-column"; + const nested = div(`editor-nested${horizontal ? " editor-nested-split" : ""}`); + setupDropTarget(nested, path, horizontal); + (w.widgets || []).forEach((child, j) => nested.append(buildWidgetCard(child, [...path, j]))); + card.append(nested); + } + + return card; +} + +function directWidgets(column) { + return Array.from(column.children).filter((el) => el.classList.contains("widget")); +} + +function setupDropTarget(element, basePath, horizontal = false) { + const marker = div(`editor-ui editor-drop-marker${horizontal ? " editor-drop-marker-v" : ""}`); + const pointer = (e) => (horizontal ? e.clientX : e.clientY); + + element.addEventListener("dragover", (e) => { + e.preventDefault(); + e.stopPropagation(); + positionMarker(element, marker, dropIndex(element, pointer(e), horizontal), horizontal); + }); + element.addEventListener("dragleave", (e) => { + if (!element.contains(e.relatedTarget)) marker.remove(); + }); + element.addEventListener("drop", (e) => { + e.preventDefault(); + e.stopPropagation(); + const index = dropIndex(element, pointer(e), horizontal); + marker.remove(); + handleDrop([...basePath, index]); + }); +} + +function positionMarker(element, marker, index, horizontal) { + document.querySelectorAll(".editor-drop-marker").forEach((m) => m !== marker && m.remove()); + + const widgets = directWidgets(element); + const edge = (el) => (horizontal ? el.offsetLeft : el.offsetTop); + const size = (el) => (horizontal ? el.offsetWidth : el.offsetHeight); + + let pos; + if (widgets.length === 0) { + pos = 8; + } else if (index < widgets.length) { + pos = edge(widgets[index]) - 4; + } else { + const last = widgets[widgets.length - 1]; + pos = edge(last) + size(last) + 3; + } + + if (horizontal) { + marker.style.left = `${pos}px`; + } else { + marker.style.top = `${pos}px`; + } + if (!marker.isConnected) element.appendChild(marker); +} + +function dropIndex(element, pointer, horizontal) { + const widgets = directWidgets(element); + for (let i = 0; i < widgets.length; i++) { + const box = widgets[i].getBoundingClientRect(); + const mid = horizontal ? box.left + box.width / 2 : box.top + box.height / 2; + if (pointer < mid) return i; + } + return widgets.length; +} + +function handleDrop(path) { + const drag = state.drag; + state.drag = null; + if (!drag) return; + + if (drag.type) { + openNewWidgetModal(drag.type, path); + } else { + save({ op: "moveWidget", page: state.pageIndex, path: drag.fromPath, toPath: path }); + } +} + +function removeWidget(path) { + save({ op: "removeWidget", page: state.pageIndex, path }); +} + +function widgetAtPath(path) { + const col = state.config.pages[state.pageIndex].columns[path[0]]; + let widgets = (col && col.widgets) || []; + let w = null; + for (let i = 1; i < path.length; i++) { + w = widgets[path[i]]; + if (!w) return null; + widgets = w.widgets || []; + } + return w; +} + +// +// Inter-column add lines +// + +function addColumnLine(insertIndex) { + const line = div("editor-ui editor-add-column"); + line.title = "Add column here"; + const plus = div("editor-add-column-plus"); + plus.innerHTML = iconPlus; + line.append(plus); + line.addEventListener("click", () => addColumn(insertIndex)); + return line; +} + +function addColumn(insertIndex) { + const page = state.config.pages[state.pageIndex]; + const max = maxColumnsFor(page); + if (page.columns.length >= max) { + toast(`This layout is full (max ${max} columns)`, "negative"); + return; + } + const fullCount = page.columns.filter((c) => c.size === "full").length; + const size = fullCount < 2 ? "full" : "small"; + save({ op: "addColumn", page: state.pageIndex, index: insertIndex, size }); +} + +// +// Add page +// + +const PAGE_LAYOUTS = [ + ["full"], + ["small", "full"], + ["full", "small"], + ["full", "full"], + ["small", "full", "small"], + ["full", "small", "full"], +]; + +function buildAddPageButton() { + const nav = document.querySelector(".header .nav"); + if (!nav) return; + + const group = div("editor-ui editor-page-tools"); + group.append( + pageToolButton("editor-add-page", iconPlus, "Add page", "Create a new page", openLayoutModal), + div("editor-page-tools-divider"), + pageToolButton("editor-edit-page", iconCog, "Edit page", "Edit this page's name, icon and options", openEditPageModal), + div("editor-page-tools-divider"), + pageToolButton("editor-remove-page", iconTrash, "Remove page", "Delete this page", removeCurrentPage), + ); + nav.append(group); +} + +function pageToolButton(className, icon, label, title, onClick) { + const btn = div(`editor-page-btn ${className}`); + btn.title = title; + btn.append(iconSpan(icon), div("editor-page-btn-label", label)); + btn.addEventListener("click", onClick); + return btn; +} + +function setupStylingTrigger() { + const picker = document.querySelector(".header .theme-picker"); + if (!picker) return; + picker.classList.add("editor-styling-trigger"); + picker.addEventListener("click", (e) => { + if (!state.active) return; + e.preventDefault(); + e.stopPropagation(); + openStylingModal(); + }, true); +} + +function removeCurrentPage() { + const page = state.config.pages[state.pageIndex]; + confirmAction(`Remove the page "${page.title}"? This cannot be undone.`, () => { + commitAndNavigate({ op: "removePage", page: state.pageIndex }, () => (location.href = `${PD.baseURL}/`)); + }); +} + +function openEditPageModal() { + const page = state.config.pages[state.pageIndex]; + if (!page) return; + if (page.writable === false) { + toast("This page's file is read only, it cannot be edited", "negative"); + return; + } + + const opt = page.options || {}; + const widths = ["", "default", "wide", "slim"]; + const controls = []; + const reg = (key, ctl) => { + controls.push({ key, read: ctl.read }); + return ctl.wrapper; + }; + + const basic = div("editor-fields"); + basic.append(reg("name", textField("Name", opt["name"] || page.title, "Page name"))); + basic.append(reg("name-icon", iconField("Icon", opt["name-icon"], "e.g. mdi:home or /assets/icon.svg"))); + basic.append(reg("slug", textField("Slug (URL)", opt["slug"], "auto from name"))); + + const layout = div("editor-fields"); + layout.append(reg("width", selectField("Width", opt["width"], widths))); + layout.append(reg("key-bind", textField("Key bind", opt["key-bind"], "e.g. d h"))); + layout.append(reg("center-vertically", checkField("Center vertically", opt["center-vertically"]))); + + const nav = div("editor-fields"); + nav.append(reg("hide-from-navigation", checkField("Hide from navigation", opt["hide-from-navigation"]))); + nav.append(reg("hide-desktop-navigation", checkField("Hide desktop navigation", opt["hide-desktop-navigation"]))); + nav.append(reg("show-mobile-header", checkField("Show mobile header", opt["show-mobile-header"]))); + nav.append(reg("desktop-navigation-width", selectField("Desktop navigation width", opt["desktop-navigation-width"], widths))); + + const sections = [ + sectionTitle("Basics"), + basic, + sectionTitle("Layout"), + layout, + collapsible("Navigation", nav), + ]; + + openModal("Page options", sections, () => { + const fields = {}; + for (const c of controls) fields[c.key] = c.read(); + return savePageOptions(fields); + }); +} + +async function savePageOptions(fields) { + const before = await serverGeneration(); + if (!(await commit({ op: "editPage", page: state.pageIndex, fields }))) return false; + await waitForServerReload(before); + const cfg = await apiGet("/config").catch(() => null); + const updated = cfg && cfg.pages[state.pageIndex]; + location.href = updated ? `${PD.baseURL}/${updated.slug}` : `${PD.baseURL}/`; + return true; +} + +function openLayoutModal() { + const body = div("editor-layout-grid"); + let chosen = PAGE_LAYOUTS[0]; + + for (const layout of PAGE_LAYOUTS) { + const option = div("editor-layout-option"); + for (const size of layout) option.append(div(`editor-layout-cell editor-layout-cell-${size}`)); + option.addEventListener("click", () => { + body.querySelectorAll(".editor-layout-option").forEach((o) => o.classList.remove("selected")); + option.classList.add("selected"); + chosen = layout; + }); + body.append(option); + } + body.firstChild.classList.add("selected"); + + const title = document.createElement("input"); + title.className = "editor-input"; + title.placeholder = "Page name"; + + openModal("New page", [labeled("Name", title), body], () => { + const name = title.value.trim() || "New Page"; + return commitAndNavigate({ op: "addPage", title: name, layout: chosen }, async () => { + const cfg = await apiGet("/config").catch(() => null); + const created = cfg?.pages.find((p) => p.title === name); + location.href = created ? `${PD.baseURL}/${created.slug}` : `${PD.baseURL}/`; + }); + }); +} + +// +// Widget option modal +// + +function openNewWidgetModal(type, path) { + const initial = path.length >= 3 ? { frameless: "true" } : {}; + buildWidgetModal(type, initial, {}, (fields, rawFields) => + save({ op: "addWidget", page: state.pageIndex, path, widgetType: type, fields, rawFields }) + ); +} + +function openWidgetModal(path) { + const widget = widgetAtPath(path); + if (!widget) return; + buildWidgetModal(widget.type, widget.values || {}, widget.structured || {}, (fields, rawFields) => + save({ op: "editWidget", page: state.pageIndex, path, fields, rawFields }) + ); +} + +function buildWidgetModal(type, values, structured, onSave) { + const schema = state.schemaByType[type]; + if (!schema) return toast(`Unknown widget: ${type}`, "negative"); + + const basic = div("editor-fields"); + const advanced = div("editor-fields"); + const controls = []; + const cleared = new Set(); + const hiddenValues = {}; + + for (const field of schema.fields) { + if (field.name === "widgets") continue; + if (field.name === "autocomplete") continue; + // Hidden fields have no control, so carry their value through untouched. + if (field.hidden) { + if (values[field.name] !== undefined) hiddenValues[field.name] = values[field.name]; + continue; + } + + let control; + if (field.name === "autocomplete-provider") { + control = renderAutocompleteProviderField(field, values["autocomplete-provider"], values["autocomplete"]); + } else if (type === "dynawidgets" && field.name === "repo") { + control = renderDynawidgetsRepoField(field, values[field.name]); + } else { + control = renderField(field, values[field.name], structured[field.name]); + } + + controls.push(control); + (field.advanced ? advanced : basic).append(control.wrapper); + } + + for (const c of controls) { + const forget = () => cleared.delete(c.field.name); + c.wrapper.addEventListener("input", forget); + c.wrapper.addEventListener("change", forget); + } + + if (type === "search") wireSearchEngineFields(controls); + + const sections = type === "custom-api" + ? [pasteSection(schema, (pasted) => applyPastedWidget(controls, pasted, cleared)), basic] + : [basic]; + if (advanced.children.length) sections.push(collapsible("Advanced", advanced)); + + openModal(schema.label, sections, async () => { + const fields = { ...hiddenValues }; + const rawFields = {}; + for (const c of controls) { + const value = c.read(); + if (c.extra) Object.assign(fields, c.extra()); + const raw = c.field.kind === "yaml" || (c.isYAML && c.isYAML()); + if (cleared.has(c.field.name)) { + (raw ? rawFields : fields)[c.field.name] = ""; + continue; + } + if (value === undefined) continue; + // An unchecked box is only written when the key is already in the config, + // so unchecking persists as `false` without adding noise everywhere else. + if (value === false && !(c.field.name in values)) continue; + if (raw) rawFields[c.field.name] = value; + else fields[c.field.name] = value; + } + if (type === "dynawidgets" && !(await confirmDynawidgetVariables(fields))) return false; + return onSave(fields, rawFields); + }, type === "custom-api" ? customAPIEditorButton(controls, hiddenValues, cleared) : null, schema.docs); +} + +// Resolves once the modal has actually saved, so the editor can stay on top until then. +function submitWidgetModal(btn) { + const overlay = btn.closest(".editor-modal-overlay"); + overlay?.querySelector(".editor-modal-actions .editor-btn-primary")?.click(); + + return new Promise((resolve) => { + const done = () => { + clearInterval(timer); + resolve(); + }; + const timer = setInterval(() => { + if (!overlay?.isConnected || document.querySelector(".editor-toast.color-negative")) done(); + }, 100); + setTimeout(done, 6000); + }); +} + +function customAPIEditorButton(controls, hiddenValues, cleared) { + const btn = button("Open Editor", "editor-btn editor-open-builder"); + const controlByName = Object.fromEntries(controls.map((c) => [c.field.name, c])); + + btn.addEventListener("click", async () => { + btn.disabled = true; + try { + const editor = await import("./customapi-editor.js"); + editor.openCustomAPIEditor({ + read: (name) => controlByName[name]?.read(), + write: (name, value) => controlByName[name]?.write(value), + clear: (name) => cleared.add(name), + submit: () => submitWidgetModal(btn), + hiddenValues, + toValue: convertToValue, + toast, + confirmAction, + resolveIcon, + }); + } catch (err) { + toast(err.message || "Could not open the editor", "negative"); + } finally { + btn.disabled = false; + } + }); + + return btn; +} + +// +// Paste raw YAML into the form +// + +function pasteSection(schema, apply) { + const row = div("editor-paste-row"); + row.append(div("editor-paste-hint", "Already have the YAML? Paste it and every option below fills in.")); + const btn = button("Paste YAML", "editor-btn editor-paste-btn"); + btn.prepend(iconSpan(iconPaste)); + btn.addEventListener("click", () => openPasteModal(schema, apply)); + row.append(btn); + return row; +} + +function openPasteModal(schema, apply) { + const textarea = document.createElement("textarea"); + textarea.className = "editor-input editor-textarea editor-paste-area"; + textarea.spellcheck = false; + textarea.placeholder = `- type: ${schema.type}\n title: ${schema.label}`; + + const body = div("editor-fields"); + body.append(fieldHint("Paste the widget block from a config file or the docs. Every key becomes an input and anything the paste leaves out is removed."), textarea); + + openModal(`Paste ${schema.label} YAML`, [body], async () => { + const pasted = await parsePastedWidget(textarea.value, schema); + if (!pasted) return false; + await apply(pasted); + // Pasting only stores the placeholder, so the values behind it still have to reach the container. + showVariableNotice(findConfigVariables(textarea.value)); + return true; + }); + textarea.focus(); +} + +async function parsePastedWidget(text, schema) { + const yaml = dedent(text); + if (!yaml) { + toast("Paste some YAML first", "negative"); + return null; + } + + let value; + try { + value = await convertToValue(yaml); + } catch (e) { + toast(e.message || "Could not parse the pasted YAML", "negative"); + return null; + } + + if (Array.isArray(value)) { + if (value.length > 1) toast("Only the first widget of the pasted YAML was used", "negative"); + value = value[0]; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + toast(`Paste a widget block, starting with "- type: ${schema.type}"`, "negative"); + return null; + } + if (value.type && value.type !== schema.type) { + const label = state.schemaByType[value.type]?.label || value.type; + toast(`That YAML is for a ${label} widget, not ${schema.label}`, "negative"); + return null; + } + + return value; +} + +async function applyPastedWidget(controls, pasted, cleared) { + const known = new Set(["type", "widgets", "autocomplete"]); + const missing = []; + + for (const c of controls) { + known.add(c.field.name); + const value = pasted[c.field.name]; + if (value === undefined || value === null) missing.push(c.field.name); + await c.write?.(value === null ? undefined : value, pasted); + } + + cleared.clear(); + for (const name of missing) cleared.add(name); + + const ignored = Object.keys(pasted).filter((k) => !known.has(k)); + if (ignored.length) toast(`No field for: ${ignored.join(", ")}`, "negative"); + else toast("Fields filled in from the pasted YAML", "positive"); +} + +// Mirrors configVariablePattern in config.go so the notice lists exactly what the server expands. +const configVariablePattern = /(^|.)\$\{(?:([a-zA-Z]+):)?([a-zA-Z0-9_-]+)\}/g; +const configVariableDocs = "https://dynacat.artur.zone/#configuration/environment-variables"; + +function findConfigVariables(text) { + const found = new Map(); + + for (const [, prefix, kind, name] of text.matchAll(configVariablePattern)) { + if (prefix === "\\") continue; + const token = kind ? `\${${kind}:${name}}` : `\${${name}}`; + if (!found.has(token)) found.set(token, { token, kind: kind || "env", name }); + } + + return [...found.values()]; +} + +// Blocks the save instead of letting it fail, since the config cannot load while a value is missing. +async function confirmDynawidgetVariables({ widget, repo }) { + if (!widget) return true; + + const params = new URLSearchParams({ widget }); + if (repo) params.set("repo", repo); + + let variables; + try { + ({ variables } = await apiGet(`/dynawidgets/variables?${params}`)); + } catch (err) { + // The template may be unreachable, and the save itself still reports what went wrong. + logFailure("dynawidget variables could not be checked, saving anyway", { + widget, + repo: repo || "default repo", + status: err.status, + error: err, + }); + return true; + } + + const missing = (variables || []) + .filter((v) => !v.set) + .map((v) => ({ token: `\${${v.name}}`, kind: v.type, name: v.name })); + if (!missing.length) return true; + + showVariableNotice(missing); + return false; +} + +function showVariableNotice(vars) { + if (!vars.length) return; + + const body = div("editor-notice"); + const list = div("editor-env-vars"); + for (const v of vars) list.append(div("editor-env-var", v.token)); + body.append(list); + + const envNames = vars.filter((v) => v.kind === "env").map((v) => v.name); + body.append(codeCard((envNames.length ? envNames : ["API_TOKEN"]).map((n) => `${n}=your-value-here`).join("\n"))); + + body.append(noticeSteps([ + envNames.length + ? "Add these lines to the `.env` file next to your compose file, with the real values." + : "Add every variable the widget needs to the `.env` file next to your compose file.", + "Recreate the container with `docker compose up -d` - the automatic config reload never re-reads the environment.", + ])); + + if (vars.some((v) => v.kind === "secret")) + body.append(noticeNote("`${secret:name}` is read from `/run/secrets/name`, so mount it as a Docker secret instead of putting it in `.env`.")); + if (vars.some((v) => v.kind === "readFileFromEnv")) + body.append(noticeNote("`${readFileFromEnv:NAME}` expects `NAME` to hold an absolute path, and the file behind it becomes the value.")); + + noticeModal("Values found in this widget", [body], configVariableDocs); +} + +function noticeSteps(steps) { + const list = document.createElement("ol"); + list.className = "editor-notice-steps"; + for (const step of steps) { + const item = document.createElement("li"); + item.append(...richText(step)); + list.append(item); + } + return list; +} + +function noticeNote(text) { + const note = div("editor-notice-note"); + note.append(...richText(text)); + return note; +} + +// Backtick-wrapped spans render as inline code so the notice can name variables and commands. +function richText(text) { + return text.split("`").map((part, i) => { + if (i % 2 === 0) return document.createTextNode(part); + const code = document.createElement("code"); + code.className = "editor-code-inline"; + code.textContent = part; + return code; + }); +} + +function codeCard(text) { + const card = div("editor-code-card"); + const pre = document.createElement("pre"); + pre.className = "editor-code-block"; + pre.textContent = text; + + const copy = button("", "editor-code-copy"); + copy.title = "Copy"; + copy.innerHTML = iconCopy; + copy.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(text); + toast("Copied to the clipboard", "positive"); + } catch { + toast("Could not copy, select the text instead", "negative"); + } + }); + card.append(pre, copy); + return card; +} + +function dedent(text) { + const lines = text.split("\n"); + const indents = lines.filter((l) => l.trim()).map((l) => l.match(/^ */)[0].length); + if (!indents.length) return ""; + const strip = Math.min(...indents); + return lines.map((l) => l.slice(strip)).join("\n").trimEnd(); +} + +async function yamlText(value) { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value; + if (typeof value !== "object") return String(value); + return convertToYAML(value); +} + +function scalarText(value) { + if (value === undefined || value === null) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function renderField(field, value, structured) { + if (field.kind === "list") return renderListField(field, structured, typeof value === "string" ? value : ""); + + if (field.kind === "checkbox") { + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = value === "true" || value === true; + const row = document.createElement("label"); + row.className = "editor-field editor-check"; + const text = document.createElement("span"); + text.className = "editor-check-label"; + text.append(field.label); + if (field.required) text.append(requiredStar()); + row.append(input, text); + return { + field, + wrapper: row, + read: () => input.checked, + write: (v) => (input.checked = v === true || v === "true"), + }; + } + + let input; + let read; + let write; + + if (field.kind === "select" && field.name === "search-engine") { + return renderSearchEngineField(field, value); + } else if (field.kind === "select") { + input = document.createElement("select"); + input.className = "editor-input"; + input.append(new Option("-", "")); + for (const opt of field.options || []) input.append(new Option(opt, opt)); + input.value = value || ""; + read = () => input.value || undefined; + write = (v) => { + const text = scalarText(v); + if (text && ![...input.options].some((o) => o.value === text)) input.append(new Option(text, text)); + input.value = text; + }; + } else if (field.kind === "yaml") { + input = document.createElement("textarea"); + input.className = "editor-input editor-textarea"; + input.value = yamlSeed(value); + read = () => input.value.trim() || undefined; + write = async (v) => (input.value = await yamlText(v)); + } else if (field.kind === "text-block") { + input = document.createElement("textarea"); + input.className = "editor-input editor-textarea editor-textarea-tall"; + input.value = typeof value === "string" ? value : yamlSeed(value); + read = () => input.value.trim() || undefined; + write = (v) => (input.value = typeof v === "string" ? v : scalarText(v)); + } else if (field.kind === "number") { + input = inputEl("number"); + if (value) input.value = value; + read = () => (input.value === "" ? undefined : Number(input.value)); + write = (v) => (input.value = scalarText(v)); + } else { + input = inputEl("text"); + if (value) input.value = value; + if (field.kind === "duration") input.placeholder = "e.g. 30s, 5m, 1h"; + read = () => input.value.trim() || undefined; + write = (v) => { + input.value = scalarText(v); + input.dispatchEvent(new Event("input")); + }; + } + + const wrapper = labeled(field.label, input, field.required, field.hint); + if (field.kind === "icon") wrapper.append(iconPreview(input)); + + return { field, wrapper, read, write }; +} + +// +// List field (repeated entries) +// + +const SUMMARY_KEYS = ["title", "name", "url", "repository", "symbol", "timezone", "shortcut", "label"]; + +function renderListField(field, structured, rawValue) { + const entries = Array.isArray(structured) ? structured : []; + let openIndex = -1; + let dirty = false; + let converting = false; + // Entries we can't map onto the schema (a map such as $include, or shorthand + // strings) can only be edited as YAML. + const listable = (v) => Array.isArray(v) && + (!!field.itemKind || v.every((i) => i !== null && typeof i === "object" && !Array.isArray(i))); + let yamlMode = !listable(structured) && (!!rawValue.trim() || Array.isArray(structured)); + + // Entries are edited on a copy; the pristine one tells us which keys already + // existed, so an explicit `false` survives while an untouched one stays out. + const originals = new WeakMap(); + const adopt = (list) => list.map((i) => { + if (i === null || typeof i !== "object") return i; + const copy = { ...i }; + originals.set(copy, i); + return copy; + }); + let items = adopt(entries); + + const wrapper = div("editor-field"); + const labelRow = div("editor-list-label"); + const label = document.createElement("label"); + label.className = "editor-field-label"; + label.append(field.label); + if (field.required) label.append(requiredStar()); + const toggle = button("", "editor-list-toggle"); + labelRow.append(label, toggle); + wrapper.append(labelRow); + if (field.hint) wrapper.append(fieldHint(field.hint)); + + const listEl = div("editor-list"); + const addBtn = button(`Add ${singularize(field.label)}`, "editor-list-add"); + addBtn.prepend(iconSpan(iconPlus)); + + const textarea = document.createElement("textarea"); + textarea.className = "editor-input editor-textarea"; + textarea.value = rawValue; + textarea.addEventListener("input", () => (dirty = true)); + + const syncMode = () => { + listEl.hidden = addBtn.hidden = yamlMode; + textarea.hidden = !yamlMode; + toggle.textContent = converting ? "Converting..." : yamlMode ? "Edit as list" : "Edit as YAML"; + toggle.disabled = converting; + }; + + toggle.addEventListener("click", async () => { + if (converting) return; + converting = true; + syncMode(); + try { + if (!yamlMode) { + textarea.value = await convertToYAML(items); + yamlMode = true; + } else if (!textarea.value.trim()) { + items = []; + openIndex = -1; + yamlMode = false; + } else { + const value = await convertToValue(textarea.value); + if (!listable(value)) { + toast(`${field.label} can only be edited as YAML`, "negative"); + } else { + items = adopt(value); + openIndex = -1; + yamlMode = false; + } + } + } catch (e) { + toast(e.message, "negative"); + } finally { + converting = false; + render(); + } + }); + + const render = () => { + listEl.textContent = ""; + items.forEach((item, i) => listEl.append(field.itemKind ? scalarRow(item, i) : itemCard(item, i))); + syncMode(); + }; + + const touch = () => { + dirty = true; + syncMode(); + }; + + const moveItem = (from, to) => { + if (from === to || to < 0 || to >= items.length) return; + items.splice(to, 0, items.splice(from, 1)[0]); + openIndex = -1; + touch(); + render(); + }; + + function removeButton(i) { + const b = toolButton("Remove", iconTrash, () => { + items.splice(i, 1); + openIndex = -1; + touch(); + render(); + }); + b.classList.add("editor-list-remove"); + return b; + } + + function scalarRow(item, i) { + const row = div("editor-list-row"); + const control = renderField({ ...field, kind: field.itemKind, label: "" }, item); + control.wrapper.classList.add("editor-list-row-field"); + row.addEventListener("input", () => { + items[i] = control.read(); + touch(); + }); + row.append(control.wrapper, removeButton(i)); + return row; + } + + function itemCard(item, i) { + const card = div("editor-list-card"); + const head = div("editor-list-head"); + const grip = div("editor-list-grip"); + grip.innerHTML = iconGrip; + const thumb = img("editor-list-thumb", ""); + const summary = div("editor-list-summary"); + const chevron = div("editor-list-chevron"); + chevron.innerHTML = iconChevron; + head.append(grip, thumb, summary, chevron, removeButton(i)); + + const refreshHead = () => { + summary.textContent = itemSummary(item, i); + applyIcon(thumb, item.icon || ""); + thumb.style.display = item.icon ? "" : "none"; + }; + refreshHead(); + + head.addEventListener("click", () => { + openIndex = openIndex === i ? -1 : i; + render(); + }); + card.append(head); + setupItemDrag(card, grip, i); + + if (openIndex !== i) return card; + + card.classList.add("editor-list-card-open"); + const body = div("editor-list-body"); + const advanced = div("editor-fields"); + const controls = []; + + for (const f of field.item) { + const control = renderField(f, item[f.name], item[f.name]); + controls.push(control); + (f.advanced ? advanced : body).append(control.wrapper); + } + if (advanced.children.length) body.append(collapsible("Advanced", advanced)); + + const original = originals.get(item) || {}; + + const sync = () => { + for (const c of controls) { + const value = c.read(); + if (c.field.kind === "list" && value === undefined) continue; + const drop = value === undefined || value === "" || + (value === false && !(c.field.name in original)); + if (drop) delete item[c.field.name]; + else item[c.field.name] = c.field.kind === "yaml" ? { $yaml: value } : value; + } + refreshHead(); + touch(); + }; + body.addEventListener("input", sync); + body.addEventListener("change", sync); + + autofillTitleFromURL(controls); + card.append(body); + return card; + } + + function setupItemDrag(card, grip, i) { + grip.addEventListener("click", (e) => e.stopPropagation()); + grip.addEventListener("mousedown", () => (card.draggable = true)); + card.addEventListener("dragstart", (e) => { + e.stopPropagation(); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", ""); + card.classList.add("editor-list-dragging"); + state.listDrag = { list: listEl, index: i }; + }); + card.addEventListener("dragend", () => { + card.draggable = false; + card.classList.remove("editor-list-dragging"); + marker.remove(); + state.listDrag = null; + }); + } + + // The whole list is the drop target so the pointer never leaves it between cards. + const marker = div("editor-list-marker"); + const isOwnDrag = () => state.listDrag && state.listDrag.list === listEl; + + const listCards = () => [...listEl.children].filter((c) => c.classList.contains("editor-list-card")); + + const dropIndexAt = (y) => { + const cards = listCards(); + for (let i = 0; i < cards.length; i++) { + const box = cards[i].getBoundingClientRect(); + if (y < box.top + box.height / 2) return i; + } + return cards.length; + }; + + listEl.addEventListener("dragover", (e) => { + if (!isOwnDrag()) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "move"; + const before = listCards()[dropIndexAt(e.clientY)]; + before ? listEl.insertBefore(marker, before) : listEl.append(marker); + }); + + listEl.addEventListener("drop", (e) => { + if (!isOwnDrag()) return; + e.preventDefault(); + e.stopPropagation(); + const from = state.listDrag.index; + const at = dropIndexAt(e.clientY); + marker.remove(); + state.listDrag = null; + moveItem(from, at > from ? at - 1 : at); + }); + + addBtn.addEventListener("click", () => { + items.push(field.itemKind ? "" : {}); + openIndex = items.length - 1; + touch(); + render(); + }); + + wrapper.append(listEl, addBtn, textarea); + render(); + + return { + field, + wrapper, + isYAML: () => yamlMode, + // Untouched fields are left out entirely so their YAML keeps its comments and formatting. + read: () => { + if (!dirty) return undefined; + if (!yamlMode) return items; + return textarea.value.trim() === rawValue.trim() ? undefined : textarea.value.trim() || undefined; + }, + write: async (v) => { + dirty = true; + openIndex = -1; + if (v === undefined || v === null) { + items = []; + textarea.value = ""; + yamlMode = false; + } else if (listable(v)) { + items = adopt(v); + yamlMode = false; + } else { + textarea.value = await yamlText(v); + yamlMode = true; + } + render(); + }, + }; +} + +function autofillTitleFromURL(controls) { + const titleInput = controls.find((c) => c.field.name === "title" || c.field.name === "name")?.wrapper.querySelector("input"); + const urlInput = controls.find((c) => c.field.name === "url")?.wrapper.querySelector("input"); + if (!titleInput || !urlInput) return; + + titleInput.addEventListener("input", () => (titleInput.dataset.touched = "1")); + urlInput.addEventListener("input", () => { + if (titleInput.dataset.touched || titleInput.value.trim()) return; + titleInput.value = titleFromURL(urlInput.value); + }); +} + +function titleFromURL(url) { + const host = (url.match(/^(?:[a-z][a-z0-9+.-]*:\/\/)?([^/?#]+)/i) || [])[1] || ""; + const labels = host.split(":")[0].split(".").filter((l) => l && l !== "www"); + const name = labels[0] || ""; + return name ? name[0].toUpperCase() + name.slice(1) : ""; +} + +function itemSummary(item, i) { + const key = SUMMARY_KEYS.find((k) => typeof item[k] === "string" && item[k].trim()); + return key ? item[key] : `Entry ${i + 1}`; +} + +function singularize(label) { + return label.replace(/ies$/, "y").replace(/s$/, "").toLowerCase(); +} + +function yamlSeed(value) { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value; + if (value.$yaml !== undefined) return value.$yaml; + return JSON.stringify(value); +} + +function renderSearchEngineField(field, value) { + const known = (field.options || []).filter((o) => o !== "custom"); + const isCustom = value && !known.includes(value); + + const select = document.createElement("select"); + select.className = "editor-input"; + select.append(new Option("-", "")); + for (const opt of field.options || []) select.append(new Option(opt, opt)); + select.value = isCustom ? "custom" : value || ""; + + const customInput = inputEl("text"); + customInput.placeholder = "https://example.com/search?q={QUERY}"; + customInput.style.display = select.value === "custom" ? "" : "none"; + if (isCustom) customInput.value = value; + select.addEventListener("change", () => { + customInput.style.display = select.value === "custom" ? "" : "none"; + }); + + const wrapper = labeled(field.label, select, field.required, field.hint); + wrapper.append(customInput); + return { + field, + wrapper, + read: () => (select.value === "custom" ? customInput.value.trim() || undefined : select.value || undefined), + write: (v) => { + const text = scalarText(v); + const custom = text && !known.includes(text); + select.value = custom ? "custom" : text; + customInput.value = custom ? text : ""; + customInput.style.display = select.value === "custom" ? "" : "none"; + }, + }; +} + +// Degoog derives both its search and its suggestion URL from the instance URL, +// so picking it swaps the provider field for the instance URL one. The hidden +// field is written as "" on save, which drops the key from the config. +function wireSearchEngineFields(controls) { + const byName = Object.fromEntries(controls.map((c) => [c.field.name, c])); + const engine = byName["search-engine"]; + const degoogURL = byName["degoog-url"]; + const select = engine?.wrapper.querySelector("select"); + if (!select || !degoogURL) return; + + const provider = byName["autocomplete-provider"]; + const isDegoog = () => select.value === "degoog"; + + const readURL = degoogURL.read; + degoogURL.read = () => (isDegoog() ? readURL() : ""); + + if (provider) { + const readProvider = provider.read; + const extraProvider = provider.extra; + provider.read = () => (isDegoog() ? "" : readProvider()); + provider.extra = () => (isDegoog() ? {} : extraProvider()); + } + + const sync = () => { + degoogURL.wrapper.style.display = isDegoog() ? "" : "none"; + if (provider) provider.wrapper.style.display = isDegoog() ? "none" : ""; + }; + select.addEventListener("change", sync); + sync(); +} + +function renderAutocompleteProviderField(field, value, autocompleteValue) { + const known = (field.options || []).filter((o) => o !== "custom"); + const isOff = autocompleteValue === "false" || autocompleteValue === false; + const isCustom = value && !known.includes(value); + + const select = document.createElement("select"); + select.className = "editor-input"; + select.append(new Option("Off", "")); + for (const opt of known) select.append(new Option(opt, opt)); + select.append(new Option("custom", "custom")); + select.value = isOff ? "" : (isCustom ? "custom" : value || known[0] || ""); + + const customInput = inputEl("text"); + customInput.placeholder = "https://example.com/suggest?q={QUERY}"; + customInput.style.display = select.value === "custom" ? "" : "none"; + if (isCustom) customInput.value = value; + select.addEventListener("change", () => { + customInput.style.display = select.value === "custom" ? "" : "none"; + }); + + const wrapper = labeled(field.label, select, field.required, field.hint); + wrapper.append(customInput); + + return { + field, + wrapper, + read: () => (select.value === "" ? undefined : (select.value === "custom" ? customInput.value.trim() || undefined : select.value)), + extra: () => ({ autocomplete: select.value !== "" }), + write: (v, pasted) => { + const off = pasted && (pasted.autocomplete === false || pasted.autocomplete === "false"); + const text = scalarText(v); + const custom = text && !known.includes(text); + select.value = off ? "" : custom ? "custom" : text || known[0] || ""; + customInput.value = custom ? text : ""; + customInput.style.display = select.value === "custom" ? "" : "none"; + }, + }; +} + +// The repo is a branch of the dynawidgets repository, so only existing branches are offered. +function renderDynawidgetsRepoField(field, value) { + const options = field.options || []; + const defaultRepo = options.includes("main") ? "main" : options[0] || ""; + const initial = scalarText(value); + + const select = document.createElement("select"); + select.className = "editor-input"; + for (const opt of options) select.append(new Option(opt, opt)); + if (initial && !options.includes(initial)) select.append(new Option(initial, initial)); + select.value = initial || defaultRepo; + + return { + field, + wrapper: labeled(field.label, select, field.required, field.hint), + // Leaving the default selected keeps the key out of the config. + read: () => (!initial && select.value === defaultRepo ? undefined : select.value), + write: (v) => { + const text = scalarText(v); + if (text && ![...select.options].some((o) => o.value === text)) select.append(new Option(text, text)); + select.value = text || defaultRepo; + }, + }; +} + +function iconPreview(input) { + const preview = img("editor-icon-preview", ""); + const update = () => { + preview.style.display = applyIcon(preview, input.value.trim()) ? "" : "none"; + }; + input.addEventListener("input", update); + update(); + return preview; +} + +// Sets src plus the same flat-icon class the widget templates use for auto-invert icons. +function applyIcon(el, value) { + const { url, autoInvert } = resolveIcon(value); + el.classList.toggle("flat-icon", autoInvert); + el.onerror = /cdn\.jsdelivr\.net\/gh\/(selfhst\/icons|homarr-labs\/dashboard-icons)\/svg\//.test(url) + ? () => { el.onerror = null; el.src = url.replace("/svg/", "/webp/").replace(/\.svg$/, ".webp"); } + : null; + el.src = url; + return url; +} + +function resolveIconURL(value) { + return resolveIcon(value).url; +} + +// Mirrors newCustomIconField in config-fields.go, keep both in sync. +function resolveIcon(value) { + let autoInvert = false; + if (!value) return { url: "", autoInvert }; + if (value.startsWith("auto-invert ")) { + autoInvert = true; + value = value.slice("auto-invert ".length); + } + + const colon = value.indexOf(":"); + if (colon < 0) return { url: /^\/?assets\//.test(value) ? `${PD.baseURL}/${value.replace(/^\//, "")}` : value, autoInvert }; + const prefix = value.slice(0, colon); + const icon = value.slice(colon + 1); + + const dot = icon.indexOf("."); + const base = dot < 0 ? icon : icon.slice(0, dot); + let ext = dot < 0 ? "svg" : icon.slice(dot + 1); + if (ext !== "svg" && ext !== "png") ext = "svg"; + + switch (prefix) { + case "si": return { url: `https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${base}.svg`, autoInvert: true }; + case "di": return { url: `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${ext}/${base}.${ext}`, autoInvert }; + case "mdi": return { url: `https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/${base}.svg`, autoInvert: true }; + case "sh": return { url: `https://cdn.jsdelivr.net/gh/selfhst/icons/${ext}/${base}.${ext}`, autoInvert }; + default: return { url: value, autoInvert }; + } +} + +// +// Styling modal +// + +function openStylingModal() { + if (state.config && state.config.mainWritable === false) { + toast("The main config file is read only, styling cannot be saved", "negative"); + return; + } + const branding = (state.config && state.config.branding) || {}; + const presets = (state.config && state.config.themePresets) || []; + let selectedKey = ""; + let page = "theme"; + let controls = []; + + const overlay = div("editor-ui editor-modal-overlay"); + const modal = div("editor-modal"); + + const themeEntries = [{ key: "", values: (state.config && state.config.theme) || {} }, ...presets.map((p) => ({ key: p.key, values: p.values || {} }))]; + const picker = div("editor-theme-picker"); + const choices = div("theme-choices"); + const swatchButtons = []; + for (const entry of themeEntries) { + const btn = themeSwatch(entry.values); + btn.title = entry.key === "" ? "Default" : entry.key; + btn.addEventListener("click", () => { + selectedKey = entry.key; + page = "theme"; + highlightCurrent(); + renderBody(selectedKey); + syncActions(); + }); + swatchButtons.push({ key: entry.key, btn }); + choices.append(btn); + } + + const badgeBtn = button("", "editor-theme-badge"); + badgeBtn.innerHTML = iconMedal; + badgeBtn.title = "Branding"; + badgeBtn.addEventListener("click", () => { + page = "branding"; + highlightCurrent(); + renderBrandingBody(); + syncActions(); + }); + + const highlightCurrent = () => { + for (const s of swatchButtons) s.btn.classList.toggle("current", page === "theme" && s.key === selectedKey); + badgeBtn.classList.toggle("current", page === "branding"); + }; + picker.append(choices, badgeBtn); + modal.append(picker); + + const bodyEl = div("editor-modal-body"); + modal.append(bodyEl); + + const themeValuesFor = (key) => + key === "" ? (state.config && state.config.theme) || {} : (presets.find((p) => p.key === key) || {}).values || {}; + + function renderBody(key) { + bodyEl.innerHTML = ""; + controls = []; + const isDefault = key === ""; + const theme = themeValuesFor(key); + + const reg = (section, k, ctl) => { + controls.push({ section, key: k, read: ctl.read }); + return ctl.wrapper; + }; + + const eff = isDefault + ? { + "background-color": resolveCssColor("--color-background"), + "primary-color": resolveCssColor("--color-primary"), + "positive-color": resolveCssColor("--color-positive"), + "negative-color": resolveCssColor("--color-negative"), + } + : {}; + + const colors = div("editor-fields"); + colors.append(reg("theme", "background-color", colorField("Background", theme["background-color"], eff["background-color"], "hsl"))); + colors.append(reg("theme", "primary-color", colorField("Primary", theme["primary-color"], eff["primary-color"], "hsl"))); + colors.append(reg("theme", "positive-color", colorField("Positive", theme["positive-color"], eff["positive-color"], "hsl"))); + colors.append(reg("theme", "negative-color", colorField("Negative", theme["negative-color"], eff["negative-color"], "hsl"))); + + const options = div("editor-fields"); + options.append(reg("theme", "light", checkField("Light scheme", theme["light"]))); + options.append(reg("theme", "contrast-multiplier", numField("Contrast multiplier", theme["contrast-multiplier"], "e.g. 1.1"))); + options.append(reg("theme", "text-saturation-multiplier", numField("Text saturation multiplier", theme["text-saturation-multiplier"], "e.g. 1"))); + + bodyEl.append(sectionTitle("Colors"), colors, sectionTitle("Theme options"), options); + } + + function renderBrandingBody() { + bodyEl.innerHTML = ""; + controls = []; + + const reg = (section, k, ctl) => { + controls.push({ section, key: k, read: ctl.read }); + return ctl.wrapper; + }; + + const logo = div("editor-fields"); + const logoURL = textField("Logo URL (overwrites logo)", branding["logo-url"], "/assets/logo.png"); + logo.append(reg("branding", "logo-url", logoURL)); + + const favSync = document.createElement("input"); + favSync.type = "checkbox"; + logo.append(checkRow("Also use this image as the favicon", favSync)); + + const faviconURL = textField("Favicon URL", branding["favicon-url"], "/assets/logo.png"); + logo.append(reg("branding", "favicon-url", faviconURL)); + + const mirrorFavicon = () => { + if (favSync.checked) faviconURL.input.value = logoURL.input.value.trim(); + faviconURL.input.disabled = favSync.checked; + }; + if (branding["logo-url"] && branding["favicon-url"] === branding["logo-url"]) favSync.checked = true; + favSync.addEventListener("change", mirrorFavicon); + logoURL.input.addEventListener("input", mirrorFavicon); + mirrorFavicon(); + + logo.append(reg("branding", "logo-text", textField("Logo text", branding["logo-text"], "G"))); + logo.append(reg("branding", "hide-logo", checkField("Hide logo", branding["hide-logo"]))); + + const app = div("editor-fields"); + app.append(reg("branding", "app-name", textField("App name", branding["app-name"], "Dynacat"))); + app.append(reg("branding", "app-icon-url", textField("App icon URL", branding["app-icon-url"], "/assets/app-icon.svg"))); + app.append(reg("branding", "app-background-color", colorField("App background color", branding["app-background-color"], resolveCssColor("--color-background"), "hex"))); + + const misc = div("editor-fields"); + misc.append(reg("branding", "hide-footer", checkField("Hide footer", branding["hide-footer"]))); + misc.append(reg("branding", "custom-footer", areaField("Custom footer HTML", branding["custom-footer"]))); + misc.append(reg("branding", "show-desktop-navigation-on-hover", checkField("Show desktop navigation on hover", branding["show-desktop-navigation-on-hover"]))); + misc.append(reg("branding", "center-desktop-navigation", checkField("Center desktop navigation", branding["center-desktop-navigation"]))); + + bodyEl.append( + sectionTitle("Logo & branding"), logo, + sectionTitle("App / PWA"), app, + sectionTitle("Footer & navigation"), misc, + ); + } + + const collect = () => { + let themeOut, brandingOut; + for (const c of controls) { + if (c.section === "theme") (themeOut ??= {})[c.key] = c.read(); + else (brandingOut ??= {})[c.key] = c.read(); + } + return { themeOut, brandingOut }; + }; + + const actions = div("editor-modal-actions"); + const cancel = button("Cancel", "editor-btn"); + const deleteBtn = button("", "editor-btn editor-btn-icon-danger"); + deleteBtn.append(iconSpan(iconTrash)); + deleteBtn.title = "Delete theme"; + const saveAsNew = button("Save as new", "editor-btn"); + const saveBtn = button("Save", "editor-btn editor-btn-primary"); + + cancel.addEventListener("click", () => overlay.remove()); + + saveBtn.addEventListener("click", async () => { + const { themeOut, brandingOut } = collect(); + saveBtn.disabled = true; + try { + const ok = + page === "branding" + ? await saveStyling(undefined, brandingOut || {}) + : selectedKey === "" + ? await saveStyling(themeOut, brandingOut) + : await savePreset(selectedKey, themeOut); + if (ok) overlay.remove(); + } finally { + saveBtn.disabled = false; + } + }); + + saveAsNew.addEventListener("click", () => { + promptModal("Name for the new theme", "Theme name", async (name) => { + if (presets.some((p) => p.key === name)) { + toast(`A theme named "${name}" already exists`, "negative"); + return false; + } + const { themeOut } = collect(); + if (await savePreset(name, themeOut)) overlay.remove(); + return true; + }); + }); + + deleteBtn.addEventListener("click", () => { + const key = selectedKey; + const message = key === "" ? "Reset the default theme to the built-in default?" : `Delete the theme "${key}"?`; + confirmAction(message, async () => { + if (await deletePreset(key)) overlay.remove(); + }); + }); + + // Delete and "save as new" act on themes, so they have no meaning on the branding page. + const syncActions = () => { + const brandingPage = page === "branding"; + deleteBtn.hidden = brandingPage; + saveAsNew.hidden = brandingPage; + }; + + actions.append(deleteBtn, cancel, saveAsNew, saveBtn); + modal.append(actions); + + highlightCurrent(); + renderBody(selectedKey); + syncActions(); + + overlay.append(modal); + let pressedOnBackdrop = false; + overlay.addEventListener("mousedown", (e) => (pressedOnBackdrop = e.target === overlay)); + overlay.addEventListener("click", (e) => { + if (e.target === overlay && pressedOnBackdrop) overlay.remove(); + }); + document.body.append(overlay); +} + +function themeSwatch(values) { + const btn = button("", "theme-preset"); + if (values["light"] === "true" || values["light"] === true) btn.classList.add("theme-preset-light"); + btn.style.setProperty("--color", colorToHex(values["background-color"]) || colorToHex("240 8 9")); + + const primary = colorToHex(values["primary-color"]) || colorToHex("280 70 60"); + const positive = colorToHex(values["positive-color"]) || (values["primary-color"] ? primary : colorToHex("280 70 60")); + const negative = colorToHex(values["negative-color"]) || colorToHex("0 70 70"); + for (const c of [primary, positive, negative]) { + const dot = div("theme-color"); + dot.style.setProperty("--color", c); + btn.append(dot); + } + return btn; +} + +async function saveStyling(theme, branding) { + const before = await serverGeneration(); + if (!(await commit({ op: "editStyling", theme, branding }))) return false; + await waitForServerReload(before); + location.reload(); + return true; +} + +async function savePreset(key, theme) { + const before = await serverGeneration(); + if (!(await commit({ op: "editStyling", presetKey: key, theme }))) return false; + await waitForServerReload(before); + location.reload(); + return true; +} + +async function deletePreset(key) { + const before = await serverGeneration(); + if (!(await commit({ op: "deleteThemePreset", presetKey: key }))) return false; + await waitForServerReload(before); + location.reload(); + return true; +} + +function sectionTitle(text) { + return div("editor-section-title", text); +} + +function colorField(label, explicit, effective, mode) { + const normalize = (v) => (mode === "hex" ? colorToHex(v) || String(v || "").trim() : normalizeHsl(v)); + + const wrapper = div("editor-field"); + const l = document.createElement("label"); + l.className = "editor-field-label"; + l.textContent = label; + wrapper.append(l); + + const row = div("editor-color-row"); + const swatch = document.createElement("input"); + swatch.type = "color"; + swatch.className = "editor-color-swatch"; + const text = inputEl("text"); + text.classList.add("editor-color-text"); + text.placeholder = mode === "hex" ? "#151519" : "43 50 70"; + + const shown = explicit || (mode === "hex" ? colorToHex(effective) : normalizeHsl(effective)) || ""; + if (shown) text.value = shown; + swatch.value = colorToHex(shown) || "#000000"; + const initial = normalize(shown); + + swatch.addEventListener("input", () => { + text.value = mode === "hex" ? swatch.value : hexToHslString(swatch.value); + }); + text.addEventListener("input", () => { + const hex = colorToHex(text.value); + if (hex) swatch.value = hex; + }); + + row.append(swatch, text); + wrapper.append(row); + + return { + wrapper, + input: text, + read: () => { + const t = text.value.trim(); + if (!t) return ""; + const out = normalize(t); + if (explicit === undefined && out === initial) return ""; + return out; + }, + }; +} + +function normalizeHsl(value) { + const t = String(value == null ? "" : value).trim(); + if (!t) return ""; + if (!/^#|^rgb/i.test(t)) { + const tr = parseHslTriple(t); + if (tr) return `${Math.round(tr.h)} ${Math.round(tr.s)} ${Math.round(tr.l)}`; + } + return colorToHslString(t) || t; +} + +function resolveCssColor(varName) { + const probe = document.createElement("span"); + probe.style.cssText = `color: var(${varName}); display: none`; + document.body.appendChild(probe); + const rgb = getComputedStyle(probe).color; + probe.remove(); + return rgb; +} + +function checkField(label, value) { + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = value === true || value === "true"; + return { wrapper: checkRow(label, input), input, read: () => input.checked }; +} + +function checkRow(label, input) { + const row = document.createElement("label"); + row.className = "editor-field editor-check"; + const text = document.createElement("span"); + text.className = "editor-check-label"; + text.textContent = label; + row.append(input, text); + return row; +} + +function textField(label, value, placeholder) { + const input = inputEl("text"); + if (value) input.value = value; + if (placeholder) input.placeholder = placeholder; + return { wrapper: labeled(label, input), input, read: () => input.value.trim() }; +} + +function numField(label, value, placeholder) { + const input = inputEl("number"); + input.step = "any"; + if (value) input.value = value; + if (placeholder) input.placeholder = placeholder; + return { wrapper: labeled(label, input), input, read: () => (input.value === "" ? "" : Number(input.value)) }; +} + +function areaField(label, value) { + const input = document.createElement("textarea"); + input.className = "editor-input editor-textarea"; + if (value) input.value = value; + return { wrapper: labeled(label, input), input, read: () => input.value.trim() }; +} + +function iconField(label, value, placeholder) { + const input = inputEl("text"); + if (value) input.value = value; + if (placeholder) input.placeholder = placeholder; + const wrapper = labeled(label, input); + wrapper.append(iconPreview(input)); + return { wrapper, input, read: () => input.value.trim() }; +} + +function selectField(label, value, options) { + const input = document.createElement("select"); + input.className = "editor-input"; + for (const opt of options) input.append(new Option(opt === "" ? "-" : opt, opt)); + input.value = value || ""; + return { wrapper: labeled(label, input), input, read: () => input.value }; +} + +// +// Color conversions +// + +function colorToHex(value) { + const str = String(value == null ? "" : value).trim(); + if (!str) return ""; + + let m = str.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/); + if (m) { + let h = m[1]; + if (h.length === 3) h = h.split("").map((c) => c + c).join(""); + return "#" + h.toLowerCase(); + } + + m = str.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i); + if (m) return rgbToHex(+m[1], +m[2], +m[3]); + + const hsl = parseHslTriple(str); + if (hsl) return hslToHex(hsl.h, hsl.s, hsl.l); + + return ""; +} + +function colorToHslString(value) { + const hex = colorToHex(value); + if (hex) return hexToHslString(hex); + const t = parseHslTriple(value); + return t ? `${Math.round(t.h)} ${Math.round(t.s)} ${Math.round(t.l)}` : ""; +} + +function parseHslTriple(value) { + const m = String(value == null ? "" : value).trim() + .match(/^(?:hsla?\()?\s*([\d.]+)[\s,]+([\d.]+)%?[\s,]+([\d.]+)%?\s*\)?$/); + if (!m) return null; + const h = +m[1], s = +m[2], l = +m[3]; + if (h > 360 || s > 100 || l > 100) return null; + return { h, s, l }; +} + +function hexToRgb(hex) { + const h = hex.replace("#", ""); + return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) }; +} + +function rgbToHex(r, g, b) { + const to = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0"); + return "#" + to(r) + to(g) + to(b); +} + +function hexToHslString(hex) { + const { r, g, b } = hexToRgb(hex); + const { h, s, l } = rgbToHsl(r, g, b); + return `${h} ${s} ${l}`; +} + +function rgbToHsl(r, g, b) { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + let h = 0, s = 0; + const l = (max + min) / 2; + const d = max - min; + if (d !== 0) { + s = d / (1 - Math.abs(2 * l - 1)); + switch (max) { + case r: h = ((g - b) / d) % 6; break; + case g: h = (b - r) / d + 2; break; + default: h = (r - g) / d + 4; + } + h *= 60; + if (h < 0) h += 360; + } + return { h: Math.round(h), s: Math.round(s * 100), l: Math.round(l * 100) }; +} + +function hslToHex(h, s, l) { + s /= 100; l /= 100; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const mm = l - c / 2; + let r = 0, g = 0, b = 0; + if (h < 60) { r = c; g = x; } + else if (h < 120) { r = x; g = c; } + else if (h < 180) { g = c; b = x; } + else if (h < 240) { g = x; b = c; } + else if (h < 300) { r = x; b = c; } + else { r = c; b = x; } + return rgbToHex((r + mm) * 255, (g + mm) * 255, (b + mm) * 255); +} + +// +// Generic modal +// + +// Falls back to plain text when the schema carries no documentation URL. +function modalTitle(title, docsURL) { + if (!docsURL) return div("editor-modal-title", title); + + const el = document.createElement("a"); + el.className = "editor-modal-title editor-modal-title-link"; + el.href = docsURL; + el.target = "_blank"; + el.rel = "noreferrer"; + el.textContent = title; + return el; +} + +// buildActions gets the close callback and returns the footer buttons, left to right. +function modalShell(title, sections, docsURL, buildActions) { + const overlay = div("editor-ui editor-modal-overlay"); + const modal = div("editor-modal"); + + const bodyEl = div("editor-modal-body"); + for (const s of sections) bodyEl.append(s); + + const actions = div("editor-modal-actions"); + actions.append(...buildActions(() => overlay.remove()).filter(Boolean)); + modal.append(modalTitle(title, docsURL), bodyEl, actions); + + overlay.append(modal); + let pressedOnBackdrop = false; + overlay.addEventListener("mousedown", (e) => (pressedOnBackdrop = e.target === overlay)); + overlay.addEventListener("click", (e) => { + if (e.target === overlay && pressedOnBackdrop) overlay.remove(); + }); + document.body.append(overlay); +} + +function openModal(title, sections, onSave, footerLeft, docsURL) { + modalShell(title, sections, docsURL, (close) => { + const cancel = button("Cancel", "editor-btn"); + const saveBtn = button("Save", "editor-btn editor-btn-primary"); + cancel.addEventListener("click", close); + saveBtn.addEventListener("click", async () => { + saveBtn.disabled = true; + try { + if (await onSave()) close(); + } finally { + saveBtn.disabled = false; + } + }); + return [footerLeft, cancel, saveBtn]; + }); +} + +function noticeModal(title, sections, docsURL) { + const okBtn = button("Got it", "editor-btn editor-btn-primary"); + modalShell(title, sections, docsURL, (close) => { + okBtn.addEventListener("click", close); + return [okBtn]; + }); + okBtn.focus(); +} + +function confirmAction(message, onConfirm, options = {}) { + if (window.matchMedia("(max-width: 768px)").matches) { + if (confirm(message)) onConfirm(); + return; + } + + const overlay = div("editor-ui editor-modal-overlay"); + const modal = div("editor-modal editor-confirm"); + modal.append(div("editor-confirm-message", message)); + if (options.note) modal.append(div("editor-confirm-note", options.note)); + + const actions = div("editor-modal-actions"); + const cancel = button("Cancel", "editor-btn"); + const confirmBtn = button(options.confirmLabel || "Remove", "editor-btn editor-btn-danger"); + if (!options.confirmLabel) confirmBtn.prepend(iconSpan(iconTrash)); + cancel.addEventListener("click", () => overlay.remove()); + confirmBtn.addEventListener("click", () => { + overlay.remove(); + onConfirm(); + }); + actions.append(cancel, confirmBtn); + modal.append(actions); + + overlay.append(modal); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) overlay.remove(); + }); + document.body.append(overlay); +} + +function promptModal(title, placeholder, onSubmit) { + const overlay = div("editor-ui editor-modal-overlay"); + const modal = div("editor-modal editor-confirm"); + modal.append(div("editor-confirm-message", title)); + + const input = inputEl("text"); + input.classList.add("editor-input"); + if (placeholder) input.placeholder = placeholder; + modal.append(input); + + const actions = div("editor-modal-actions"); + const cancel = button("Cancel", "editor-btn"); + const okBtn = button("Save", "editor-btn editor-btn-primary"); + const submit = async () => { + const val = input.value.trim(); + if (!val) return; + okBtn.disabled = true; + try { + if ((await onSubmit(val)) !== false) overlay.remove(); + } finally { + okBtn.disabled = false; + } + }; + cancel.addEventListener("click", () => overlay.remove()); + okBtn.addEventListener("click", submit); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") submit(); + }); + actions.append(cancel, okBtn); + modal.append(actions); + + overlay.append(modal); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) overlay.remove(); + }); + document.body.append(overlay); + input.focus(); +} + +function collapsible(title, content) { + const details = document.createElement("details"); + details.className = "editor-collapsible"; + const summary = document.createElement("summary"); + summary.textContent = title; + details.append(summary, content); + return details; +} + +// +// Toast +// + +function toast(message, type) { + let container = document.querySelector(".editor-toasts"); + if (!container) { + container = div("editor-ui editor-toasts"); + document.body.append(container); + } + const el = div(`editor-toast color-${type}`, message); + container.append(el); + setTimeout(() => el.remove(), 4000); +} + +// +// Small DOM helpers +// + +function div(className, text) { + const el = document.createElement("div"); + el.className = className; + if (text) el.textContent = text; + return el; +} + +function img(className, src) { + const el = document.createElement("img"); + el.className = className; + if (src) el.src = src; + return el; +} + +function maskIcon(className, url) { + const el = div(className); + el.style.setProperty("--icon-url", `url("${url}")`); + return el; +} + +function button(text, className) { + const el = document.createElement("button"); + el.type = "button"; + el.className = className; + el.textContent = text; + return el; +} + +function inputEl(type) { + const el = document.createElement("input"); + el.type = type; + el.className = "editor-input"; + return el; +} + +function labeled(label, control, required, hint) { + const wrapper = div("editor-field"); + const l = document.createElement("label"); + l.className = "editor-field-label"; + l.append(label); + if (required) l.append(requiredStar()); + wrapper.append(l); + if (hint) wrapper.append(fieldHint(hint)); + wrapper.append(control); + return wrapper; +} + +function fieldHint(text) { + const hint = document.createElement("p"); + hint.className = "editor-field-hint"; + hint.append(text); + return hint; +} + +function requiredStar() { + const star = document.createElement("span"); + star.className = "editor-required"; + star.textContent = "*"; + return star; +} + +function toolButton(title, svg, onClick) { + const b = button("", "editor-tool"); + b.title = title; + b.innerHTML = svg; + b.addEventListener("click", (e) => { + e.stopPropagation(); + onClick(); + }); + return b; +} + +const iconPlus = ``; +const iconPencil = ``; +const iconCog = ``; +const iconGrip = ``; +const iconChevron = ``; +const iconPaste = ``; +const iconCopy = ``; +const iconTrash = ``; +const iconMedal = ``; diff --git a/internal/dynacat/static/js/login.js b/internal/dynacat/static/js/login.js index 55a220b6..badc5ed8 100644 --- a/internal/dynacat/static/js/login.js +++ b/internal/dynacat/static/js/login.js @@ -20,7 +20,6 @@ const toggleVisibilityButton = find("#toggle-password-visibility"); container.clearStyles("display"); -// Password form only wired up if elements exist if (usernameInput && passwordInput && loginButton) { const state = { lastUsername: "", @@ -59,7 +58,6 @@ if (usernameInput && passwordInput && loginButton) { const usernameValue = usernameInput.value.trim(); const passwordValue = passwordInput.value.trim(); - // Keep client-side validation permissive and let the server decide auth validity. const usernameValid = usernameValue.length > 0; const passwordValid = passwordValue.length > 0; @@ -79,7 +77,6 @@ if (usernameInput && passwordInput && loginButton) { usernameInput.on("input", enableLoginButtonIfCriteriaMet); passwordInput.on("input", enableLoginButtonIfCriteriaMet); - // Allow pressing Enter in either input to submit the login when enabled usernameInput.on("keydown", function(e) { if (e.key === "Enter") { e.preventDefault(); @@ -121,7 +118,7 @@ if (usernameInput && passwordInput && loginButton) { if (data && typeof data.redirect === "string" && data.redirect.startsWith("/") && !data.redirect.startsWith("//")) { destination = data.redirect; } - } catch (_) { /* no body; fall back to home */ } + } catch (_) {} setTimeout(() => { window.location.href = destination; }, 300); container.animate({ diff --git a/internal/dynacat/static/js/masonry.js b/internal/dynacat/static/js/masonry.js index e7d470ca..6b456a71 100644 --- a/internal/dynacat/static/js/masonry.js +++ b/internal/dynacat/static/js/masonry.js @@ -1,4 +1,3 @@ - import { clamp } from "./utils.js"; export function setupMasonries() { @@ -27,11 +26,11 @@ export function setupMasonries() { if (columnsCount === previousColumnsCount) { return; - } else { - container.textContent = ""; - previousColumnsCount = columnsCount; } + container.textContent = ""; + previousColumnsCount = columnsCount; + const columnsFragment = document.createDocumentFragment(); for (let i = 0; i < columnsCount; i++) { @@ -47,7 +46,13 @@ export function setupMasonries() { container.append(columnsFragment); }; + // A morph reuses the container while wiping data-initialized, so it drops the previous observer. + if (container._masonryObserver) { + container._masonryObserver.disconnect(); + } + const observer = new ResizeObserver(() => requestAnimationFrame(render)); + container._masonryObserver = observer; observer.observe(container); } } diff --git a/internal/dynacat/static/js/page.js b/internal/dynacat/static/js/page.js index 2f7a2c60..f00e47fc 100644 --- a/internal/dynacat/static/js/page.js +++ b/internal/dynacat/static/js/page.js @@ -15,6 +15,8 @@ async function fetchPageContent(pageData) { }; } +let carouselResizeListenerInitialized = false; + function setupCarousels() { const carouselElements = document.getElementsByClassName("carousel-container"); @@ -22,6 +24,20 @@ function setupCarousels() { return; } + if (!carouselResizeListenerInitialized) { + carouselResizeListenerInitialized = true; + + const determineAllSideCutoffs = throttledDebounce(() => { + const carousels = document.getElementsByClassName("carousel-container"); + + for (let i = 0; i < carousels.length; i++) { + if (carousels[i]._determineSideCutoffs) carousels[i]._determineSideCutoffs(); + } + }, 20, 100); + + window.addEventListener("resize", determineAllSideCutoffs); + } + for (let i = 0; i < carouselElements.length; i++) { const carousel = carouselElements[i]; @@ -47,10 +63,20 @@ function setupCarousels() { const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100); + // A morph reuses the container while wiping data-initialized, so drop the previous handler. + if (itemsContainer._carouselScrollHandler) { + itemsContainer.removeEventListener("scroll", itemsContainer._carouselScrollHandler); + } + + itemsContainer._carouselScrollHandler = determineSideCutoffsRateLimited; itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited); - window.addEventListener("resize", determineSideCutoffsRateLimited); + carousel._determineSideCutoffs = determineSideCutoffs; - afterContentReady(determineSideCutoffs); + if (pageSetupComplete) { + determineSideCutoffs(); + } else { + afterContentReady(determineSideCutoffs); + } } } @@ -107,7 +133,6 @@ let keybindState = { pressedKeys: [], chordTimeout: null }; function normalizedEventKey(event) { const key = event.key.toLowerCase(); if (/^[a-z0-9]$/.test(key)) return key; - // Use physical key codes so existing binds still work on non-English keyboard layouts if (/^Key[A-Z]$/.test(event.code)) return event.code.slice(3).toLowerCase(); if (/^Digit[0-9]$/.test(event.code)) return event.code.slice(5); return ""; @@ -255,8 +280,6 @@ function setupSearchBoxes() { } if (event.key == "Enter") { - // Prevent the form submit event from firing a second search after - // keydown already handled submission. event.preventDefault(); const openInNewTab = newTab && !event.ctrlKey || !newTab && event.ctrlKey; submitSearch(openInNewTab); @@ -319,7 +342,7 @@ function setupSearchBoxes() { document.addEventListener("keydown", (event) => { if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; if (event.code != "KeyS") return; - if (keybindState.pressedKeys.length > 0) return; // Don't trigger search if in chord + if (keybindState.pressedKeys.length > 0) return; inputElement.focus(); event.preventDefault(); @@ -329,22 +352,29 @@ function setupSearchBoxes() { requestAnimationFrame(() => inputElement.focus()); }); - // Handle autofocus for dynamically loaded content if (inputElement.hasAttribute("autofocus")) { - // Use requestAnimationFrame to ensure DOM is fully ready requestAnimationFrame(() => { inputElement.focus(); }); } - // Search Autocomplete - if (widget.dataset.autocomplete === "true") { + if (widget.dataset.autocomplete === "true" || widget.dataset.targetsEnabled === "true") { + const autocompleteEnabled = widget.dataset.autocomplete === "true"; + const targetsEnabled = widget.dataset.targetsEnabled === "true"; const autocompleteEl = widget.querySelector(".search-autocomplete"); + const targets = Array.from(widget.querySelectorAll(".search-targets > input")).map((el) => ({ + type: "target", + kind: el.dataset.kind || "bookmark", + title: el.dataset.title, + url: el.dataset.url, + target: el.dataset.target || "", + icon: el.dataset.icon || "", + iconAutoInvert: el.dataset.iconAutoInvert === "true", + })); let acItems = []; let acIndex = -1; let acDebounce = null; let acVisible = false; - let acAbove = false; let acRepositioner = null; let acRepositionFrame = null; @@ -355,7 +385,6 @@ function setupSearchBoxes() { const spaceAbove = rect.top; const maxH = 280; - // Switch to above if not enough space below and more space above const goAbove = spaceBelow < 120 && spaceAbove > spaceBelow; autocompleteEl.style.left = rect.left + "px"; @@ -371,12 +400,6 @@ function setupSearchBoxes() { autocompleteEl.style.maxHeight = Math.min(maxH, spaceBelow - 4) + "px"; } - if (goAbove !== acAbove) { - acAbove = goAbove; - } - - // Keep direction classes in sync on every reposition so the initial - // below state also receives its border adjustments. autocompleteEl.classList.toggle("search-autocomplete-above", goAbove); widget.classList.toggle("search-suggestions-above", goAbove); widget.classList.toggle("search-suggestions-below", !goAbove); @@ -414,7 +437,6 @@ function setupSearchBoxes() { autocompleteEl.classList.remove("active", "search-autocomplete-above"); widget.classList.remove("search-suggestions-above", "search-suggestions-below"); acVisible = false; - acAbove = false; acIndex = -1; }; @@ -424,20 +446,71 @@ function setupSearchBoxes() { acIndex = i; }; - const renderAC = (suggestions) => { - acItems = suggestions; + const TARGET_MATCH_LIMIT = 3; + const TARGET_FALLBACK_GLYPHS = { bookmark: "↗", docker: "▣", monitor: "◉" }; + + // Each kind gets its own limit so bookmarks can't crowd out containers or sites. + const matchTargets = (query) => { + if (!targetsEnabled || !query) return []; + const lowerQuery = query.toLowerCase(); + const countPerKind = {}; + + return targets.filter((t) => { + if (!t.title || !t.title.toLowerCase().startsWith(lowerQuery)) return false; + countPerKind[t.kind] = (countPerKind[t.kind] || 0) + 1; + return countPerKind[t.kind] <= TARGET_MATCH_LIMIT; + }); + }; + + const selectItem = (item) => { + if (item.type === "target") { + hideAC(); + if (item.target === "_blank") { + window.open(item.url, item.target).focus(); + } else { + window.location.href = item.url; + } + return; + } + + inputElement.value = item.phrase; + hideAC(); + submitSearch(newTab); + }; + + const renderAC = (items) => { + acItems = items; acIndex = -1; autocompleteEl.innerHTML = ""; - if (suggestions.length === 0) { hideAC(); return; } - suggestions.forEach((phrase, idx) => { + if (items.length === 0) { hideAC(); return; } + items.forEach((acItem, idx) => { const item = document.createElement("div"); - item.className = "search-autocomplete-item"; - item.textContent = phrase; + + if (acItem.type === "target") { + item.className = "search-autocomplete-item search-autocomplete-item-target search-autocomplete-item-" + acItem.kind; + if (acItem.icon) { + const icon = document.createElement("img"); + icon.className = "search-autocomplete-item-target-icon" + (acItem.iconAutoInvert ? " flat-icon" : ""); + icon.src = acItem.icon; + icon.alt = ""; + item.appendChild(icon); + } else { + const fallback = document.createElement("span"); + fallback.className = "search-autocomplete-item-target-fallback"; + fallback.textContent = TARGET_FALLBACK_GLYPHS[acItem.kind] || TARGET_FALLBACK_GLYPHS.bookmark; + item.appendChild(fallback); + } + const label = document.createElement("span"); + label.textContent = acItem.title; + item.appendChild(label); + } else { + item.className = "search-autocomplete-item"; + item.textContent = acItem.phrase; + } + item.addEventListener("mousedown", (e) => { e.preventDefault(); - inputElement.value = phrase; - hideAC(); - submitSearch(newTab); + selectItem(acItem); }); item.addEventListener("mousemove", () => { setACIndex(idx); @@ -447,19 +520,33 @@ function setupSearchBoxes() { showAC(); }; + const updateSuggestions = (query, phraseItems) => { + if (inputElement.value.trim() !== query) return; + renderAC([...matchTargets(query), ...phraseItems]); + }; + const fetchSuggestions = (query) => { - if (!query || query.length < 2 || currentBang != null) { + if (!query || currentBang != null) { hideAC(); return; } + + if (!autocompleteEnabled || query.length < 2) { + updateSuggestions(query, []); + return; + } + const provider = widget.dataset.autocompleteProvider || "duckduckgo"; - fetch("/api/search/autocomplete?q=" + encodeURIComponent(query) + "&provider=" + encodeURIComponent(provider)) + let suggestionsUrl = "/api/search/autocomplete?q=" + encodeURIComponent(query) + "&provider=" + encodeURIComponent(provider); + if (provider === "custom" && widget.dataset.autocompleteWidgetId) { + suggestionsUrl += "&widgetId=" + encodeURIComponent(widget.dataset.autocompleteWidgetId); + } + fetch(suggestionsUrl) .then((r) => r.json()) .then((data) => { - if (inputElement.value.trim() !== query) return; - renderAC(data.map((d) => d.phrase)); + updateSuggestions(query, data.map((d) => ({ type: "phrase", phrase: d.phrase }))); }) - .catch(() => hideAC()); + .catch(() => updateSuggestions(query, [])); }; inputElement.addEventListener("input", () => { @@ -475,20 +562,20 @@ function setupSearchBoxes() { event.preventDefault(); setACIndex(Math.min(acIndex + 1, acItems.length - 1)); } else if (event.key === "ArrowUp") { + // Stop the document handler from also restoring the last query. event.preventDefault(); + event.stopPropagation(); setACIndex(Math.max(acIndex - 1, -1)); } else if (event.key === "Enter" && acIndex >= 0) { + // Stop the document handler from also running a search for the query. event.preventDefault(); - inputElement.value = acItems[acIndex]; - hideAC(); - submitSearch(newTab); + event.stopPropagation(); + selectItem(acItems[acIndex]); } else if (event.key === "Escape") { hideAC(); } }); - // Delay hide to guard against spurious blur (e.g. Chrome scroll-on-focus - // near viewport bottom). Only close if focus genuinely left the input. inputElement.addEventListener("blur", () => { setTimeout(() => { if (document.activeElement !== inputElement) { @@ -501,7 +588,6 @@ function setupSearchBoxes() { } function setupDynamicRelativeTime() { - // Always do an immediate update pass (new elements may have arrived) updateRelativeTimeForElements(document.querySelectorAll("[data-dynamic-relative-time]")); if (dynamicRelativeTimeInitialized) return; @@ -641,13 +727,20 @@ function setupGroups() { function setupImageFallbacks() { document.querySelectorAll("img[data-fallback-src]:not([loading=lazy])").forEach(img => { - img.addEventListener("error", function handler() { + if (img._fallbackHandler) { + img.removeEventListener("error", img._fallbackHandler); + } + + const handler = function() { img.removeEventListener("error", handler); const fallback = img.dataset.fallbackSrc; if (fallback && img.src !== fallback) { img.src = fallback; } - }); + }; + + img._fallbackHandler = handler; + img.addEventListener("error", handler); }); } @@ -686,12 +779,10 @@ function setupLazyImages() { }; if (image.complete) { - // Check if the image loaded successfully if (image.naturalHeight > 0) { image.classList.add("cached"); setTimeout(() => imageFinishedTransition(image), 1); } else { - // Image failed to load, try fallback handleError(); } } else { @@ -967,6 +1058,11 @@ function setupCollapsibleGrids() { applyCollapsibleItems(cardsPerRow); } + // This runs page wide on every widget update + if (gridElement._collapsibleResizeObserver) { + gridElement._collapsibleResizeObserver.disconnect(); + } + const observer = new ResizeObserver(() => { if (!isElementVisible(gridElement)) { return; @@ -982,7 +1078,13 @@ function setupCollapsibleGrids() { resolveCollapsibleItems(); }); - afterContentReady(() => observer.observe(gridElement)); + gridElement._collapsibleResizeObserver = observer; + + if (pageSetupComplete) { + observer.observe(gridElement); + } else { + afterContentReady(() => observer.observe(gridElement)); + } } } @@ -1128,8 +1230,6 @@ function setupClocks() { } async function setupCalendars() { - // Snapshot the collection: a built calendar keeps the "calendar" class, so a - // live collection (and any re-entry of setup) could re-process it. const elems = Array.from(document.getElementsByClassName("calendar")); if (elems.length == 0) return; @@ -1177,8 +1277,6 @@ function setupTruncatedElementTitles() { const THEME_STORAGE_KEY = "dynacat-theme"; -// Mirror the active theme to localStorage so reloads survive a lost cookie and -// other tabs can pick the change up via the storage event. function persistTheme(key, css, scheme) { try { localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ key: key, css: css, scheme: scheme })); @@ -1266,7 +1364,6 @@ function initThemePicker() { }); }) - // Live sync when the theme is changed in another tab. window.addEventListener("storage", (e) => { if (e.key !== THEME_STORAGE_KEY || !e.newValue) return; @@ -1337,8 +1434,6 @@ async function setupPage() { initThemePicker(); - // If the early restore swapped in a theme the server did not have a cookie - // for, re-POST it so the server cookie is rewritten and stays in sync. if (pageData.serverTheme !== undefined && pageData.serverTheme !== pageData.theme) { fetch(`${pageData.baseURL}/api/set-theme/${pageData.theme}`, { method: "POST" }).catch(() => {}); } @@ -1455,7 +1550,7 @@ async function updateWidget(widgetElement) { } syncWidgetUpdateInterval(widgetElement, newWidget); -} + } if (newWidget && widgetElement.outerHTML !== newWidget.outerHTML) { const oldContent = widgetElement.querySelector('.widget-content'); @@ -1544,10 +1639,6 @@ function updateContentPreservingImages(oldContent, newContent) { oldContent.replaceWith(newContent); } -// syncWidgetUpdateInterval keeps the polling cadence in step with a widget that -// reports a dynamic data-update-interval (e.g. speedtest polls fast while testing, -// then slows down once the result is in). updateWidget only swaps inner content, so -// the root attribute and the polling state need to be reconciled explicitly. function syncWidgetUpdateInterval(widgetElement, newWidget) { const newInterval = newWidget.dataset.updateInterval; @@ -1620,7 +1711,6 @@ function remainingDelayMs(intervalMs, lastRunAt) { const widgetPollingStates = new Map(); let widgetPollingVisibilityListenerInitialized = false; -// Local playing progress updaters keyed by widget element const playingUpdaters = new Map(); function clearPlayingUpdater(widget) { @@ -2180,6 +2270,7 @@ function _initSSE() { window.addEventListener('beforeunload', _closeSSE); window.dynacatSetupPopovers = setupPopovers; +window.dynacatSetupCollapsibleLists = setupCollapsibleLists; function fetchLazyWidgets() { document.querySelectorAll('.widget[data-lazy-load]').forEach(widget => { diff --git a/internal/dynacat/static/js/popover.js b/internal/dynacat/static/js/popover.js index a9f1df3c..da9efe8e 100644 --- a/internal/dynacat/static/js/popover.js +++ b/internal/dynacat/static/js/popover.js @@ -73,11 +73,7 @@ function showPopover() { } else if (popoverType === "html") { const htmlContent = activeTarget.querySelector(htmlContentSelector); if (htmlContent === null) return; - /** - * The reason for all of the below shenanigans is that I want to preserve - * all attached event listeners of the original HTML content. This is so I don't have to - * re-setup events for things like lazy images, they'd just work as expected. - */ + // Move the node itself to preserve its attached event listeners. const placeholder = document.createComment(""); htmlContent.replaceWith(placeholder); contentElement.replaceChildren(htmlContent); diff --git a/internal/dynacat/static/js/todo.js b/internal/dynacat/static/js/todo.js index 429638b4..4d803339 100644 --- a/internal/dynacat/static/js/todo.js +++ b/internal/dynacat/static/js/todo.js @@ -574,8 +574,7 @@ export function verticallyReorderable(itemsContainer, onItemRepositioned, onDrag item.initialIndex = decoy.currentIndex = itemsContainer.children.indexOf(element); item.clientOffset.set(client.x - elementRect.x, client.y - elementRect.y); - // We use getComputedStyle here to get width and height because .clientWidth and .clientHeight - // return integers and not the real float values, which can cause the decoy to be off by a pixel + // getComputedStyle gives float width/height; clientWidth/Height round and offset the decoy by a pixel. const elementStyle = getComputedStyle(element); const initialWidth = elementStyle.width; diff --git a/internal/dynacat/static/js/utils.js b/internal/dynacat/static/js/utils.js index a82f97a8..aa0eb628 100644 --- a/internal/dynacat/static/js/utils.js +++ b/internal/dynacat/static/js/utils.js @@ -28,9 +28,6 @@ export function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } -// NOTE: inconsistent behavior between browsers when it comes to -// whether the newly opened tab gets focused or not, potentially -// depending on the event that this function is called from export function openURLInNewTab(url, focus = true) { const newWindow = window.open(url, '_blank', 'noopener,noreferrer'); diff --git a/internal/dynacat/templates/calendar.html b/internal/dynacat/templates/calendar.html index f566b781..79a3022e 100644 --- a/internal/dynacat/templates/calendar.html +++ b/internal/dynacat/templates/calendar.html @@ -4,6 +4,6 @@ {{ define "widget-content" }}
    -
    +
    {{ end }} diff --git a/internal/dynacat/templates/contribution-graph.html b/internal/dynacat/templates/contribution-graph.html deleted file mode 100644 index 6b6d9962..00000000 --- a/internal/dynacat/templates/contribution-graph.html +++ /dev/null @@ -1,31 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }} - -{{ define "widget-content" }} -
    -
    - {{ range .MonthLabels }} - {{ .Name }} - {{ end }} -
    -
    - {{ range .Weeks }} -
    - {{ range .Days }} -
    -
    - {{ end }} -
    - {{ end }} -
    - -
    -{{ end }} diff --git a/internal/dynacat/templates/first-run-setup.html b/internal/dynacat/templates/first-run-setup.html new file mode 100644 index 00000000..67ae83dd --- /dev/null +++ b/internal/dynacat/templates/first-run-setup.html @@ -0,0 +1,223 @@ + + + + + + + Welcome to Dynacat + + + + + + + +
    +

    Welcome to Dynacat

    +

    Create your first page

    +

    + There is no config file yet. Pick a name and a starting point - every widget can be + rearranged afterwards with the built in editor. +

    + +
    + +
    + +
    + +

    Start from

    + +
    + {{- range $index, $preset := .Presets }} + + {{- end }} +
    + + + + +
    + +

    + Writing to {{ .ConfigPath }} · + docs +

    +
    + + + + + 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 }}
      +{{ if eq .StatusStyle "disabled" }} +
      {{ .StatusLabel }}
      +
      + + + +
      +{{ else }}
      {{ .Status.ResponseTime.Milliseconds | formatNumber }}ms
      {{ if eq .StatusStyle "ok" }}
      @@ -44,5 +53,6 @@
      {{ end }} +{{ end }}
      {{ end }} diff --git a/internal/dynacat/templates/monitor-history.html b/internal/dynacat/templates/monitor-history.html new file mode 100644 index 00000000..c9bc4cad --- /dev/null +++ b/internal/dynacat/templates/monitor-history.html @@ -0,0 +1,7 @@ +{{ define "monitor-history" }} +
      + {{ range .History }} +
      + {{ end }} +
      +{{ end }} diff --git a/internal/dynacat/templates/monitor.html b/internal/dynacat/templates/monitor.html index 4706b822..d1a6439c 100644 --- a/internal/dynacat/templates/monitor.html +++ b/internal/dynacat/templates/monitor.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" . }}
        @@ -30,16 +30,25 @@ {{ .Title }} {{- if .Description }}
        {{ .Description }}
        + {{- else if eq .StatusStyle "disabled" }} +
        {{ .StatusLabel }}
        {{- else }}
        {{ .StatusLabel }} • {{ .Status.ResponseTime.Milliseconds | formatNumber }}ms
        {{- end }} +{{ if .History }}{{ template "monitor-history" . }}{{ end }} {{ if eq .StatusStyle "ok" }}
        +{{ else if eq .StatusStyle "disabled" }} +
        + + + +
        {{ else }}
        diff --git a/internal/dynacat/templates/page.html b/internal/dynacat/templates/page.html index e272f35c..a5bb8833 100644 --- a/internal/dynacat/templates/page.html +++ b/internal/dynacat/templates/page.html @@ -5,6 +5,20 @@ {{ define "document-head-after" }} +{{ if .App.EditorEnabled }} + +{{ end }} {{ end }} {{ define "navigation-links" }} @@ -62,6 +76,13 @@
        {{ end }} + {{ if and (or (not .App.AnyAuthEnabled) .AuthUser) (.App.UserAllowedToEdit .AuthUser) (.App.EditingAllowedForPage .AuthUser .Page) }} + + {{ end }} {{- if .AuthUser }}
        {{ .AuthUser.Username }} diff --git a/internal/dynacat/templates/playing.html b/internal/dynacat/templates/playing.html index 38a85259..ed230f1a 100644 --- a/internal/dynacat/templates/playing.html +++ b/internal/dynacat/templates/playing.html @@ -4,6 +4,14 @@ {{ define "widget-content" }}