diff --git a/.claude/commands/seed-demo.md b/.claude/commands/seed-demo.md index 18145b7..ccca3ce 100644 --- a/.claude/commands/seed-demo.md +++ b/.claude/commands/seed-demo.md @@ -8,6 +8,6 @@ Run the demo seeding script to populate today with one tagged sample artifact pe python scripts/seed_demo_artifacts.py ``` -Requires the AgentFeed dev server to be running on the project's fixed local port: `python manage.py runserver 127.0.0.1:8001`. The script defaults to `AGENTFEED_URL=http://127.0.0.1:8001` / `AGENTFEED_TOKEN=dev-token`; no overrides needed unless the user has changed these. +Requires the Daybook dev server to be running on the project's fixed local port: `python manage.py runserver 127.0.0.1:8001`. The script defaults to `DAYBOOK_URL=http://127.0.0.1:8001` / `DAYBOOK_TOKEN=dev-token`; no overrides needed unless the user has changed these. After running, report each posted artifact's kind and URL, and remind the user to reload the feed for today. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 2cb6ed3..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,280 +0,0 @@ -# Changelog - -All notable changes to AgentFeed are recorded here. Format loosely follows -[Keep a Changelog](https://keepachangelog.com/); versions are SemVer and match -the git tags (`vX.Y.Z`) and `__version__`. (Entries before 0.3.0 use the app's -former name, "Daybook".) - -## [Unreleased] - -_Nothing yet._ - -## [0.7.1] — 2026-09-08 - -### Added -- **Custom labels.** Curate a personal palette of coloured labels in - **Settings → Labels**, then apply them to any message from its ⋯ menu (or - right-click) — the menu stays open so you can apply several at once, with a - checkmark showing which are applied. Labels are unified with tags: applying - one adds it to the message's tags and renders as a chip. Backed by - `POST /a/{id}/label` (session-auth, owner-scoped, palette-validated); chip - colours sync to the global `TagColor`. -- **Change your password in-app** from **Settings → Account**: enter the current - password, then a new one. Uses Django's `PasswordChangeForm` and keeps the - session logged in after the change. - -### Changed -- Sidebar: the **Keep** and **Deleted** folders are now always shown (even when - empty); the count badge only appears when the folder is non-empty. - -## [0.7.0] — 2026-09-07 - -### Added -- **Mark all as read.** Click a source's unread badge in the sidebar to mark - all of that source's messages read (clears the badge); click the **All - sources** total to mark everything read. Owner-scoped, excludes trashed; any - visible feed rows update too. Backed by `POST /feed/mark-read`. - -## [0.6.3] — 2026-09-07 - -### Changed -- **User-facing wording: "artifact(s)" → "messages"** throughout the UI (feed - count, empty states, preview, About, Storage). The model, API - (`/api/artifacts`), and posting skill keep "artifact" as the internal term — - no contract change for agents. -- Sidebar: the Keep/Deleted folders now have spacing between them and share the - "All sources" bold+blue-tint styling (they're special folders). - -### Fixed -- About page now points to Settings → API Keys (was the stale "Settings → - Account / personal API token"). - -## [0.6.2] — 2026-09-07 - -### Changed -- Source sidebar is now ordered by **most recent message** (Telegram-style) - instead of alphabetically; "All sources" stays pinned at the top. Live - updates move a source to the top when it receives a new message. - -## [0.6.1] — 2026-09-07 - -### Changed -- Sidebar: the **Keep / Deleted folders are pinned to the bottom** — only the - source list scrolls, so the folders stay reachable no matter how many sources - you have. **"All sources"** is now visually distinct (bold + subtle blue tint) - as the primary entry. (Internal: `.source-folders` class renamed to - `.system-folders`.) - -## [0.6.0] — 2026-09-07 - -### Added -- **Per-artifact actions menu.** A **⋯** button appears on row hover, and - **right-clicking** a feed row opens the same floating menu — **Keep / - Open / Copy link / Delete**. Dismisses on click-away or Escape, edge-flips - to stay on-screen. The standalone one-click Delete icon stays; the Trash - view keeps its dedicated Undelete. (Share and Add-label will join the menu - in later slices.) - -## [0.5.1] — 2026-09-07 - -### Added -- **Automatic Trash purge.** Trashed artifacts older than a ~3-day retention - window are now permanently removed automatically. It's cron-less: a delete - triggers a sweep that runs on a **background thread** (off the request, so - the click never blocks), tolerant of concurrent runs. Manual "Empty Trash" - still works for immediate reclaim. Controlled by `AGENTFEED_ASYNC_PURGE` - (on by default; tests run it inline). - -## [0.5.0] — 2026-09-07 - -### Added -- **Trash (soft-delete).** Deleting an artifact now moves it to a recoverable - **Trash** instead of removing it. Feed rows get a hover **Delete** icon; a - **"Deleted"** folder at the bottom of the sidebar lists trashed items with a - hover **Undelete**, plus an **Empty Trash** button to purge permanently. - Trashed items are **off-quota** (deleting frees quota immediately) and - eviction-protected. `DELETE /api/artifacts/{id}` is now a soft-delete too - (idempotent); trashed items are hidden from all API reads. -- **Keep folder.** A sidebar folder listing kept artifacts (a view over the - existing `is_kept` flag), mirroring Trash. Both hide when empty. -- Session endpoints `POST /a/{id}/delete`, `/a/{id}/undelete`, - `/feed/empty-trash`; feed `?folder=deleted` / `?folder=keep` views. - -### Changed -- `DELETE /api/artifacts/{id}` no longer hard-deletes — see above. - -### Note -- Permanent purge is manual (**Empty Trash**) in this release; automatic - purge-on-delete (background, ~3-day retention) is the planned fast-follow. - -### Changed -- Live feed updates now also keep the **source sidebar** current: as new posts - arrive, per-source and total unread counts bump, and a source posting for the - first time is added to the list (alphabetically) — all derived client-side - from the polled rows, no extra request. Sources added live stay - keyboard-navigable. Caveat: while filtered to a single source, *other* new - sources still only appear on reload (the poll is scoped to the selected view). - -## [0.4.0] — 2026-09-04 - -### Added -- **Live feed updates.** New artifacts now appear at the top of the feed - automatically, without a manual refresh — the browser polls a lightweight - `GET /feed/updates` every 5s (paused while the tab is hidden, one immediate - poll on refocus). Owner-scoped and **respects the active source/label/kind/q - filters**, so a new post that doesn't match your current view won't show. The - currently selected/previewed artifact stays put and visibly selected. Fresh - rows display "now" and age into their real time after a few minutes. - (Chosen over SSE/WebSockets deliberately: zero new infrastructure on the - sync-WSGI + SQLite stack; see `planning/next_steps/` for the SSE upgrade path.) - -### Changed -- Extracted the feed row markup into a reusable `_artifact_rows.html` partial - (shared by the day-group render and the live-updates endpoint). - -## [0.3.2] — 2026-09-04 - -### Added -- `GET /api/artifacts/{id}/raw` — the artifact's full content, separate from the - metadata detail endpoint. Bearer-token (or session) auth, owner-scoped, so an - agent can read back and verify exactly what it posted: exact source text for - `markdown`/`html_full`/`json`/`csv`, file bytes for `image`/`pdf`/`binary`, - `404` for render-only `html_fragment`/`deck`. The metadata response now - includes `api_raw_url`, and `/api/capabilities` advertises the route. - -## [0.3.1] — 2026-09-04 - -### Changed -- Artifact **read** endpoints (`GET /api/artifacts`, `GET /api/artifacts/{id}`) - now accept a bearer token as well as a browser session, owner-scoped — an - agent can read back what it posted with the same token. Unauthenticated API - requests return a JSON `403` instead of a 302 redirect to the browser login. -- `GET /api/skill` and `GET /api/capabilities` are now **public** (no auth) — - they expose no user data, so an agent can fetch the contract and posting - instructions before it has a key. - -## [0.3.0] — 2026-09-04 - -### Changed -- **Renamed the app to AgentFeed.** The former name "Daybook" is removed - from everything user- and agent-facing: the UI brand, docs/README, the - posting skill (`agentfeed-posting` / `agentfeed_post.py`), CSS - (`agentfeed.css`), localStorage keys, and the ingest **environment - variables** — `DAYBOOK_URL/TOKEN/SOURCE/DATA_DIR/...` are now - `AGENTFEED_*`. Agents must update their env-var names (the token *values* - are unchanged). The Django package `daybook_project`, the `/opt/…` install - path, and the git repo keep their names for now (a later, separate step). -- Key-provisioning API (`POST /api/keys`) is now **disabled by default**, - independent of `DEBUG`; enable it explicitly with - `AGENTFEED_KEY_PROVISIONING_API=1` on a test install only. - -### Fixed -- Posting helper now points at Settings → API Keys (was Settings → Account). - -## [0.2.1] — 2026-09-04 - -### Added -- Top-bar **avatar**: a round initials badge (deterministic per-user color) - replaces the gear/username button; lays the groundwork for profile photos. -- Django admin now registers `ApiKey` and `TagColor`, so `/admin/` is complete - as an operator break-glass tool (key value shown read-only). - -### Changed -- Settings nav regrouped into **You** (Account, API Keys, Storage, Demo Data) - and a superuser-only **Admin** (Users) section, with About on its own — the - personal/admin split now mirrors who each setting affects. - -## [0.2.0] — 2026-09-04 - -### Added -- **Multiple named API keys per user.** Each user can now hold several keys — - one per agent or script — named and revocable individually, managed from a - new **Settings → API Keys** page (a table with name, key, created, last-used, - and per-row revoke). Keys are viewable on demand (`dbk_`-prefixed). -- **Gated key-provisioning API** for automated test installations: - `GET/POST /api/keys` and `DELETE /api/keys/{id}`, controlled by - `DAYBOOK_KEY_PROVISIONING_API` (unset → follows `DJANGO_DEBUG`, so **off in - deployment**). Advertised as `key_provisioning_api` in `/api/capabilities`; - returns 403 when disabled. -- API keys track `last_used_at` (throttled to one write/minute) so stale keys - are visible before you revoke them. - -### Changed -- Auth now resolves bearer tokens against the new `ApiKey` model instead of the - single DRF `authtoken.Token`. Existing tokens are migrated 1:1 into named - ("default") keys with the same value — no client reconfiguration needed. -- Settings → Account no longer manages a single token; it links to API Keys. - Creating a user (Settings → Users) issues an initial named key. - -## [0.1.1] — 2026-09-03 - -### Fixed -- Deployment crash-loop: the vendored `gridjs.umd.js` referenced a - `gridjs.umd.js.map` sourcemap that isn't in the repo, so `collectstatic` - (strict manifest storage) failed on container start and v0.1.0 never came - up. Removed the dangling sourcemap trailer (debug-only, no runtime effect). - -### Added -- `GET /api/skill` serves the bundled agent-posting skill as Markdown (or - `?format=json`), with `DAYBOOK_URL` pre-filled to the instance's own - origin so the instructions are ready to hand to an AI. Advertised as - `skill_url` in `/api/capabilities`. - -### Changed -- Rewrote the posting skill (`skills/daybook-posting/SKILL.md`) to be - location-agnostic: no longer assumes a local source checkout, reframes - `DAYBOOK_URL` as set-your-instance, and adds a raw `curl` fallback. - -## [0.1.0] — 2026-09-03 - -First tagged release. - -### Artifacts & rendering -- Artifact kinds: `markdown`, `html_fragment`, `html_full`, `deck`, `json`, - `csv`, `image`, `pdf`, `binary`. -- `.xlsx` uploads convert server-side to a `csv` artifact (active sheet, - saved formula results). -- Markdown code blocks get syntax highlighting; tables (markdown & - html_fragment) render styled — monospace cells, sans-serif headers. -- `json`/`csv` render as an interactive collapsible tree / sortable table - (Grid.js + renderjson, vendored locally). -- Unified off-white document background; system sans-serif body text. - -### API -- `POST/GET/PATCH/DELETE /api/artifacts`, list filters (source/label/day/ - kind/q), `/api/assets` image upload, `/api/capabilities` (reports - `version`). Per-user bearer-token ingest; session-authed reads. -- Server-side validation: per-kind content shape, field length limits, - string-typed source fields, valid-JSON for `json`. - -### Sharing, quotas, security -- Per-artifact public share links (`/s/{token}`), owner-toggled, revocable. -- Per-user disk quotas with oldest-unkept auto-eviction and a Keep flag. -- Allowlist HTML sanitization (nh3) + `script-src 'none'` CSP for the themed - kinds; deep-JSON parse errors return 400 (not 500). - -### UI -- Multi-user auth; feed with source sidebar + label filtering; settings area - (Account, Storage, Demo Data, Users, About); light/dark toggle; app icon. - -### Deployment -- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a - runbook (`deploy/cloud_agent_deploy.md`) and health check. - -[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.7.0...HEAD -[0.7.0]: https://git.cygnus.systems/peter/daybook/compare/v0.6.3...v0.7.0 -[0.6.3]: https://git.cygnus.systems/peter/daybook/compare/v0.6.2...v0.6.3 -[0.6.2]: https://git.cygnus.systems/peter/daybook/compare/v0.6.1...v0.6.2 -[0.6.1]: https://git.cygnus.systems/peter/daybook/compare/v0.6.0...v0.6.1 -[0.6.0]: https://git.cygnus.systems/peter/daybook/compare/v0.5.1...v0.6.0 -[0.5.1]: https://git.cygnus.systems/peter/daybook/compare/v0.5.0...v0.5.1 -[0.5.0]: https://git.cygnus.systems/peter/daybook/compare/v0.4.1...v0.5.0 -[0.4.1]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...v0.4.1 -[0.4.0]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...v0.4.0 -[0.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2 -[0.3.1]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...v0.3.1 -[0.3.0]: https://git.cygnus.systems/peter/daybook/compare/v0.2.1...v0.3.0 -[0.2.1]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...v0.2.1 -[0.2.0]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...v0.2.0 -[0.1.1]: https://git.cygnus.systems/peter/daybook/compare/v0.1.0...v0.1.1 -[0.1.0]: https://git.cygnus.systems/peter/daybook/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index 86baea8..f612d1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Project -AgentFeed is a self-hosted, multi-user Django + Django REST Framework web app where AI agents, scripts, or people publish artifacts and each logged-in user browses their own time-ordered, label-filterable feed. +Daybook is a self-hosted, multi-user Django + Django REST Framework web app where AI agents, scripts, or people publish artifacts and each logged-in user browses their own time-ordered, label-filterable feed. The core product principle is: agents publish content, not files. The server owns IDs, styling, storage layout, dates, and normalization into browser-native HTML, image, or PDF outputs. @@ -11,11 +11,10 @@ The core product principle is: agents publish content, not files. The server own - Each user has a personal ingest API token (DRF `Token` model), viewable/regenerable from Settings → Account. - Require `source` in every artifact payload — a free-text label identifying what posted it (agent-profile, script, or person name). Ownership of the resulting artifact comes from whichever user's token authenticated the POST, not from the `source` value itself. - `source` and `tags` together form one unified **label** system: every artifact shows all its labels next to the title, and a single `label` filter/query param matches against either. -- Require login (Django session auth) for the browser feed/UI. The JSON API accepts a per-user bearer token for both ingest (POST/PATCH/DELETE) and artifact reads (GET list/detail), owner-scoped — an agent reads back with the same token it posts with; API requests never redirect to a browser login (JSON 403 instead). `GET /api/capabilities` and `GET /api/skill` are public (no user data), so agents can bootstrap before they have a key. +- Require login (Django session auth) for the whole app, including the feed and API reads. Ingest (POST/PATCH) stays per-user bearer-token authenticated, since agents don't have browser sessions. - Exception to the above: an artifact can be explicitly opted into public sharing (per-artifact, owner-toggled from the feed UI) via a high-entropy `share_token`, served unauthenticated at `GET /s/{share_token}`. Not exposed via the ingest API yet — UI-only. Turning sharing off immediately invalidates the old link. Images embedded in a shared artifact are also served unauthenticated while it's shared, so inline content doesn't break for a link recipient. - User accounts can be created from Settings → Users (superuser-only in-app page) or via Django admin. Regular users are not staff and cannot access `/admin/`. Creating a user via Settings → Users also issues their API token immediately, shown once. - `TagColor` (label color overrides) stays global/shared across all users. -- Deleting an artifact is a **soft-delete to Trash** (`deleted_at`), not a hard removal — recoverable via the "Deleted" sidebar folder (undelete), off-quota and eviction-protected while trashed, and purged permanently via "Empty Trash". `DELETE /api/artifacts/{id}` is soft too (idempotent); trashed artifacts are hidden from all API reads. A **Keep** sidebar folder is a view over `is_kept`. Both the Keep and Deleted folders are always shown in the sidebar (even when empty); the count badge only appears when the folder is non-empty. Trash auto-purges after a ~3-day retention window via a cron-less background thread triggered by deletes (`AGENTFEED_ASYNC_PURGE`, on by default); "Empty Trash" purges immediately. - Use random 8-character artifact IDs. - Start as a plain Django project; add Docker after core ingest and viewer work. - Preserve original markdown as `.md` alongside rendered `.html`. @@ -45,7 +44,7 @@ The core product principle is: agents publish content, not files. The server own - Use the local Django commands for normal iteration: `python manage.py check`, `python manage.py migrate`, and `python manage.py runserver`. - Run the local development server on `127.0.0.1:8001`. Port `8000` may be reserved or blocked on this workstation. - Always use this same fixed port for the dev server. Never start a second instance on a different port; if `8001` is already serving, reuse it rather than starting another. -- All artifact-posting tools (`skills/agentfeed-posting/scripts/agentfeed_post.py`, `scripts/seed_demo_artifacts.py`, and any future posting skill) must default `AGENTFEED_URL` to `http://127.0.0.1:8001` to match. +- All artifact-posting tools (`skills/daybook-posting/scripts/daybook_post.py`, `scripts/seed_demo_artifacts.py`, and any future posting skill) must default `DAYBOOK_URL` to `http://127.0.0.1:8001` to match. - A regular (non-superuser) user `test` / password `testpwd` must always exist in the local dev database — it's the permanent account a sibling project (`AGENT_DAYBOOK__LLM_TEST`) uses for API testing. If the local `db.sqlite3` is ever reset, recreate this exact user (e.g. via Settings → Users) before assuming that project is broken. - Do not deploy to the Proxmox NUC during routine code iteration. - Treat the NUC container as the local live/staging target. Deploy there only at explicit stable checkpoints, such as an end-of-day build. diff --git a/README.md b/README.md deleted file mode 100644 index a2bacf2..0000000 --- a/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# AgentFeed - -AgentFeed is a self-hosted, multi-user **artifact journal**. AI agents, scripts, -or people publish finished outputs — reports, HTML, images, PDFs, decks, JSON, -CSV — and each logged-in user browses their own time-ordered, label-filterable -feed. The core principle: **agents publish content, not files.** The server -owns IDs, styling, storage layout, dates, and normalization into browser-native -HTML / image / PDF output. - -Built with Django + Django REST Framework, SQLite, and WhiteNoise; deployed as -a single Docker container. - -## Quick start (local development) - -```bash -python -m venv .venv -.venv/Scripts/python -m pip install -r requirements.txt # Windows; use .venv/bin on POSIX -python manage.py migrate -python manage.py createsuperuser -python manage.py runserver 127.0.0.1:8001 -``` - -Then open and log in. - -- **Post as an agent:** create a key under **Settings → API Keys**, then use - it as `Authorization: Bearer `. Each user can hold several keys (one - per agent), each revocable on its own. -- **Health check / contract:** `GET /api/capabilities`. -- **Ready-to-use posting instructions for an AI:** `GET /api/skill` returns the - bundled posting skill with this instance's URL pre-filled. - -## Documentation - -| Doc | For | -|---|---| -| [`docs/agent_quickstart.md`](docs/agent_quickstart.md) | Shortest path to posting from an agent | -| [`docs/api.md`](docs/api.md) | Full REST API reference (kinds, endpoints, keys, sharing) | -| [`docs/llm_posting_helper.md`](docs/llm_posting_helper.md) | Using the bundled `agentfeed_post.py` helper | -| [`docs/deployment.md`](docs/deployment.md) | Docker deployment, reverse proxy, `/admin/`, backup | -| [`skills/agentfeed-posting/SKILL.md`](skills/agentfeed-posting/SKILL.md) | The agent-posting skill (also served at `/api/skill`) | -| [`CHANGELOG.md`](CHANGELOG.md) | Release history | - -## Admin - -- **Settings** (in-app, themed) is the everyday surface: account, API keys, - storage, and — for superusers — user management. -- **Django admin** at `/admin/` is an operator break-glass tool for raw-data - fixes. It requires `is_staff` (the `createsuperuser` account has it). See - [`docs/deployment.md`](docs/deployment.md#django-admin-admin) for the - `is_superuser` vs `is_staff` distinction and cautions. - -## Version - -The version is defined once in `daybook_project/__init__.py`, shown on -**Settings → About**, and reported by `GET /api/capabilities`. Releases are -git-tagged `vX.Y.Z`. diff --git a/artifacts/admin.py b/artifacts/admin.py index 347749d..0a0d7f2 100644 --- a/artifacts/admin.py +++ b/artifacts/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import ApiKey, Artifact, Asset, TagColor, UserProfile +from .models import Artifact, Asset, UserProfile @admin.register(Artifact) @@ -21,18 +21,3 @@ class AssetAdmin(admin.ModelAdmin): list_display = ("id", "owner", "artifact", "content_type", "size_bytes", "created_at") list_filter = ("owner", "content_type") search_fields = ("id",) - - -@admin.register(ApiKey) -class ApiKeyAdmin(admin.ModelAdmin): - list_display = ("name", "user", "key", "created_at", "last_used_at") - list_filter = ("user",) - search_fields = ("name", "key", "user__username") - # The key value and timestamps are system-managed; show but don't hand-edit. - readonly_fields = ("key", "created_at", "last_used_at") - - -@admin.register(TagColor) -class TagColorAdmin(admin.ModelAdmin): - list_display = ("name", "color") - search_fields = ("name",) diff --git a/artifacts/exception_handlers.py b/artifacts/exception_handlers.py index 205752b..fa09eba 100644 --- a/artifacts/exception_handlers.py +++ b/artifacts/exception_handlers.py @@ -2,7 +2,7 @@ from rest_framework.response import Response from rest_framework.views import exception_handler -def agentfeed_exception_handler(exc, context): +def daybook_exception_handler(exc, context): """DRF exception handler that additionally maps pathological-input crashes to a clean 400 instead of an unhandled 500. diff --git a/artifacts/forms.py b/artifacts/forms.py index 52f2d29..44f8e98 100644 --- a/artifacts/forms.py +++ b/artifacts/forms.py @@ -2,5 +2,5 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm -class AgentFeedLoginForm(AuthenticationForm): +class DaybookLoginForm(AuthenticationForm): remember_me = forms.BooleanField(required=False, initial=True) diff --git a/artifacts/migrations/0014_apikey.py b/artifacts/migrations/0014_apikey.py deleted file mode 100644 index 09e31ce..0000000 --- a/artifacts/migrations/0014_apikey.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 5.2.15 on 2026-09-04 09:35 - -import artifacts.models -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('artifacts', '0013_artifact_share_token'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='ApiKey', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('key', models.CharField(db_index=True, default=artifacts.models.generate_api_key, max_length=64, unique=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('last_used_at', models.DateTimeField(blank=True, null=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ['created_at'], - }, - ), - ] diff --git a/artifacts/migrations/0015_migrate_tokens_to_apikeys.py b/artifacts/migrations/0015_migrate_tokens_to_apikeys.py deleted file mode 100644 index 46bb6e7..0000000 --- a/artifacts/migrations/0015_migrate_tokens_to_apikeys.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.db import migrations - - -def copy_tokens_to_apikeys(apps, schema_editor): - """Carry every existing DRF authtoken over to a named ApiKey so keys issued - before the multi-key feature keep authenticating (incl. the permanent test - user and any deployed agents). Same key string -> no client reconfiguration.""" - Token = apps.get_model("authtoken", "Token") - ApiKey = apps.get_model("artifacts", "ApiKey") - for token in Token.objects.all(): - if ApiKey.objects.filter(key=token.key).exists(): - continue - api_key = ApiKey.objects.create(user_id=token.user_id, name="default", key=token.key) - # created_at is auto_now_add, so set it after the fact to preserve the - # original issue date (queryset.update bypasses auto_now_add). - ApiKey.objects.filter(pk=api_key.pk).update(created_at=token.created) - - -def noop(apps, schema_editor): - pass - - -class Migration(migrations.Migration): - - dependencies = [ - ("artifacts", "0014_apikey"), - ("authtoken", "0004_alter_tokenproxy_options"), - ] - - operations = [ - migrations.RunPython(copy_tokens_to_apikeys, noop), - ] diff --git a/artifacts/migrations/0016_artifact_deleted_at.py b/artifacts/migrations/0016_artifact_deleted_at.py deleted file mode 100644 index 0454f2f..0000000 --- a/artifacts/migrations/0016_artifact_deleted_at.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.15 on 2026-09-07 11:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('artifacts', '0015_migrate_tokens_to_apikeys'), - ] - - operations = [ - migrations.AddField( - model_name='artifact', - name='deleted_at', - field=models.DateTimeField(blank=True, db_index=True, null=True), - ), - ] diff --git a/artifacts/migrations/0017_label.py b/artifacts/migrations/0017_label.py deleted file mode 100644 index aedcc1a..0000000 --- a/artifacts/migrations/0017_label.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 5.2.15 on 2026-09-08 13:46 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('artifacts', '0016_artifact_deleted_at'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Label', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=40)), - ('color', models.CharField(max_length=20)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ['name'], - 'unique_together': {('user', 'name')}, - }, - ), - ] diff --git a/artifacts/models.py b/artifacts/models.py index dcba524..446454f 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -1,14 +1,7 @@ -import secrets - from django.conf import settings from django.db import models -def generate_api_key(): - """`dbk_` prefix (recognizable as a AgentFeed key) + 40 hex chars.""" - return "dbk_" + secrets.token_hex(20) - - class Artifact(models.Model): HTML_FRAGMENT = "html_fragment" HTML_FULL = "html_full" @@ -60,9 +53,6 @@ class Artifact(models.Model): metadata = models.JSONField(default=dict, blank=True) read_at = models.DateTimeField(null=True, blank=True) is_kept = models.BooleanField(default=False) - # Soft-delete: non-null = in Trash (hidden from feed/sources, off-quota, - # recoverable via undelete, purged by Empty Trash). NULL = live. - deleted_at = models.DateTimeField(null=True, blank=True, db_index=True) size_bytes = models.BigIntegerField(default=0) # NULL = not shared. Many rows share NULL (SQL treats each NULL as # distinct, so `unique=True` still holds); a real value must be unique @@ -77,10 +67,6 @@ class Artifact(models.Model): def is_shared(self): return bool(self.share_token) - @property - def is_deleted(self): - return self.deleted_at is not None - class Meta: ordering = ["-created_at"] @@ -96,23 +82,6 @@ class TagColor(models.Model): return f"{self.name} ({self.color})" -class Label(models.Model): - """A user's reusable label in their personal palette. Applying one from the - feed UI just adds its `name` to an artifact's `tags`; the chip colour lives - in the global TagColor table (kept in sync when the palette entry is saved).""" - - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="labels") - name = models.CharField(max_length=40) - color = models.CharField(max_length=20) # #RRGGBB - - class Meta: - unique_together = ("user", "name") - ordering = ["name"] - - def __str__(self): - return f"{self.name} ({self.color})" - - class UserProfile(models.Model): DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB @@ -123,28 +92,6 @@ class UserProfile(models.Model): return f"{self.user} quota={self.quota_bytes}" -class ApiKey(models.Model): - """A named, per-user API key for ingest. A user can hold several — one per - agent or script — and revoke them individually. Stored and shown in - plaintext: this is a self-hosted, single-operator app and keys are - viewable on demand by design. - """ - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_keys" - ) - name = models.CharField(max_length=100) - key = models.CharField(max_length=64, unique=True, default=generate_api_key, db_index=True) - created_at = models.DateTimeField(auto_now_add=True) - last_used_at = models.DateTimeField(null=True, blank=True) - - class Meta: - ordering = ["created_at"] - - def __str__(self): - return f"{self.name} ({self.user})" - - class Asset(models.Model): """An image uploaded for embedding inside an artifact's content. diff --git a/artifacts/storage.py b/artifacts/storage.py index df53526..1ac53cd 100644 --- a/artifacts/storage.py +++ b/artifacts/storage.py @@ -7,11 +7,9 @@ SUM rather than a stat of every file. """ import shutil -import threading from datetime import timedelta from django.conf import settings -from django.db import connection from django.db.models import F, Sum from django.utils import timezone @@ -72,13 +70,8 @@ def artifact_total_bytes(artifact): def user_usage_bytes(user): - # Trashed artifacts are off-quota (deleting relieves quota immediately); - # their files linger on disk until Empty Trash / purge, but don't count. artifact_bytes = ( - Artifact.objects.filter(owner=user, deleted_at__isnull=True) - .aggregate(total=Sum("size_bytes")) - .get("total") - or 0 + Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0 ) asset_bytes = ( Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0 @@ -120,10 +113,8 @@ def make_room_for(user, incoming_bytes): if used + incoming_bytes <= quota: return [] - # Trashed artifacts are eviction-protected (recovery window is guaranteed) - # and already off-quota, so they're never eviction candidates. unkept = list( - Artifact.objects.filter(owner=user, is_kept=False, deleted_at__isnull=True).order_by("created_at") + Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at") ) # Check whether eviction can possibly succeed before deleting anything - # otherwise a doomed request would still destroy every unkept artifact on @@ -146,55 +137,6 @@ def make_room_for(user, incoming_bytes): return evicted -def purge_trashed(user): - """Permanently remove all of a user's trashed artifacts (files + rows). - Backs the "Empty Trash" action.""" - trashed = list(Artifact.objects.filter(owner=user, deleted_at__isnull=False)) - for artifact in trashed: - delete_artifact_files(artifact) - artifact.delete() - return len(trashed) - - -TRASH_RETENTION = timedelta(days=3) - - -def purge_expired_trash(now=None): - """Permanently remove trashed artifacts older than the retention window - (all users). Tolerant of already-gone rows so concurrent purges are safe. - Returns the number purged.""" - now = now or timezone.now() - cutoff = now - TRASH_RETENTION - expired = list(Artifact.objects.filter(deleted_at__isnull=False, deleted_at__lt=cutoff)) - purged = 0 - for artifact in expired: - try: - delete_artifact_files(artifact) - artifact.delete() - purged += 1 - except Exception: - # Another concurrent purge got there first, or a file's already - # gone — a purge error must never surface to anyone. - pass - return purged - - -def spawn_trash_purge(): - """Run purge_expired_trash off the request thread (cron-less: triggered by - deletes). Falls back to inline when async is disabled (tests).""" - if not getattr(settings, "AGENTFEED_ASYNC_PURGE", True): - purge_expired_trash() - return - - def _run(): - try: - purge_expired_trash() - finally: - connection.close() # threads get their own connection; don't leak it - - threading.Thread(target=_run, daemon=True).start() - - ASSET_ORPHAN_GRACE = timedelta(hours=24) diff --git a/artifacts/templatetags/__init__.py b/artifacts/templatetags/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/artifacts/templatetags/agentfeed_extras.py b/artifacts/templatetags/agentfeed_extras.py deleted file mode 100644 index bb27bde..0000000 --- a/artifacts/templatetags/agentfeed_extras.py +++ /dev/null @@ -1,30 +0,0 @@ -from django import template - -register = template.Library() - -# A small fixed palette. A user's avatar color is picked deterministically from -# their username, so it stays stable across sessions and page loads. -_AVATAR_COLORS = [ - "#4a6ea8", - "#3f8f7b", - "#a3712a", - "#8a4f9e", - "#a3312a", - "#2f7d95", - "#6b7280", - "#7b6a3f", -] - - -@register.filter -def avatar_color(value): - text = str(value or "") - if not text: - return _AVATAR_COLORS[0] - return _AVATAR_COLORS[sum(ord(c) for c in text) % len(_AVATAR_COLORS)] - - -@register.filter -def avatar_initial(value): - text = str(value or "").strip() - return text[0].upper() if text else "?" diff --git a/artifacts/urls.py b/artifacts/urls.py index d7adc5d..98323e3 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -6,30 +6,18 @@ from . import views urlpatterns = [ path("", views.feed, name="feed"), path("feed/older", views.feed_older, name="feed-older"), - path("feed/updates", views.feed_updates, name="feed-updates"), path("a//read", views.mark_artifact_read, name="artifact-mark-read"), path("a//keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a//share", views.toggle_artifact_share, name="artifact-toggle-share"), - path("a//label", views.toggle_artifact_label, name="artifact-toggle-label"), - path("a//delete", views.delete_artifact, name="artifact-delete"), - path("a//undelete", views.undelete_artifact, name="artifact-undelete"), - path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"), - path("feed/mark-read", views.mark_all_read, name="feed-mark-read"), path("a/", views.artifact_view, name="artifact"), path("s/", views.shared_artifact_view, name="artifact-shared"), path("assets/", views.asset_view, name="asset"), path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"), path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"), - path("api/skill", views.SkillView.as_view(), name="skill-api"), - path("api/keys", views.ApiKeyListCreateView.as_view(), name="api-keys"), - path("api/keys/", views.ApiKeyDetailView.as_view(), name="api-key-detail"), path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), path("api/artifacts/", views.ArtifactDetailView.as_view(), name="artifact-detail-api"), - path("api/artifacts//raw", views.ArtifactRawView.as_view(), name="artifact-raw-api"), path("settings/", views.settings_home, name="settings-home"), path("settings/account/", views.settings_account, name="settings-account"), - path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"), - path("settings/labels/", views.settings_labels, name="settings-labels"), path("settings/users/", views.settings_users, name="settings-users"), path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"), path("settings/storage/", views.settings_storage, name="settings-storage"), diff --git a/artifacts/views.py b/artifacts/views.py index bd2fccc..555c121 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -15,29 +15,28 @@ import nh3 import openpyxl from django.conf import settings from django.contrib import messages -from django.contrib.auth import update_session_auth_hash, views as auth_views +from django.contrib.auth import views as auth_views from django.contrib.auth.decorators import login_required -from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.models import User from django.db import OperationalError, transaction -from django.db.models import Count, Max, Q, Sum +from django.db.models import Count, Q, Sum from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import get_object_or_404, redirect, render -from django.template.defaultfilters import date as date_filter, slugify -from django.template.loader import render_to_string +from django.template.defaultfilters import slugify from django.urls import reverse -from django.utils.dateparse import parse_date, parse_datetime +from django.utils.dateparse import parse_date from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.http import require_POST +from rest_framework.authtoken.models import Token from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView from .demo_data import create_demo_artifacts -from .forms import AgentFeedLoginForm -from .models import ApiKey, Artifact, Asset, Label, TagColor +from .forms import DaybookLoginForm +from .models import Artifact, Asset, TagColor from .storage import ( QuotaExceeded, SIDECAR_SUFFIXES, @@ -45,8 +44,6 @@ from .storage import ( delete_artifact_files, disk_free_bytes, make_room_for, - purge_trashed, - spawn_trash_purge, sweep_orphan_assets, total_allocated_bytes, user_quota_bytes, @@ -70,7 +67,7 @@ REMEMBER_ME_SESSION_AGE = 60 * 60 * 24 * 30 # 30 days MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB per uploaded image -# Matches the asset id in a AgentFeed asset URL, e.g. /assets/ab12cd34ef56 +# Matches the asset id in a Daybook asset URL, e.g. /assets/ab12cd34ef56 ASSET_URL_RE = re.compile(r"/assets/([0-9a-f]{12})") @@ -86,14 +83,12 @@ DEFAULT_THEMES = { } +@method_decorator(login_required, name="get") class ArtifactListCreateView(APIView): parser_classes = [JSONParser, FormParser, MultiPartParser] def get(self, request): - user = resolve_request_user(request) - if user is None: - return Response({"detail": "Authentication required (session or bearer token)."}, status=403) - artifacts = Artifact.objects.filter(owner=user, deleted_at__isnull=True) + artifacts = Artifact.objects.filter(owner=request.user) if source := request.query_params.get("source"): artifacts = artifacts.filter(source=source) if label := request.query_params.get("label"): @@ -229,12 +224,10 @@ def asset_view(request, asset_id): return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream") +@method_decorator(login_required, name="get") class ArtifactDetailView(APIView): def get(self, request, artifact_id): - user = resolve_request_user(request) - if user is None: - return Response({"detail": "Authentication required (session or bearer token)."}, status=403) - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True) + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) return Response(serialize_artifact(artifact, request)) def patch(self, request, artifact_id): @@ -242,7 +235,7 @@ class ArtifactDetailView(APIView): if user is None: return HttpResponseForbidden("Invalid or missing API token") - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True) + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) data = request.data not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys()) if not_editable: @@ -301,67 +294,21 @@ class ArtifactDetailView(APIView): if user is None: return HttpResponseForbidden("Invalid or missing API token") - # Soft-delete: move to Trash (recoverable, off-quota) rather than - # permanently removing. Purged later via Empty Trash. Idempotent. artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) - if artifact.deleted_at is None: - artifact.deleted_at = timezone.now() - artifact.save(update_fields=["deleted_at"]) - spawn_trash_purge() # sweep expired trash off the request thread + delete_artifact_files(artifact) + artifact.delete() return Response(status=204) -class ArtifactRawView(APIView): - """The artifact's full content, for read-back / verification. Same auth as - the detail endpoint (bearer token or session), owner-scoped. - - GET /api/artifacts/{id}/raw - - markdown / html_full / json / csv -> exact posted source (text/plain) - - image / pdf / binary -> the stored file bytes - - html_fragment / deck -> 404 (render-only; no raw kept) - """ - - def get(self, request, artifact_id): - user = resolve_request_user(request) - if user is None: - return Response({"detail": "Authentication required (session or bearer token)."}, status=403) - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True) - - raw_path = raw_artifact_path(artifact) - if raw_path and raw_path.exists(): - return FileResponse(raw_path.open("rb"), content_type="text/plain; charset=utf-8") - - if artifact.kind in {Artifact.IMAGE, Artifact.PDF, Artifact.BINARY}: - path = safe_artifact_path(artifact.path) - if path.exists(): - content_type, _ = mimetypes.guess_type(path.name) - return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream") - - return Response( - {"detail": "No raw content available for this kind (render-only)."}, status=404 - ) - - class CapabilitiesView(APIView): """Static capability info. Reachable by a logged-in session or a valid API token, so agents can check connectivity before posting. Exposes no user data.""" def get(self, request): - # Public: capability/contract info only, no user data. + if not request.user.is_authenticated and resolve_token_user(request) is None: + return HttpResponseForbidden("Login or a valid API token is required") from daybook_project import __version__ - methods = { - "/api/artifacts": ["GET", "POST"], - "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], - "/api/artifacts/{id}/raw": ["GET"], - "/api/assets": ["POST"], - "/api/capabilities": ["GET"], - "/api/skill": ["GET"], - } - if settings.AGENTFEED_KEY_PROVISIONING_API: - methods["/api/keys"] = ["GET", "POST"] - methods["/api/keys/{id}"] = ["DELETE"] - return Response( { "version": __version__, @@ -371,121 +318,15 @@ class CapabilitiesView(APIView): "raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL, Artifact.JSON, Artifact.CSV], "source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES], "auth": "bearer_token", - "methods": methods, - "skill_url": request.build_absolute_uri(reverse("skill-api")), - "key_provisioning_api": settings.AGENTFEED_KEY_PROVISIONING_API, + "methods": { + "/api/artifacts": ["GET", "POST"], + "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], + "/api/assets": ["POST"], + }, } ) -SKILL_MD_PATH = settings.BASE_DIR / "skills" / "agentfeed-posting" / "SKILL.md" - - -def render_posting_skill(origin): - """Return the bundled posting SKILL.md with its AGENTFEED_URL placeholder - rewritten to this instance's origin, so text handed to an agent already - points at the right server. Only the config-block assignment is touched; - the surrounding prose (which explains AGENTFEED_URL) is left alone.""" - text = SKILL_MD_PATH.read_text(encoding="utf-8") - return re.sub( - r"(?m)^(AGENTFEED_URL=)\S+", - lambda m: m.group(1) + origin, - text, - ) - - -class SkillView(APIView): - """Serve the bundled agent-posting skill (skills/agentfeed-posting/SKILL.md), - with AGENTFEED_URL pre-filled to this instance so a recipient posts to the - right server. Same auth as capabilities: a logged-in user can copy it, or - an agent can fetch it with its API token. - - GET /api/skill -> raw Markdown (text/markdown) - GET /api/skill?format=json -> {"version", "url", "skill"} - """ - - def get(self, request): - # Public: the skill is bootstrapping instructions (how to authenticate - # and post) and contains no secrets — an agent shouldn't need a key to - # learn how to use one. - origin = request.build_absolute_uri("/").rstrip("/") - try: - skill = render_posting_skill(origin) - except FileNotFoundError: - raise Http404("Posting skill is not bundled with this deployment") - - if request.GET.get("format") == "json": - from daybook_project import __version__ - - return Response({"version": __version__, "url": origin, "skill": skill}) - return HttpResponse(skill, content_type="text/markdown; charset=utf-8") - - -def serialize_api_key(api_key): - return { - "id": api_key.pk, - "name": api_key.name, - "key": api_key.key, - "created_at": api_key.created_at.isoformat(), - "last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None, - } - - -class ApiKeyListCreateView(APIView): - """Programmatic API-key management, for automated test installations that - provision their own agents. Gated by settings.AGENTFEED_KEY_PROVISIONING_API, - which is OFF in deployment (it hands out key material over the API). When - off, every method returns 403. Authenticated by session or an existing - bearer token, and only ever operates on that user's own keys. - - GET /api/keys -> list this user's keys (incl. key values) - POST /api/keys -> create {"name": ...} -> the new key - """ - - def _resolve_user(self, request): - if request.user.is_authenticated: - return request.user - return resolve_token_user(request) - - def get(self, request): - if not settings.AGENTFEED_KEY_PROVISIONING_API: - return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403) - user = self._resolve_user(request) - if user is None: - return HttpResponseForbidden("Login or a valid API token is required") - return Response([serialize_api_key(k) for k in user.api_keys.all()]) - - def post(self, request): - if not settings.AGENTFEED_KEY_PROVISIONING_API: - return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403) - user = self._resolve_user(request) - if user is None: - return HttpResponseForbidden("Login or a valid API token is required") - name = (request.data.get("name") or "").strip() - if not name: - return Response({"detail": "A non-empty 'name' is required."}, status=400) - api_key = ApiKey.objects.create(user=user, name=name) - return Response(serialize_api_key(api_key), status=201) - - -class ApiKeyDetailView(APIView): - """Revoke a single key by id, same gating and auth as the list/create view. - - DELETE /api/keys/{id} - """ - - def delete(self, request, key_id): - if not settings.AGENTFEED_KEY_PROVISIONING_API: - return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403) - user = request.user if request.user.is_authenticated else resolve_token_user(request) - if user is None: - return HttpResponseForbidden("Login or a valid API token is required") - deleted, _ = ApiKey.objects.filter(pk=key_id, user=user).delete() - if not deleted: - raise Http404("No such key") - return Response(status=204) - - FEED_WINDOW_DAYS = 7 @@ -523,40 +364,21 @@ def feed(request): selected_artifact_id = request.GET.get("artifact", "") preview_none = request.GET.get("preview") == "none" selected_view = "raw" if request.GET.get("view") == "raw" else "rendered" - selected_folder = request.GET.get("folder", "") - if selected_folder not in ("deleted", "keep"): - selected_folder = "" - # Live (non-trashed) artifacts drive the normal feed and the source sidebar. - live = Artifact.objects.filter(owner=request.user, deleted_at__isnull=True) - if selected_folder == "deleted": - # System folder: all trashed items, not source-scoped. - selected_source = "" - base = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False) - elif selected_folder == "keep": - selected_source = "" - base = live.filter(is_kept=True) - else: - base = live + all_artifacts = Artifact.objects.filter(owner=request.user) artifacts = apply_feed_filters( - base, selected_source, selected_label, selected_kind, title_query + all_artifacts, selected_source, selected_label, selected_kind, title_query ) - if selected_folder: - # System folders show everything (no 7-day window / infinite scroll). - window_start = today - window_artifacts = list(artifacts.order_by("-created_at")) - older_count = 0 - else: - window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1) - if not artifacts.filter(day__gte=window_start).exists(): - # Nothing recent for these filters: fall back to the most recent days - # that do match, so selecting a source is never met with a blank list. - recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS]) - if recent_days: - window_start = recent_days[-1] - window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at")) - older_count = artifacts.filter(day__lt=window_start).count() + window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1) + if not artifacts.filter(day__gte=window_start).exists(): + # Nothing recent for these filters: fall back to the most recent days that + # do match, so selecting a source is never met with a blank list. + recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS]) + if recent_days: + window_start = recent_days[-1] + window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at")) + older_count = artifacts.filter(day__lt=window_start).count() overrides = tag_color_overrides() day_groups = group_artifacts_by_day(window_artifacts, overrides) @@ -566,8 +388,6 @@ def feed(request): selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None) toggle_query = {"view": "rendered"} - if selected_folder: - toggle_query["folder"] = selected_folder if selected_source: toggle_query["source"] = selected_source if selected_label: @@ -582,22 +402,18 @@ def feed(request): toggle_query["view"] = "raw" raw_url = f"?{urlencode(toggle_query)}" - # Source counts + labels always come from live artifacts, so the sidebar is - # the same whichever folder you're viewing. + # Source counts come from the other filters only, so the sidebar still shows + # every source available under the current label/type/name filters. source_scoped = apply_feed_filters( - live, "", selected_label, selected_kind, title_query + all_artifacts, "", selected_label, selected_kind, title_query ) - # Sources ordered by most recent message first (Telegram-style); "All - # sources" is rendered separately and stays pinned at the top. source_counts = ( source_scoped.values("source") - .annotate(count=Count("id", filter=Q(read_at__isnull=True)), latest=Max("created_at")) - .order_by("-latest") + .annotate(count=Count("id", filter=Q(read_at__isnull=True))) + .order_by("source") ) - keep_count = live.filter(is_kept=True).count() - trash_count = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False).count() - all_labels = sorted({tag for tags in live.values_list("tags", flat=True) for tag in tags}) + all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags}) return render( request, "artifacts/feed.html", @@ -606,14 +422,8 @@ def feed(request): "day_groups": day_groups, "older_count": older_count, "window_start": window_start, - # Marker for live polling: newest artifact shown, else now. - "feed_since": (window_artifacts[0].created_at if window_artifacts else timezone.now()).isoformat(), "source_counts": source_counts, "total_count": source_scoped.filter(read_at__isnull=True).count(), - "keep_count": keep_count, - "trash_count": trash_count, - "selected_folder": selected_folder, - "in_trash": selected_folder == "deleted", "all_labels": labels_with_colors_list(all_labels, overrides), "raw_url": raw_url, "rendered_url": rendered_url, @@ -626,7 +436,6 @@ def feed(request): "today": today, "title_query": title_query, "kind_choices": Artifact.KIND_CHOICES, - "label_palette": request.user.labels.all(), }, ) @@ -641,31 +450,6 @@ def mark_artifact_read(request, artifact_id): return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()}) -@login_required -@require_POST -def toggle_artifact_label(request, artifact_id): - """Toggle a palette label on/off for one of the user's own messages. The - label name is written into/removed from the artifact's `tags` list (labels - are unified with tags). Only names in the user's palette are accepted.""" - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user, deleted_at__isnull=True) - name = (request.POST.get("name") or "").strip() - label = Label.objects.filter(user=request.user, name=name).first() - if label is None: - return JsonResponse({"error": "No such label in your palette."}, status=400) - tags = list(artifact.tags) - if name in tags: - tags.remove(name) - applied = False - else: - tags.append(name) - applied = True - artifact.tags = tags - artifact.save(update_fields=["tags"]) - # Keep the chip colour in sync with the palette (TagColor is global). - TagColor.objects.update_or_create(name=name, defaults={"color": label.color}) - return JsonResponse({"applied": applied, "name": name, "color": label.color, "tags": tags}) - - @login_required @require_POST def toggle_artifact_keep(request, artifact_id): @@ -692,58 +476,12 @@ def toggle_artifact_share(request, artifact_id): return JsonResponse({"id": artifact.id, "is_shared": artifact.is_shared, "share_url": share_url}) -@login_required -@require_POST -def delete_artifact(request, artifact_id): - """Soft-delete: move to Trash (recoverable, off-quota). Idempotent.""" - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) - if artifact.deleted_at is None: - artifact.deleted_at = timezone.now() - artifact.save(update_fields=["deleted_at"]) - spawn_trash_purge() # sweep expired trash off the request thread - return JsonResponse({"id": artifact.id, "deleted": True}) - - -@login_required -@require_POST -def undelete_artifact(request, artifact_id): - """Restore a trashed artifact back to the live feed.""" - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) - if artifact.deleted_at is not None: - artifact.deleted_at = None - artifact.save(update_fields=["deleted_at"]) - return JsonResponse({"id": artifact.id, "deleted": False}) - - -@login_required -@require_POST -def empty_trash(request): - """Permanently purge all of the user's trashed artifacts.""" - count = purge_trashed(request.user) - return JsonResponse({"purged": count}) - - -@login_required -@require_POST -def mark_all_read(request): - """Mark all of the user's unread (non-trashed) messages as read, optionally - scoped to one source. Backs the sidebar 'Mark all as read' action.""" - unread = Artifact.objects.filter( - owner=request.user, deleted_at__isnull=True, read_at__isnull=True - ) - source = request.POST.get("source", "") - if source: - unread = unread.filter(source=source) - marked = unread.update(read_at=timezone.now()) - return JsonResponse({"marked": marked, "source": source}) - - @login_required def feed_older(request): """Return the next page of older day-groups as an HTML fragment for infinite scroll.""" before = parse_date(request.GET.get("before", "")) or timezone.localdate() artifacts = apply_feed_filters( - Artifact.objects.filter(owner=request.user, deleted_at__isnull=True), + Artifact.objects.filter(owner=request.user), request.GET.get("source", ""), request.GET.get("label", ""), request.GET.get("kind", ""), @@ -777,58 +515,6 @@ def feed_older(request): ) -@login_required -def feed_updates(request): - """New artifacts since a timestamp, as row HTML for the feed to prepend live. - Owner-scoped and filter-aware — respects the source/label/kind/q the browser - is currently viewing, so a new post that doesn't match won't appear.""" - after = parse_datetime(request.GET.get("after", "") or "") - if after is None: - return JsonResponse({"count": 0}) - - selected_source = request.GET.get("source", "") - selected_label = request.GET.get("label", "") - selected_kind = request.GET.get("kind", "") - title_query = request.GET.get("q", "") - artifacts = apply_feed_filters( - Artifact.objects.filter(owner=request.user, deleted_at__isnull=True), - selected_source, - selected_label, - selected_kind, - title_query, - ).filter(created_at__gt=after).order_by("-created_at") - - new = list(artifacts[:100]) - if not new: - return JsonResponse({"count": 0}) - - overrides = tag_color_overrides() - for artifact in new: - artifact.label_chips = tags_with_colors(artifact.tags, overrides) - - html = render_to_string( - "artifacts/_artifact_rows.html", - { - "artifacts": new, - "selected_artifact": None, - "selected_source": selected_source, - "selected_label": selected_label, - "selected_kind": selected_kind, - "title_query": title_query, - "selected_view": "raw" if request.GET.get("view") == "raw" else "rendered", - }, - request=request, - ) - return JsonResponse( - { - "count": len(new), - "newest": new[0].created_at.isoformat(), - "today_label": date_filter(timezone.localdate(), "D, M j Y"), - "html": html, - } - ) - - @login_required @xframe_options_sameorigin def artifact_view(request, artifact_id): @@ -1056,7 +742,7 @@ def create_binary_artifact(data, request, user): # Allowlist for sanitizing the "themed" text kinds (markdown, deck, -# html_fragment) — kinds where AgentFeed, not the poster, owns presentation, so +# html_fragment) — kinds where Daybook, not the poster, owns presentation, so # they must never be a script-execution vector (issue #014). html_full is the # deliberate exception (verbatim, poster owns everything) and is NOT run # through this. The list keeps ordinary formatting + tables + code-highlight @@ -1161,14 +847,14 @@ def wrap_json_viewer(content, title, request): - -
+ +
@@ -1205,18 +891,18 @@ def wrap_csv_viewer(content, title, request): - -
+ +
@@ -1260,24 +946,9 @@ def resolve_token_user(request): if not key: return None try: - api_key = ApiKey.objects.select_related("user").get(key=key) - except ApiKey.DoesNotExist: + return Token.objects.select_related("user").get(key=key).user + except Token.DoesNotExist: return None - # Stamp last-used so stale keys are visible in the UI, but throttle the - # write to at most once a minute to avoid a DB write on every request. - now = timezone.now() - if api_key.last_used_at is None or (now - api_key.last_used_at).total_seconds() > 60: - ApiKey.objects.filter(pk=api_key.pk).update(last_used_at=now) - return api_key.user - - -def resolve_request_user(request): - """The acting user from either a browser session or a bearer API key. - Used by the JSON read endpoints so agents can read back with the same token - they post with (owner-scoped). None if neither authenticates.""" - if request.user.is_authenticated: - return request.user - return resolve_token_user(request) def normalize_tags(tags): @@ -1469,7 +1140,6 @@ def serialize_artifact(artifact, request): "url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])), "api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])), "raw_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"), - "api_raw_url": request.build_absolute_uri(reverse("artifact-raw-api", args=[artifact.id])), "raw_available": raw_artifact_path(artifact) is not None, "source": artifact.source, "title": artifact.title, @@ -1514,9 +1184,9 @@ def raw_artifact_path(artifact): return None -class AgentFeedLoginView(auth_views.LoginView): +class DaybookLoginView(auth_views.LoginView): template_name = "artifacts/login.html" - authentication_form = AgentFeedLoginForm + authentication_form = DaybookLoginForm def form_valid(self, form): response = super().form_valid(form) @@ -1534,94 +1204,16 @@ def settings_home(request): @login_required def settings_account(request): - if request.method == "POST": - password_form = PasswordChangeForm(request.user, request.POST) - if password_form.is_valid(): - user = password_form.save() - # Keep the current session valid — changing the password rotates the - # session auth hash, which would otherwise log the user out. - update_session_auth_hash(request, user) - messages.success(request, "Password changed.") - return redirect("settings-account") - else: - password_form = PasswordChangeForm(request.user) + token, _ = Token.objects.get_or_create(user=request.user) + if request.method == "POST" and request.POST.get("action") == "regenerate": + token.delete() + token = Token.objects.create(user=request.user) + messages.success(request, "API token regenerated.") + return redirect("settings-account") return render( request, "artifacts/settings_account.html", - { - "active_section": "account", - "key_count": request.user.api_keys.count(), - "password_form": password_form, - }, - ) - - -@login_required -def settings_api_keys(request): - if request.method == "POST": - action = request.POST.get("action") - if action == "create": - name = (request.POST.get("name") or "").strip() - if not name: - messages.error(request, "Give the key a name so you know which agent it's for.") - else: - ApiKey.objects.create(user=request.user, name=name) - messages.success(request, f"Created API key '{name}'.") - elif action == "revoke": - key_id = request.POST.get("key_id") or "" - deleted, _ = ApiKey.objects.filter(pk=key_id, user=request.user).delete() - if deleted: - messages.success(request, "API key revoked.") - else: - messages.error(request, "No such key.") - return redirect("settings-api-keys") - return render( - request, - "artifacts/settings_api_keys.html", - {"active_section": "api-keys", "api_keys": request.user.api_keys.all()}, - ) - - -LABEL_HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$") - - -@login_required -def settings_labels(request): - if request.method == "POST": - action = request.POST.get("action") - if action == "create": - name = (request.POST.get("name") or "").strip() - color = (request.POST.get("color") or "").strip() - if not name: - messages.error(request, "Give the label a name.") - elif not LABEL_HEX_RE.match(color): - messages.error(request, "Pick a colour (#RRGGBB).") - elif Label.objects.filter(user=request.user, name=name).exists(): - messages.error(request, f"You already have a label called '{name}'.") - else: - Label.objects.create(user=request.user, name=name, color=color) - TagColor.objects.update_or_create(name=name, defaults={"color": color}) - messages.success(request, f"Created label '{name}'.") - elif action == "update": - color = (request.POST.get("color") or "").strip() - label = Label.objects.filter(pk=request.POST.get("label_id") or "", user=request.user).first() - if label is None: - messages.error(request, "No such label.") - elif not LABEL_HEX_RE.match(color): - messages.error(request, "Pick a colour (#RRGGBB).") - else: - label.color = color - label.save(update_fields=["color"]) - TagColor.objects.update_or_create(name=label.name, defaults={"color": color}) - messages.success(request, f"Updated '{label.name}'.") - elif action == "delete": - deleted, _ = Label.objects.filter(pk=request.POST.get("label_id") or "", user=request.user).delete() - messages.success(request, "Label removed from your palette.") if deleted else messages.error(request, "No such label.") - return redirect("settings-labels") - return render( - request, - "artifacts/settings_labels.html", - {"active_section": "labels", "labels": request.user.labels.all()}, + {"token": token, "active_section": "account"}, ) @@ -1631,7 +1223,7 @@ def settings_users(request): return HttpResponseForbidden("Admin access required") created_user = None - created_key = None + created_token = None if request.method == "POST": username = request.POST.get("username", "").strip() password = request.POST.get("password", "") @@ -1645,7 +1237,7 @@ def settings_users(request): if grant_admin: created_user.is_superuser = True created_user.save(update_fields=["is_superuser"]) - created_key = ApiKey.objects.create(user=created_user, name="default") + created_token = Token.objects.create(user=created_user) messages.success(request, f"Created user '{username}'.") return render( @@ -1655,7 +1247,7 @@ def settings_users(request): "active_section": "users", "users": User.objects.order_by("username"), "created_user": created_user, - "created_key": created_key, + "created_token": created_token, }, ) @@ -1725,7 +1317,7 @@ def raw_unavailable_html(artifact):

Raw source unavailable

-

AgentFeed does not have preserved raw source for {html.escape(artifact.title)}.

+

Daybook does not have preserved raw source for {html.escape(artifact.title)}.

diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index a5bfd5e..29f4719 100644 --- a/daybook_project/__init__.py +++ b/daybook_project/__init__.py @@ -1,4 +1,4 @@ # Single source of truth for the app version. Bump this, commit, then tag the # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in # GET /api/capabilities ("version"). -__version__ = "0.7.1" +__version__ = "0.1.0" diff --git a/daybook_project/settings.py b/daybook_project/settings.py index a041730..ee85175 100644 --- a/daybook_project/settings.py +++ b/daybook_project/settings.py @@ -3,26 +3,12 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent -DATA_DIR = Path(os.environ.get("AGENTFEED_DATA_DIR", BASE_DIR)).resolve() +DATA_DIR = Path(os.environ.get("DAYBOOK_DATA_DIR", BASE_DIR)).resolve() SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me") DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"} ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()] -# Allow creating/listing/revoking API keys over the ingest API (POST /api/keys). -# Meant for automated test installations that provision their own agents. It -# exposes key material over the API, so it is DISABLED BY DEFAULT and must be -# turned on explicitly — set AGENTFEED_KEY_PROVISIONING_API=1 only on a throwaway -# test install. Never enable it in a real deployment. (Independent of DEBUG, so -# a misconfigured DEBUG=1 production box still won't expose it.) -AGENTFEED_KEY_PROVISIONING_API = os.environ.get( - "AGENTFEED_KEY_PROVISIONING_API", "" -).lower() in {"1", "true", "yes", "on"} - -# Purge expired Trash off the request thread (triggered by deletes, no cron). -# Tests override this to run inline so purge behavior is deterministic. -AGENTFEED_ASYNC_PURGE = True - # Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.): # Django otherwise thinks every request is plain HTTP, which breaks CSRF checks # on login/POST. The proxy must forward X-Forwarded-Proto: https. @@ -74,7 +60,7 @@ WSGI_APPLICATION = "daybook_project.wsgi.application" REST_FRAMEWORK = { # Maps deeply-nested-JSON RecursionErrors (raised in DRF's parser, before # view validation) to a 400 instead of an unhandled 500. See issue #015. - "EXCEPTION_HANDLER": "artifacts.exception_handlers.agentfeed_exception_handler", + "EXCEPTION_HANDLER": "artifacts.exception_handlers.daybook_exception_handler", } DATABASES = { diff --git a/daybook_project/urls.py b/daybook_project/urls.py index b08c354..4204d51 100644 --- a/daybook_project/urls.py +++ b/daybook_project/urls.py @@ -2,12 +2,12 @@ from django.contrib import admin from django.contrib.auth import views as auth_views from django.urls import include, path -from artifacts.views import AgentFeedLoginView +from artifacts.views import DaybookLoginView urlpatterns = [ path("admin/", admin.site.urls), - path("accounts/login/", AgentFeedLoginView.as_view(), name="login"), + path("accounts/login/", DaybookLoginView.as_view(), name="login"), path("accounts/logout/", auth_views.LogoutView.as_view(next_page="login"), name="logout"), path("", include("artifacts.urls")), ] diff --git a/deploy/cloud_agent_deploy.md b/deploy/cloud_agent_deploy.md index dfbde41..d90f5db 100644 --- a/deploy/cloud_agent_deploy.md +++ b/deploy/cloud_agent_deploy.md @@ -1,7 +1,7 @@ # Cloud Deployment Runbook For A Dedicated Deploy Agent This is written for a capable (Opus-class) agent deploying and operating -AgentFeed on a cloud VM. Treat it as a guide and a source of AgentFeed-specific +Daybook on a cloud VM. Treat it as a guide and a source of Daybook-specific facts, **not a rigid script** — you're expected to use judgment, adapt to the actual VM, and read the authoritative sources rather than follow steps blindly. The steps below are a sensible default path; deviate where the host @@ -25,7 +25,7 @@ disagree): Where a decision needs info you don't have (VM IP, domain, whether it's internet-facing, whether a reverse proxy/secondary volume exists), ask the operator once, then proceed autonomously for routine redeploys. The rest of -this file is the recommended path plus the non-obvious, AgentFeed-specific +this file is the recommended path plus the non-obvious, Daybook-specific gotchas (SSH port, CSRF-behind-proxy, bind address, data volume, healthcheck) that are easy to get wrong. @@ -37,9 +37,9 @@ a token tied to a full user account. 1. Generate an SSH key pair on the VM (or ask the operator for one to install): ```bash - ssh-keygen -t ed25519 -C "agentfeed-deploy-agent" -f ~/.ssh/agentfeed_deploy -N "" + ssh-keygen -t ed25519 -C "daybook-deploy-agent" -f ~/.ssh/daybook_deploy -N "" ``` -2. Ask the operator to add the **public** key (`~/.ssh/agentfeed_deploy.pub`) as +2. Ask the operator to add the **public** key (`~/.ssh/daybook_deploy.pub`) as a Deploy Key on the repo in Forgejo (repo → Settings → Deploy Keys → read-only is sufficient), or hand it to them directly — never ask them to give you their personal account credentials. @@ -52,7 +52,7 @@ a token tied to a full user account. cat >> ~/.ssh/config <<'EOF' Host git.cygnus.systems Port 2222 - IdentityFile ~/.ssh/agentfeed_deploy + IdentityFile ~/.ssh/daybook_deploy IdentitiesOnly yes EOF ``` @@ -112,12 +112,12 @@ Edit `deploy/.env`: DJANGO_DEBUG=0 DJANGO_SECRET_KEY= DJANGO_ALLOWED_HOSTS=,,localhost,127.0.0.1 -AGENTFEED_DATA_DIR=/data +DAYBOOK_DATA_DIR=/data DATABASE_PATH=/data/db.sqlite3 ARTIFACTS_ROOT=/data/artifacts_store ``` -There is no shared API token to set — AgentFeed issues a personal token per +There is no shared API token to set — Daybook issues a personal token per user from Settings → Account once a user exists (step 5). Never commit `deploy/.env`; it is already gitignored. @@ -131,17 +131,17 @@ if unsure which applies here. ## 4. Network Access **If this VM is reachable from the public internet, a reverse proxy with TLS -in front of AgentFeed is not optional — it's the only path.** Bearer tokens are +in front of Daybook is not optional — it's the only path.** Bearer tokens are this app's entire write-auth model, and they'd otherwise go over plaintext HTTP straight to a port on the public IP. Direct port exposure (below) is only for private/VPN-only networks with no public exposure. -- **Internet-facing (the common case):** set `AGENTFEED_BIND_ADDR=127.0.0.1` in +- **Internet-facing (the common case):** set `DAYBOOK_BIND_ADDR=127.0.0.1` in `deploy/.env` so the container port is not reachable from outside the VM at all, then open `80`/`443` in the VM's firewall/security group for the reverse proxy (see §8). Do not open `8000` externally. - **Private/VPN-only network:** open TCP `8000` to the addresses that should - reach it (never `0.0.0.0/0`), and leave `AGENTFEED_BIND_ADDR` unset. + reach it (never `0.0.0.0/0`), and leave `DAYBOOK_BIND_ADDR` unset. Before opening any port, check whether one is already open and in use — `ss -tlnp | grep -E ':80|:443|:8000'` — rather than assuming a clean slate. @@ -153,8 +153,8 @@ don't have API access to it yourself. ## 5. Build, Start, Create A User If the target has a separate, larger data volume than its main disk (common -on space-constrained VMs), point AgentFeed's persistent data at it before -starting: set `AGENTFEED_HOST_DATA_DIR=/path/on/that/volume` in `deploy/.env`. +on space-constrained VMs), point Daybook's persistent data at it before +starting: set `DAYBOOK_HOST_DATA_DIR=/path/on/that/volume` in `deploy/.env`. Ask the operator whether such a volume exists and where before defaulting to the main disk — filling the main disk on a host running other services is a bigger problem than it would be on a dedicated box. Create that same path @@ -162,9 +162,9 @@ bigger problem than it would be on a dedicated box. Create that same path unset) before starting the stack: ```bash -mkdir -p deploy/data # or the AGENTFEED_HOST_DATA_DIR path if you set one above +mkdir -p deploy/data # or the DAYBOOK_HOST_DATA_DIR path if you set one above docker compose -f deploy/docker-compose.yml up -d --build -docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py createsuperuser +docker compose -f deploy/docker-compose.yml exec daybook python manage.py createsuperuser ``` The entrypoint runs migrations and collects static files automatically on @@ -183,7 +183,7 @@ response when unauthenticated — it confirms the app is up. (Don't use container's own healthcheck hits the login page instead, which is 200 without auth.) This `curl` runs against `127.0.0.1` from on the VM itself, so it works regardless of whether -`AGENTFEED_BIND_ADDR` is set. Log in from a browser at whichever address is +`DAYBOOK_BIND_ADDR` is set. Log in from a browser at whichever address is actually externally reachable at this point in the runbook — `http://:8000/` if you're on the private/VPN-only path from §4, or `https:///` through the reverse proxy once §8 is done if you're on the internet-facing path — with @@ -202,52 +202,30 @@ Confirm the response includes `id`, `url`, and `api_url`, and that opening ## 7. Redeploy Loop (routine updates) -Releases are tagged `vX.Y.Z` (SemVer). Deploy a specific tag rather than a -moving branch, so what's live is always a known, named version — and note -the currently-deployed one before switching, for a clean rollback target: +This is the command you run every time you're asked to ship a new version: ```bash cd /opt/daybook -PREV_TAG=$(git describe --tags --exact-match 2>/dev/null || git rev-parse HEAD) # what's live now -git fetch --tags -git checkout v0.1.0 # the tag you were told to deploy (latest: git tag -l | sort -V | tail -1) +PREV_SHA=$(git rev-parse HEAD) +git pull docker compose -f deploy/docker-compose.yml up -d --build ``` -Then repeat the verification in step 6 (and confirm `/api/capabilities` -reports the `version` you expect). If it fails, roll back to the previous -version and report the failure (build log / migration error / failed health -check) rather than retrying blindly: +Then repeat the verification in step 6 (capabilities check at minimum). If it +fails: ```bash -git checkout "$PREV_TAG" +git checkout "$PREV_SHA" docker compose -f deploy/docker-compose.yml up -d --build ``` -Caveat: rollback reverts code, not data — a release that already applied a -DB migration may not cleanly downgrade against the migrated `db.sqlite3`. -For a routine forward release that's fine; if a rollback is needed after a -migration, restore the pre-deploy `deploy/data` backup (see §9) alongside -the code checkout. +to roll back, then report the failure (build log / migration error / failed +health check) back to the operator rather than retrying blindly. -`deploy/.env` and the data directory (wherever `AGENTFEED_HOST_DATA_DIR` points, +`deploy/.env` and the data directory (wherever `DAYBOOK_HOST_DATA_DIR` points, or `deploy/data` by default) are untracked, so `git pull` never touches them — no need to back them up before a routine redeploy. -**If the container crash-loops right after a build** (`docker compose ps` -shows `restarting`, health never goes green), the most likely cause is the -entrypoint's `collectstatic` step failing — check `docker logs agentfeed` -before assuming an env/network problem. A `whitenoise ... MissingFileError` -there means a static asset references a file that isn't present (this is what -bit v0.1.0). No data or schema impact; fix the asset in the repo, tag, and -redeploy. - -**If `git checkout ` refuses because of local working-tree changes** to -tracked files (e.g. a previous on-server hotfix), reset those tracked files -first — `git checkout -- ` for a specific one, or `git stash` / -`git reset --hard` to clear all tracked drift. This never touches `deploy/.env` -or the data directory, which are untracked and therefore safe. - ## 8. Hardening For Internet-Facing Deployments If this VM is reachable from the public internet (not just a private @@ -277,14 +255,14 @@ instead. Apache example: ```apache - ServerName agentfeed.example.com + ServerName daybook.example.com ProxyPreserveHost On RequestHeader set X-Forwarded-Proto "https" ProxyPass / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/ SSLEngine on - SSLCertificateFile /etc/letsencrypt/live/agentfeed.example.com/fullchain.pem - SSLCertificateKeyFile /etc/letsencrypt/live/agentfeed.example.com/privkey.pem + SSLCertificateFile /etc/letsencrypt/live/daybook.example.com/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/daybook.example.com/privkey.pem ``` @@ -296,7 +274,7 @@ Only reach for a standalone proxy like Caddy if the target genuinely has nothing on 80/443 yet — it still needs the same header: ```text -agentfeed.example.com { +daybook.example.com { reverse_proxy 127.0.0.1:8000 { header_up X-Forwarded-Proto https } @@ -305,15 +283,15 @@ agentfeed.example.com { ## 9. Backup -If you set `AGENTFEED_HOST_DATA_DIR` in step 5, the real data lives there, not +If you set `DAYBOOK_HOST_DATA_DIR` in step 5, the real data lives there, not at `deploy/data` — back up whichever path is actually configured, read straight from `deploy/.env` rather than assumed, so this keeps working whether or not that override is set: ```bash cd /opt/daybook -DATA_DIR="$(grep -oP '(?<=^AGENTFEED_HOST_DATA_DIR=).*' deploy/.env || echo deploy/data)" -tar -czf "/tmp/agentfeed-data-$(date +%Y%m%d-%H%M%S).tar.gz" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")" +DATA_DIR="$(grep -oP '(?<=^DAYBOOK_HOST_DATA_DIR=).*' deploy/.env || echo deploy/data)" +tar -czf "/tmp/daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")" ``` Copy the resulting archive off the VM (to object storage, the operator's @@ -331,12 +309,12 @@ Before reporting the deployment complete, confirm all of these: public HTTPS origin, and the proxy forwards `X-Forwarded-Proto: https`. Verify by actually logging in through the proxy, not just curling `/api/capabilities` — CSRF only bites on POST (login, artifact writes). -- [ ] `docker compose -f deploy/docker-compose.yml ps` shows `agentfeed` healthy. +- [ ] `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy. - [ ] `/api/capabilities` returns `403` unauthenticated, `200` with a token. - [ ] At least one superuser exists with a working personal API token. - [ ] A smoke-test artifact posts and renders successfully. - [ ] If internet-facing: TLS is in front of the app, not raw HTTP on `8000`. -- [ ] A backup of the data directory (`deploy/data`, or `AGENTFEED_HOST_DATA_DIR` +- [ ] A backup of the data directory (`deploy/data`, or `DAYBOOK_HOST_DATA_DIR` if you set it) has been taken and copied off the VM. - [ ] If the target host has its own service-inventory, monitoring, or changelog conventions for new services, they've been followed — ask diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f3a3376..cbb276c 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,4 +1,4 @@ -# ${VAR} substitution below (AGENTFEED_BIND_ADDR, AGENTFEED_HOST_DATA_DIR) reads +# ${VAR} substitution below (DAYBOOK_BIND_ADDR, DAYBOOK_HOST_DATA_DIR) reads # from deploy/.env because Compose resolves .env relative to *this file's* # directory, not the caller's cwd. That relies on being invoked as # `docker compose -f deploy/docker-compose.yml` from the repo root, per @@ -7,20 +7,20 @@ # the defaults (0.0.0.0 bind, ./data) would be a security regression, not # just a broken build. services: - agentfeed: + daybook: build: context: .. dockerfile: deploy/Dockerfile - container_name: agentfeed + container_name: daybook restart: unless-stopped env_file: - .env mem_limit: 384m mem_reservation: 128m ports: - - "${AGENTFEED_BIND_ADDR:-0.0.0.0}:8000:8000" + - "${DAYBOOK_BIND_ADDR:-0.0.0.0}:8000:8000" volumes: - - ${AGENTFEED_HOST_DATA_DIR:-./data}:/data + - ${DAYBOOK_HOST_DATA_DIR:-./data}:/data healthcheck: # Hit the login page, not /api/capabilities: capabilities returns 403 # when unauthenticated, and `curl --fail` treats 403 as an error, which diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index aaea9ca..cdd620d 100644 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -1,7 +1,7 @@ #!/usr/bin/env sh set -eu -mkdir -p "${AGENTFEED_DATA_DIR:-/data}" "${ARTIFACTS_ROOT:-/data/artifacts_store}" +mkdir -p "${DAYBOOK_DATA_DIR:-/data}" "${ARTIFACTS_ROOT:-/data/artifacts_store}" python manage.py migrate --noinput python manage.py collectstatic --noinput diff --git a/deploy/env.example b/deploy/env.example index 6c034a9..5fe9ca3 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -1,26 +1,21 @@ DJANGO_DEBUG=0 DJANGO_SECRET_KEY=replace-with-a-long-random-secret DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-vm-ip,your-domain.example -# Required if AgentFeed sits behind a TLS-terminating reverse proxy (Apache, +# Required if Daybook sits behind a TLS-terminating reverse proxy (Apache, # Nginx, Traefik, a cloud load balancer). Without it, login/POST fails with # "CSRF verification failed" because Django can't tell the original request # was HTTPS. Set to the public HTTPS origin(s) users actually visit: DJANGO_CSRF_TRUSTED_ORIGINS=https://your-domain.example -# Disabled by default. When on, POST /api/keys can create/list/revoke API keys -# over the network (it returns key material), which is only appropriate for -# automated test installations. Leave unset in deployment; set to 1 only on a -# throwaway test box. -# AGENTFEED_KEY_PROVISIONING_API=0 -AGENTFEED_DATA_DIR=/data +DAYBOOK_DATA_DIR=/data DATABASE_PATH=/data/db.sqlite3 ARTIFACTS_ROOT=/data/artifacts_store # Optional, read by deploy/docker-compose.yml directly (not by the app): -# AGENTFEED_BIND_ADDR=127.0.0.1 +# DAYBOOK_BIND_ADDR=127.0.0.1 # Uncomment and set to 127.0.0.1 when a reverse proxy on the same host will # front this service — this stops the container port from being reachable # directly from the network. Leave unset (binds all interfaces) only for # private/VPN-only deployments with no proxy in front. -# AGENTFEED_HOST_DATA_DIR=/mnt/some-larger-volume/agentfeed-data +# DAYBOOK_HOST_DATA_DIR=/mnt/some-larger-volume/daybook-data # Uncomment and point at a path on a separate/larger volume if the host's # main disk is space-constrained. Defaults to ./data next to this file. diff --git a/docs/agent_quickstart.md b/docs/agent_quickstart.md index 29339ac..d6ba619 100644 --- a/docs/agent_quickstart.md +++ b/docs/agent_quickstart.md @@ -1,12 +1,11 @@ # Agent Quickstart -This is the shortest path for an agent to publish and retrieve artifacts from AgentFeed. +This is the shortest path for an agent to publish and retrieve artifacts from Daybook. -## 0. Get Your API Key +## 0. Get Your API Token -Log in to the app in a browser and create a personal key under **Settings → API Keys** -(`http://127.0.0.1:8001/settings/api-keys/`). Name it after the agent so you can revoke it -later. Every artifact you post with it lands in your +Log in to the app in a browser and copy your personal token from **Settings → Account** +(`http://127.0.0.1:8001/settings/account/`). Every artifact you post with it lands in your feed, and only yours. ## 1. Check Capabilities @@ -15,7 +14,7 @@ Also confirms your token is valid: ```powershell Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" ` - -Headers @{ Authorization = "Bearer " } + -Headers @{ Authorization = "Bearer " } ``` ## 2. Publish Markdown @@ -39,7 +38,7 @@ $body = @{ $artifact = Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts" ` -Method Post ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body @@ -69,7 +68,7 @@ $body = @{ Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts" ` -Method Post ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body ``` @@ -78,7 +77,7 @@ Invoke-RestMethod ` ```powershell curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` - -H "Authorization: Bearer " ` + -H "Authorization: Bearer " ` -F "source=orion" ` -F "source_type=agent" ` -F "source_name=Orion Agent" ` @@ -120,7 +119,7 @@ $body = @{ Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" ` -Method Patch ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body ``` @@ -131,7 +130,7 @@ Invoke-RestMethod ` Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" ` -Method Delete ` - -Headers @{ Authorization = "Bearer " } + -Headers @{ Authorization = "Bearer " } ``` Deletes the artifact's database row and its files. You can only delete artifacts your own token owns. diff --git a/docs/api.md b/docs/api.md index 413a238..8d95c6f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,6 @@ -# AgentFeed API +# Daybook API -AgentFeed is built around agents publishing artifacts. Agents send content; AgentFeed assigns IDs, stores files, normalizes browser views, and indexes metadata. +Daybook is built around agents publishing artifacts. Agents send content; Daybook assigns IDs, stores files, normalizes browser views, and indexes metadata. Base URL while running locally: @@ -8,23 +8,19 @@ Base URL while running locally: http://127.0.0.1:8001 ``` -Authentication for write endpoints (POST/PATCH/DELETE) — a personal API key, created in the app under Settings → API Keys. Each user can hold several (one per agent/script) and revoke them individually: +Authentication for write endpoints (POST/PATCH) — a personal per-user API token, from Settings → Account in the app: ```http -Authorization: Bearer +Authorization: Bearer ``` -Every artifact belongs to the user whose key authenticated the POST — feeds are private per user. +Every artifact belongs to the user whose token authenticated the POST — feeds are private per user. -Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) accept -**either** a bearer token **or** a logged-in browser session, and are owner-scoped — a -token reads only its own user's artifacts (the same rule POST/PATCH/DELETE enforce). So an -agent can read back what it posted with the same token. Unauthenticated API requests get a -JSON `403`, never a browser-login redirect. +Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a +logged-in browser session, not a bearer token — visit the app in a browser and log in first. -`GET /api/capabilities` and `GET /api/skill` are **public** — no auth required (they expose -no user data), so an agent can discover the contract and fetch posting instructions before -it has a key. +`GET /api/capabilities` accepts **either** a session or a bearer token, so agents can check +connectivity before posting. ## Artifact Kinds @@ -32,7 +28,7 @@ Currently supported kinds: | Kind | Upload type | Rendered behavior | Raw support | |---|---|---|---| -| `html_fragment` | JSON text | Wrapped with a AgentFeed theme; tables styled automatically; **HTML sanitized** | No | +| `html_fragment` | JSON text | Wrapped with a Daybook theme; tables styled automatically; **HTML sanitized** | No | | `html_full` | JSON text | Stored as complete HTML, verbatim — no theme, styling, or sanitization | Yes | | `markdown` | JSON text | Rendered to HTML (code blocks syntax-highlighted, tables styled), **sanitized**, original `.md` preserved | Yes | | `deck` | JSON text | Wrapped as a simple HTML slide/deck page; **HTML sanitized** | No | @@ -108,7 +104,7 @@ Endpoint: ```http POST /api/artifacts Content-Type: application/json -Authorization: Bearer +Authorization: Bearer ``` Required fields: @@ -188,7 +184,7 @@ $body = @{ Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts" ` -Method Post ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body ``` @@ -241,7 +237,7 @@ Endpoint: ```http POST /api/artifacts Content-Type: multipart/form-data -Authorization: Bearer +Authorization: Bearer ``` Required fields: @@ -288,7 +284,7 @@ PowerShell/curl example: ```powershell curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` - -H "Authorization: Bearer " ` + -H "Authorization: Bearer " ` -F "source=orion" ` -F "source_type=agent" ` -F "source_name=Orion Agent" ` @@ -305,7 +301,7 @@ artifact), upload it first and reference the returned URL in your content. ```http POST /api/assets Content-Type: multipart/form-data -Authorization: Bearer +Authorization: Bearer ``` | Field | Notes | @@ -347,7 +343,7 @@ Endpoint: GET /api/artifacts ``` -Accepts a bearer token or a browser session. Returns only the authenticated user's artifacts. +Requires a logged-in browser session. Returns only the current user's artifacts. Supported filters (combine freely; all are ANDed): @@ -392,7 +388,7 @@ Endpoint: ```http PATCH /api/artifacts/{id} Content-Type: application/json -Authorization: Bearer +Authorization: Bearer ``` Editable fields: @@ -438,7 +434,7 @@ $body = @{ Invoke-RestMethod ` -Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" ` -Method Patch ` - -Headers @{ Authorization = "Bearer " } ` + -Headers @{ Authorization = "Bearer " } ` -ContentType "application/json" ` -Body $body ``` @@ -449,50 +445,44 @@ Endpoint: ```http DELETE /api/artifacts/{id} -Authorization: Bearer +Authorization: Bearer ``` -**Soft-delete:** moves the artifact to Trash rather than removing it — it's -recoverable (from the app's "Deleted" folder), becomes off-quota, and is hidden -from all API reads (`GET /api/artifacts`, the detail, and `…/raw` all treat it -as gone). Files stay on disk until the trash is purged (Empty Trash in the app). -Returns `204` with no body. +Removes the artifact's database row and its files (primary file, markdown +sidecar, and any attached assets). Returns `204` with no body on success. -**Idempotent:** deleting an already-trashed id returns `204` again (not `404`). -You can only delete artifacts your own token owns; another user's returns `404`. -There is no API endpoint to undelete or purge yet — that's app-UI-only. +You can only delete artifacts your own token owns; others return `404`, as +does a second delete of the same id. ```bash curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \ - -H "Authorization: Bearer " + -H "Authorization: Bearer " ``` ## Rendered And Raw Content -**For agents (bearer token):** fetch the full content from the API — separate -from the metadata `GET /api/artifacts/{id}`: +Rendered browser view: ```http -GET /api/artifacts/{id}/raw +GET /a/{id} ``` -Same auth as the detail endpoint (bearer token or session), owner-scoped, so an -agent can read back and verify exactly what it posted. The metadata response -includes `api_raw_url` pointing here. +Raw/source view: -- `markdown` / `html_full` / `json` / `csv` → the exact posted source, as - `text/plain`. -- `image` / `pdf` / `binary` → the stored file bytes (with its content type). -- `html_fragment` / `deck` → `404` (render-only; no raw source is kept). +```http +GET /a/{id}?raw=1 +``` -Byte-faithful to what's stored; note the deploy (Linux) preserves `\n` line -endings. +Raw currently exists for: -**For browsers (session):** the human viewer at `GET /a/{id}` (rendered) and -`GET /a/{id}?raw=1` (source) is session-authed; use the API `…/raw` endpoint -above for token access. +| Kind | Raw content | +|---|---| +| `markdown` | Preserved `.md` source. | +| `html_full` | Stored HTML file served as text. | +| `json` | Preserved original JSON text. | +| `csv` | Preserved original CSV text. | -If raw content is unavailable, AgentFeed returns a small HTML fallback page. +If raw content is unavailable, Daybook returns a small HTML fallback page. ## Sharing @@ -513,7 +503,7 @@ GET /s/{share_token} with the link can view it. - Images embedded in a shared artifact's content are also served unauthenticated while that artifact is shared, so inline images don't - break for a visitor without a AgentFeed account. + break for a visitor without a Daybook account. - `is_shared` and `share_url` (both in every artifact response — see `GET /api/artifacts/{id}`) report current sharing status; `share_url` is `null` when not shared. @@ -539,84 +529,25 @@ Example response: "methods": { "/api/artifacts": ["GET", "POST"], "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], - "/api/assets": ["POST"], - "/api/capabilities": ["GET"], - "/api/skill": ["GET"] - }, - "skill_url": "http://127.0.0.1:8001/api/skill" + "/api/assets": ["POST"] + } } ``` This is generated from the server's actual configuration, so it's the authoritative source if this document and a running instance ever disagree. -Agents should call this endpoint if they need to adapt to the current AgentFeed instance, or -simply to check the instance is reachable. It's public — no auth required: +Agents should call this endpoint if they need to adapt to the current Daybook instance, or +simply to check the instance is reachable and their token is valid. Unlike the other read +endpoints, it accepts a bearer token as well as a browser session: ```bash -curl http://127.0.0.1:8001/api/capabilities -``` - -## Posting Skill - -Endpoint: - -```http -GET /api/skill -GET /api/skill?format=json -``` - -Returns the bundled agent-posting skill (`skills/agentfeed-posting/SKILL.md`) — -the same instructions you'd give an AI so it knows how to publish to this -instance. The server rewrites the `AGENTFEED_URL` line to **this instance's own -origin**, so the text a caller receives already points at the right server; -a recipient only has to fill in their `AGENTFEED_TOKEN`. - -**Public** — no auth required (like capabilities), so an agent can fetch its -posting instructions before it has a key. The default response is raw Markdown -(`text/markdown`); add `?format=json` for `{"version", "url", "skill"}`. - -```bash -# Fetch the ready-to-use instructions for this instance (no auth needed) -curl http://127.0.0.1:8001/api/skill -``` - -Discover it programmatically via `skill_url` in the capabilities response. - -## API Keys (provisioning) - -Keys are normally created and revoked in the app under **Settings → API Keys**. -For automated test installations, a programmatic surface can provision them -too — but it is **gated** and **off in deployment**, because it returns key -material over the API. - -The gate is `AGENTFEED_KEY_PROVISIONING_API`. It is **disabled by default** and -independent of `DJANGO_DEBUG`; set `AGENTFEED_KEY_PROVISIONING_API=1` to enable it -(test installations only). `GET /api/capabilities` reports the current state as -`key_provisioning_api`, and only lists the `/api/keys` methods when it's -enabled. When disabled, every method below returns `403`. - -```http -GET /api/keys # list this user's keys (incl. key values) -POST /api/keys # body {"name": "hermes-agent"} -> the new key -DELETE /api/keys/{id} # revoke one key -``` - -Authenticated by a browser session **or** an existing bearer key, and only -ever operates on the authenticated user's own keys. - -```bash -# Create a new named key (test installations only) -curl -X POST -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{"name": "hermes-agent"}' \ - http://127.0.0.1:8001/api/keys +curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/capabilities ``` ## Current MVP Constraints -- Each user can hold several personal API keys (Settings → API Keys), each - individually revocable; there is no shared key. +- Each user has a personal ingest API token; there is no shared token. - Reader/browser access requires login; feeds are private per user (except per-artifact public share links — see Sharing above). - Content modification/revisions are deferred (`PATCH` edits metadata only, diff --git a/docs/deployment.md b/docs/deployment.md index 27d1fad..aafbfad 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,6 +1,6 @@ # Deployment -AgentFeed deploys the same way on any Linux host with Docker: a cloud VM, a home +Daybook deploys the same way on any Linux host with Docker: a cloud VM, a home server, a Proxmox guest, whatever. It runs as a single Docker container (Gunicorn) with all persistent state — the SQLite database and artifact files — on a bind-mounted `./data` directory outside the image. @@ -8,7 +8,7 @@ on a bind-mounted `./data` directory outside the image. ```text Target host (cloud VM, home server, ...) Docker Engine - agentfeed container + daybook container /app application code baked into the image /data persistent volume: db.sqlite3, artifacts_store/ ``` @@ -17,8 +17,8 @@ Target host (cloud VM, home server, ...) | File | Purpose | |---|---| -| `Dockerfile` | Builds the AgentFeed image. | -| `docker-compose.yml` | Runs AgentFeed with a persistent `./data:/data` bind mount. | +| `Dockerfile` | Builds the Daybook image. | +| `docker-compose.yml` | Runs Daybook with a persistent `./data:/data` bind mount. | | `entrypoint.sh` | Runs migrations, collects static files, then starts Gunicorn. | | `env.example` | Template for deployment environment variables. | @@ -59,15 +59,14 @@ Edit `deploy/.env` and set at minimum: DJANGO_DEBUG=0 DJANGO_SECRET_KEY= DJANGO_ALLOWED_HOSTS=,localhost,127.0.0.1 -AGENTFEED_DATA_DIR=/data +DAYBOOK_DATA_DIR=/data DATABASE_PATH=/data/db.sqlite3 ARTIFACTS_ROOT=/data/artifacts_store ``` -There is no shared API token to configure — AgentFeed uses per-user API keys -created under Settings → API Keys once a user exists (each user can hold -several, one per agent, each individually revocable). Do not commit -`deploy/.env`. +There is no shared API token to configure — Daybook uses per-user tokens +(DRF `Token` model) issued from Settings → Account once a user exists. Do not +commit `deploy/.env`. Create the persistent data directory and bring the stack up: @@ -77,10 +76,10 @@ docker compose -f deploy/docker-compose.yml up -d --build ``` Create your first login (superuser) so you have something to log in with and -a personal API key to post through: +a personal API token to post through: ```bash -docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py createsuperuser +docker compose -f deploy/docker-compose.yml exec daybook python manage.py createsuperuser ``` ## Verify Deployment @@ -95,18 +94,18 @@ that's expected; it confirms the app is up and enforcing auth. (Don't add `curl -f` — it treats the 403 as an error. The container healthcheck uses the login page, which is 200 without auth.) Open `http://:8000/` in a browser and log in with the superuser you just -created, then create a personal key under Settings → API Keys and use it for a +created, then grab a personal token from Settings → Account and use it for a smoke-test post: ```bash curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \ - -H "Authorization: Bearer " \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "source": "deploy-smoke", "kind": "markdown", "title": "Deployment Smoke Test", - "content": "# Deployment Smoke Test\n\nAgentFeed accepted an artifact." + "content": "# Deployment Smoke Test\n\nDaybook accepted an artifact." }' ``` @@ -127,68 +126,37 @@ picked up automatically. `deploy/.env` and `deploy/data/` are untouched by ## Operational Commands ```bash -docker compose -f deploy/docker-compose.yml logs -f agentfeed -docker compose -f deploy/docker-compose.yml restart agentfeed +docker compose -f deploy/docker-compose.yml logs -f daybook +docker compose -f deploy/docker-compose.yml restart daybook docker compose -f deploy/docker-compose.yml down docker compose -f deploy/docker-compose.yml up -d -docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py check -docker compose -f deploy/docker-compose.yml exec agentfeed sh +docker compose -f deploy/docker-compose.yml exec daybook python manage.py check +docker compose -f deploy/docker-compose.yml exec daybook sh ``` -## Django Admin (`/admin/`) - -AgentFeed ships Django's built-in admin at `/admin/` as an operator -break-glass tool — for raw-data fixes: reset a password, inspect or delete -any user's artifacts, adjust a quota, audit or revoke API keys. It is -separate from the in-app **Settings** area, which is the everyday product -surface (see the in-app guide / `docs/` for normal use). - -**Logging in:** open `/admin/` and sign in with an account that has -`is_staff = True`. The account you create with `createsuperuser` has it (plus -full superuser rights). Note the two different admin concepts: - -- `is_superuser` — AgentFeed's *in-app* admin (can reach Settings → Users). -- `is_staff` — can log into *Django's* `/admin/` site. - -The `createsuperuser` account has **both**. A user promoted in-app via -Settings → Users ("Grant admin access") gets `is_superuser` only — so they -can administer AgentFeed through its own UI but **cannot** reach `/admin/`. -Regular users have neither. To grant someone `/admin/` access, set staff -explicitly: - -```bash -docker compose -f deploy/docker-compose.yml exec agentfeed \ - python manage.py shell -c "from django.contrib.auth.models import User; u=User.objects.get(username='NAME'); u.is_staff=True; u.save()" -``` - -**Caution:** the admin edits database rows directly and bypasses app logic — -e.g. deleting an artifact there does not clean up its files on disk the way -the app's own delete does. Use it for inspection and targeted fixes, not -routine management. - ## Backup And Restore Back up `deploy/data/` — it contains the SQLite database and all artifact files: ```bash -tar -czf agentfeed-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data +tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data ``` For a consistent snapshot during heavy writes, stop the container first: ```bash docker compose -f deploy/docker-compose.yml down -tar -czf agentfeed-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data +tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data docker compose -f deploy/docker-compose.yml up -d ``` -To restore: stop AgentFeed, replace `deploy/data/` with the backup contents, +To restore: stop Daybook, replace `deploy/data/` with the backup contents, then start it again. ## Reverse Proxy Notes -If putting AgentFeed behind Nginx, Caddy, Traefik, or a cloud load balancer: +If putting Daybook behind Nginx, Caddy, Traefik, or a cloud load balancer: - Proxy to `http://:8000`. - Add the public hostname to `DJANGO_ALLOWED_HOSTS`. @@ -200,8 +168,8 @@ If putting AgentFeed behind Nginx, Caddy, Traefik, or a cloud load balancer: terminates TLS and forwards plain HTTP to the container. ```text -DJANGO_ALLOWED_HOSTS=agentfeed.example.com,localhost,127.0.0.1 -DJANGO_CSRF_TRUSTED_ORIGINS=https://agentfeed.example.com +DJANGO_ALLOWED_HOSTS=daybook.example.com,localhost,127.0.0.1 +DJANGO_CSRF_TRUSTED_ORIGINS=https://daybook.example.com ``` Forwarding the proto header, by proxy: @@ -220,16 +188,15 @@ Caddy: header_up X-Forwarded-Proto https - If behind a reverse proxy: `DJANGO_CSRF_TRUSTED_ORIGINS` is set and the proxy forwards `X-Forwarded-Proto: https` (login will 403 otherwise). - `deploy/data/` exists and persists across `git pull` + rebuild. -- `docker compose -f deploy/docker-compose.yml ps` shows `agentfeed` healthy. +- `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy. - `GET /api/capabilities` returns 403 without auth, 200 with a valid token or session (check the status code — don't use `curl -f`, which errors on 403). -- At least one superuser exists (`manage.py createsuperuser`) with a key - from Settings → API Keys. +- At least one superuser exists (`manage.py createsuperuser`) with a token + from Settings → Account. ## Known MVP Constraints - SQLite is used by default — fine for a single-VM self-hosted deployment. -- Per-user API keys (multiple per user, individually revocable); no - shared/global key. +- Per-user ingest tokens; no shared/global token. - Reader/browser access requires login; feeds are private per user. - Manual drag/drop upload and content revisions are deferred. diff --git a/docs/llm_posting_helper.md b/docs/llm_posting_helper.md index 75b4508..6cafeb6 100644 --- a/docs/llm_posting_helper.md +++ b/docs/llm_posting_helper.md @@ -1,8 +1,8 @@ # LLM Posting Helper -Agents should publish to AgentFeed with `skills/agentfeed-posting/scripts/agentfeed_post.py` instead of hand-building HTTP, JSON, curl, or PowerShell multipart requests. +Agents should publish to Daybook with `skills/daybook-posting/scripts/daybook_post.py` instead of hand-building HTTP, JSON, curl, or PowerShell multipart requests. -The project also keeps `scripts/agentfeed_post.py` as a compatibility wrapper. +The project also keeps `scripts/daybook_post.py` as a compatibility wrapper. The helper is intentionally zero-dependency and uses only Python's standard library. It works on Windows and Linux. @@ -19,37 +19,37 @@ Set these as real process/shell environment variables — the helper reads them, or set them in your agent harness's own env config. ```text -AGENTFEED_URL=http://127.0.0.1:8001 -AGENTFEED_TOKEN=your-personal-api-key -AGENTFEED_SOURCE=agent +DAYBOOK_URL=http://127.0.0.1:8001 +DAYBOOK_TOKEN=your-personal-api-token +DAYBOOK_SOURCE=agent ``` -`AGENTFEED_TOKEN` is a personal API key, created under Settings → API Keys in the app (one per agent, individually revocable) — not a key shared across users. Override these per environment. +`DAYBOOK_TOKEN` is a personal per-user token, from Settings → Account in the app — not a token shared across users. Override these per environment. ## Commands Check the instance: ```bash -python skills/agentfeed-posting/scripts/agentfeed_post.py --check +python skills/daybook-posting/scripts/daybook_post.py --check ``` Post Markdown from a file: ```bash -python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report +python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report ``` Post Markdown from stdin: ```bash -cat report.md | python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report" +cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" ``` Post a deck: ```bash -python skills/agentfeed-posting/scripts/agentfeed_post.py --kind deck --title "Deployment Plan" --content-file deck.html +python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Deployment Plan" --content-file deck.html ``` `deck.html` must contain top-level `
...
` blocks. @@ -57,44 +57,44 @@ python skills/agentfeed-posting/scripts/agentfeed_post.py --kind deck --title "D Post an image/PDF/binary file: ```bash -python skills/agentfeed-posting/scripts/agentfeed_post.py --file diagram.svg --title "Architecture Diagram" -python skills/agentfeed-posting/scripts/agentfeed_post.py --file report.pdf --title "Generated Report" +python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram" +python skills/daybook-posting/scripts/daybook_post.py --file report.pdf --title "Generated Report" ``` Post CSV or JSON as an interactive table/tree (auto-mode detects the kind from the extension; explicit `--kind` works too): ```bash -python skills/agentfeed-posting/scripts/agentfeed_post.py results.csv -python skills/agentfeed-posting/scripts/agentfeed_post.py --kind json --title "Run Summary" --content-file summary.json +python skills/daybook-posting/scripts/daybook_post.py results.csv +python skills/daybook-posting/scripts/daybook_post.py --kind json --title "Run Summary" --content-file summary.json ``` Post an `.xlsx` spreadsheet (converted server-side to a `csv` artifact — active sheet only, saved formula results only, no formatting preserved): ```bash -python skills/agentfeed-posting/scripts/agentfeed_post.py balance_sheet.xlsx +python skills/daybook-posting/scripts/daybook_post.py balance_sheet.xlsx ``` Render other code as a Markdown fenced block instead (no dedicated `code` kind yet): ```bash -python skills/agentfeed-posting/scripts/agentfeed_post.py --file script.py --as-markdown --title "Generated Script" +python skills/daybook-posting/scripts/daybook_post.py --file script.py --as-markdown --title "Generated Script" ``` Embed an image inside a markdown/HTML artifact (upload first, then reference the printed URL in your content): ```bash -python skills/agentfeed-posting/scripts/agentfeed_post.py --upload-image chart.png +python skills/daybook-posting/scripts/daybook_post.py --upload-image chart.png # -> prints .../assets/; put that in your markdown, then post normally ``` Dry run: ```bash -python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Draft" --content-file report.md --dry-run +python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Draft" --content-file report.md --dry-run ``` The artifact URL is printed as the final stdout line. @@ -118,7 +118,7 @@ The helper validates common LLM mistakes before sending: Use repeatable `--meta key=value` flags: ```bash -python skills/agentfeed-posting/scripts/agentfeed_post.py \ +python skills/daybook-posting/scripts/daybook_post.py \ --kind markdown \ --title "Run Summary" \ --content-file summary.md \ @@ -129,7 +129,7 @@ python skills/agentfeed-posting/scripts/agentfeed_post.py \ Or pass a JSON object: ```bash -python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Run Summary" --content-file summary.md --meta-json '{"run_id":"run-001"}' +python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Run Summary" --content-file summary.md --meta-json '{"run_id":"run-001"}' ``` `--meta` and `--meta-json` are merged if both are given. Metadata lives in @@ -140,7 +140,7 @@ filtering). | Flag | Default | Notes | |---|---|---| -| `--source` | `AGENTFEED_SOURCE` env, else `agent` | What produced this — an agent-profile, script, or person name. | +| `--source` | `DAYBOOK_SOURCE` env, else `agent` | What produced this — an agent-profile, script, or person name. | | `--source-type` | `agent` | One of `agent`, `person`, `system`. | | `--source-name` | The effective `--source` | Human-readable name, e.g. "Hermes Agent". | | `--source-id` | Empty | A stable external ID, if one exists. | @@ -152,7 +152,7 @@ filtering). A thin LLM-facing wrapper lives at: ```text -skills/agentfeed-posting/SKILL.md +skills/daybook-posting/SKILL.md ``` -Its key instruction is: do not hand-build requests; use `scripts/agentfeed_post.py`. +Its key instruction is: do not hand-build requests; use `scripts/daybook_post.py`. diff --git a/issues/007-handover-to-llm-test-2026-09-02.md b/issues/007-handover-to-llm-test-2026-09-02.md new file mode 100644 index 0000000..1303fb9 --- /dev/null +++ b/issues/007-handover-to-llm-test-2026-09-02.md @@ -0,0 +1,54 @@ +# 007 — Handover to AGENT_DAYBOOK__LLM_TEST: issues #002-#006 fixed + +**Status:** For information — not a bug report, a handback after fixing +what #002-#006 found. + +## What Changed + +All five issues your last testing round filed are fixed, committed +(`07328a3` on `main`, pushed to `origin`), and confirmed live on the running +local dev instance (`http://127.0.0.1:8001`, same one you test against — +no restart needed on your end, Django's autoreloader already picked it up): + +- **#002** — `.env.example` deleted; `SKILL.md`/`docs/llm_posting_helper.md` + now say explicitly these must be real process/shell env vars. +- **#003** — `source`/`title`/`source_name`/`source_id`/`theme` now + enforce their model length limits on both `POST` and `PATCH`, returning + `400` naming the field. +- **#004** — server now validates content shape per `kind` + (`html_fragment`/`html_full`/`deck`/`markdown`), mirroring + `daybook_post.py`'s existing client-side checks. +- **#005** — `PATCH` now rejects wrong-typed `tags` (400, instead of + silently wiping existing tags) and rejects immutable fields + (`content`/`source`/`kind`/`day`) with 400 instead of silently no-op'ing. +- **#006** — the quota-eviction data-loss bug: a doomed `POST` no longer + destroys unkept artifacts on its way to still failing with `413`. + +Full detail, root cause, and exact fix for each is in its own issue file +(`002` through `006`) — this doc is just the summary + what to do next. + +## Suggested Next Round + +Worth specifically re-probing (not just re-running the original repros — +try to break the fixes themselves): + +- **#003/#004 boundary/edge cases**: exact-limit-plus-one values, unicode + characters (does length-checking count characters or bytes?), mixed valid + content with edge-case whitespace/casing on the ``, `< html`, content + containing those strings inside a code block/string literal rather than as + real markup). +- **#005**: other wrong-typed fields on `PATCH` beyond `tags` — e.g. + `source_prompt`/`source_name`/`source_id` as non-strings (currently + accepted via implicit coercion, noted as untested in #005's original + writeup). +- **#006**: multi-request race conditions around eviction (two posts near + the quota boundary at once) — not covered by the fix, which only handles + the single-request case cleanly. +- General: anything from the original `issues/002-006` reproductions that + might have a sibling case not explicitly covered by the tests added + alongside each fix (`tests/api/test_artifacts.py`, `tests/unit/test_storage.py`). + +As always: use the `test`/`testpwd` account, clean up what you create, and +file anything real as a new numbered issue rather than reopening a closed +one (link back to the original if it's a variant of the same root cause). diff --git a/issues/013-handover-to-llm-test-round-2.md b/issues/013-handover-to-llm-test-round-2.md new file mode 100644 index 0000000..4cae7ac --- /dev/null +++ b/issues/013-handover-to-llm-test-round-2.md @@ -0,0 +1,121 @@ +# 013 — Handover to AGENT_DAYBOOK__LLM_TEST (round 2) + +**Status:** For information — a handback covering everything shipped since +the round-1 handoff (#007). + +Round 1 (#007) closed out issues #002–#006. Since then, your round-2 +testing filed #008–#012 (all now fixed), and several new capabilities +landed. This doc summarizes all of it and points at where the fresh +surface area is worth probing. + +All of this is committed on `main` (pushed to Forgejo) and live on the +local dev instance you test against (`http://127.0.0.1:8001`) — Django's +autoreloader has already picked it up, no restart needed on your end. + +## New Capabilities To Exercise + +These are new since #007 and largely untested by you yet: + +1. **`json` and `csv` artifact kinds** (commit `e5d2f19`). Text kinds like + markdown. `json` renders as a collapsible tree (renderjson), `csv` as a + sortable/searchable table (Grid.js). Both validate server-side (`json` + must parse; `csv` only needs non-blank content) and preserve original + text as a `?raw=1` sidecar. Both vendored libs are served from + `/static/vendor/…`. + +2. **`.xlsx` upload → `csv`** (commit `4b5ec09`). Uploading an `.xlsx` file + (multipart, like any file) is converted server-side via openpyxl into a + `csv` artifact rather than stored as `binary`. Active sheet only; cached + formula results only; `.xls` unsupported; corrupt file → `400`. + +3. **Per-artifact public sharing** (commit `91ea19d`). Owner toggles a + share link from the feed UI; `GET /s/{share_token}` then serves that one + artifact with **no authentication at all**. `is_shared`/`share_url` now + appear in every artifact API response. Embedded images in a shared + artifact are also served unauthenticated while it's shared. **This is + the highest-value target for adversarial testing** — see below. + +4. **Syntax-highlighted code + styled tables** (commits `af9ecf2`, + `696cfe7`). Markdown fenced code blocks get Pygments highlighting; + tables in markdown and `html_fragment` render styled. Cosmetic, low-risk. + +5. **Posting script supports `json`/`csv`/`.xlsx`** (commit `e137725`). + `daybook_post.py --kind json/csv`, positional auto-detect of + `.json`/`.csv`/`.xlsx`. + +6. **Light/dark UI toggle** (commit `9a3b381`). UI-chrome only, no API + surface — not relevant to API testing. + +## Fixes Since #007 (issues you filed) + +- **#008** — `source_name`/`source_id`/`source_prompt` now reject + non-string values with `400` instead of silently mangling them. +- **#009** — the `html_fragment` content-shape check is substring-based; + documented as a known limitation rather than switched to real HTML + parsing (deliberate — matches the client helper's own approach). +- **#010** — concurrent posts could race past quota; fixed with a + write-lock-before-read + atomic transaction. Verified live but **not** + covered by an automated test (SQLite shared-cache test DB behaves + differently — see the issue). Worth re-probing. +- **#011** — CDN snippets documented for `html_full`; `--theme` help typo + fixed. +- **#012** — posting script gained `json`/`csv` support. + +## Suggested Round-3 Probes + +Prioritized by where the new surface area is riskiest. + +### Sharing (#91ea19d) — security boundary, probe hardest + +- Can an **unshared** artifact ever be reached via `/s/`? Brute/ + guessed/empty/malformed tokens should all `404`. +- Does **unsharing immediately** kill the old link? (Confirmed manually, + but re-verify — and try racing an unshare against an in-flight request.) +- Is `share_token` ever leaked in a response for an artifact you *don't* + own, or in the list endpoint, or anywhere a non-owner could see it? +- Can a shared artifact's **embedded assets** be reached unauthenticated — + and, importantly, can an *un*shared artifact's assets be reached that way + (they must not)? Also: does an orphaned/unattached asset stay private + even when some unrelated artifact is shared? +- Does `?raw=1` on `/s/` correctly serve raw source for the kinds + that support it, and not leak anything for kinds that don't? +- Cross-user: user A shares an artifact; can user B toggle/​unshare it, or + only view it? + +### json / csv (#e5d2f19) + +- `json`: valid-but-not-an-object payloads (bare array, string, number, + `null`); deeply nested / very large; unicode; the `` injection + vector (confirmed handled for json — verify **csv** escapes its embedded + data block the same way, it's a separate code path). +- `csv`: ragged rows (fewer/more columns than the header), embedded commas/ + quotes/newlines, duplicate column names, header-only, empty, a single + giant cell, BOM/encoding oddities. +- Quota accounting: json/csv now write a sidecar file — confirm size + accounting and eviction include/delete the sidecar (this generalized the + markdown-only logic; worth verifying it didn't regress markdown either). + +### .xlsx conversion (#4b5ec09) + +- Multi-sheet workbook — verify only the active sheet survives and the + others are silently dropped (documented behavior, confirm it's true). +- Formula cells never calculated by a real spreadsheet app (openpyxl + doesn't evaluate) → should be blank, not error. +- A non-`.xlsx` file renamed to `.xlsx`; a real `.xlsx` sent with a wrong/ + generic content type; a huge sheet; empty workbook; `.xls` (must not + convert). +- Field mangling regression: this path rebuilds the payload dict manually + because multipart `QueryDict` + `{**data}` was silently list-wrapping + values (the bug that made this a multi-fix). Re-probe that title/tags/ + source_* all come through correctly on `.xlsx` posts specifically. + +### #010 quota race (no automated coverage) + +- Fire N truly-concurrent posts at a tight quota; confirm final usage never + exceeds quota and no artifact is left half-created (row without files or + files without row). + +As always: use the `test` / `testpwd` account, clean up what you create, +check `artifacts/views.py` before calling a surprise a bug (some, like +#009, are documented-by-design), and file anything real as a new numbered +issue (next is #014), linking back if it's a variant of a closed one. diff --git a/issues/014-stored-xss-in-rendered-text-artifacts.md b/issues/014-stored-xss-in-rendered-text-artifacts.md new file mode 100644 index 0000000..88edbd9 --- /dev/null +++ b/issues/014-stored-xss-in-rendered-text-artifacts.md @@ -0,0 +1,169 @@ +# 014 — Stored XSS: markdown (and deck/html_fragment) render unsanitized script; no CSP; now reachable unauthenticated via public share links + +**Status:** Fixed +**Severity:** High — stored XSS executing on the Daybook origin, with a +concrete escalation chain against the owner's session, and (since the +sharing feature shipped in `91ea19d`) reachable by unauthenticated third +parties. + +Round-3 probe of content validation ("put malformed/bad content in each +kind, is it caught?"). Short answer: JSON and CSV are well-defended; +markdown is not; deck and html_fragment inject script too. + +## What was tested and what happened + +All posted to the `test` account and fetched back rendered from `/a/{id}`. + +| Kind | Injected | Result | +|---|---|---| +| `markdown` | ``, ``, `` | **All pass through verbatim into rendered HTML — executes.** | +| `deck` | `
…` | **Script passes through into the themed deck page — executes.** | +| `html_fragment` | `

hi

` | **Script/`svg onload` pass through into the themed page — executes.** (The #004 content-shape check blocks `//` but never `"}` | **Defended.** `escape_script_body` (`re.compile(r"` data block; renderjson shows it as inert text. Case-variation bypass (``) correctly caught. | +| `csv` | `` in a cell | **Defended.** Same escaping via `json.dumps` + `escape_script_body`. | +| `csv` | `=cmd|/c calc`, `+SUM(...)` as cell values | Passes through into the `?raw=1` sidecar verbatim (classic CSV/formula injection). Low severity / debatable — see below. | + +## Root cause (markdown specifically) + +`create_text_artifact` renders markdown with: + +```python +markdown.markdown(content, extensions=["extra", "sane_lists", "codehilite"], ...) +``` + +Python-Markdown does **no** HTML sanitization, and the `extra` extension +enables raw-HTML passthrough. Its own docs warn output must be sanitized +(bleach/nh3) before display if the input is at all untrusted. The rendered +HTML is then written to the artifact's `.html` file and served as-is. + +Unlike `html_full`/`html_fragment`, **markdown is not an "author your own +HTML" kind** — nobody expects a Markdown note to be a script-execution +vector. In an agent-driven system this is exactly the kind most likely to +carry *indirectly* untrusted content: an agent that summarizes a scraped +page, an email, or an ingested document can emit a summary containing an +attacker-controlled ` ``` -**Offline caveat:** AgentFeed can run fully self-hosted/air-gapped. Any +**Offline caveat:** Daybook can run fully self-hosted/air-gapped. Any artifact that links a CDN script won't render fully (or at all, for Tailwind's in-browser compiler) without internet access. If the deployment target is offline, don't reach for these — use `markdown`/`html_fragment` instead, which are styled entirely server-side with no external requests. -**Self-hosted alternative for tables/JSON — no CDN needed:** AgentFeed already +**Self-hosted alternative for tables/JSON — no CDN needed:** Daybook already vendors Grid.js and renderjson locally (used to render `csv`/`json` artifacts), and they're reachable as plain static files from any artifact, including `html_full`: @@ -138,7 +108,7 @@ tables/JSON; there's no vendored equivalent for Tailwind/Chart.js, so the CDN snippets above are still the only option for those. Only use this for `html_full` specifically — never add CDN references to -`markdown`/`html_fragment`/`deck`. Those kinds exist so AgentFeed, not the +`markdown`/`html_fragment`/`deck`. Those kinds exist so Daybook, not the poster, owns styling; injecting a default CDN habit into them would undermine that. @@ -149,12 +119,11 @@ artifact), upload it first and reference the returned URL: ```bash # 1. Upload -> prints the URL as the last line -python "$AGENTFEED_POST" --upload-image chart.png +python skills/daybook-posting/scripts/daybook_post.py --upload-image chart.png -# 2. Reference that URL in your markdown, then post as usual. -# The printed URL is absolute against your AGENTFEED_URL, e.g. -# ![chart]($AGENTFEED_URL/assets/ab12cd34ef56) -python "$AGENTFEED_POST" report.md +# 2. Reference that URL in your markdown, then post as usual +# ![chart](http://127.0.0.1:8001/assets/ab12cd34ef56) +python skills/daybook-posting/scripts/daybook_post.py report.md ``` The image is linked to the artifact that references it and is deleted along with it. @@ -167,19 +136,19 @@ just post the file positionally; auto-mode detects `.csv`/`.json` and picks the right kind automatically: ```bash -python "$AGENTFEED_POST" results.csv -python "$AGENTFEED_POST" --kind json --title "Run Summary" --content-file summary.json +python skills/daybook-posting/scripts/daybook_post.py results.csv +python skills/daybook-posting/scripts/daybook_post.py --kind json --title "Run Summary" --content-file summary.json ``` `.xlsx` spreadsheets work too, with no special flag needed — just `--file` (or post it positionally, same as any other file): ```bash -python "$AGENTFEED_POST" balance_sheet.xlsx +python skills/daybook-posting/scripts/daybook_post.py balance_sheet.xlsx ``` The server converts it to a `csv` artifact automatically. Known -limitations (see the instance's `docs/api.md` for the full list): +limitations (see `docs/api.md` for the full list): - Only the workbook's **active sheet** converts; other sheets are dropped. - Formula cells need to have been calculated and saved by real spreadsheet @@ -196,7 +165,7 @@ as a binary, use `--file path --as-markdown` to wrap it in a Markdown fenced code block (still gets syntax highlighting): ```bash -python "$AGENTFEED_POST" --file script.py --as-markdown --title "Generated Script" +python skills/daybook-posting/scripts/daybook_post.py --file script.py --as-markdown --title "Generated Script" ``` Without `--as-markdown`, it uploads as a downloadable binary file instead. @@ -208,7 +177,7 @@ All optional, all default to something reasonable if omitted: | Flag | Default | Notes | |---|---|---| | `--source-type` | `agent` | One of `agent`, `person`, `system`. | -| `--source-name` | Same as the effective source (`--source`, or `AGENTFEED_SOURCE` if `--source` wasn't given) | Human-readable name, e.g. "Hermes Agent". | +| `--source-name` | Same as the effective source (`--source`, or `DAYBOOK_SOURCE` if `--source` wasn't given) | Human-readable name, e.g. "Hermes Agent". | | `--source-id` | Empty string | A stable external ID, if the poster has one. | | `--source-prompt` | Empty string | The prompt/instruction that produced this artifact — useful for later tracing why something was posted. | @@ -219,38 +188,9 @@ context like `run_id` or `model` — these end up in the artifact's ## Rules -- Run `--check` first if unsure AgentFeed is reachable. -- Make sure `AGENTFEED_URL` points at the right instance and `AGENTFEED_TOKEN` is set before posting anything real. -- Use a stable `AGENTFEED_SOURCE` or pass `--source` (the agent-profile, script, or person name that produced this artifact). +- Run `--check` first if unsure Daybook is reachable. +- Use a stable `DAYBOOK_SOURCE` or pass `--source` (the agent-profile, script, or person name that produced this artifact). - Use `--tags a,b,c` for filtering. - Use `--meta run_id=... --meta model=...` for machine-readable context. - Never inline multiline content into the shell command; use a file or stdin. - Add `--dry-run` to see exactly what would be sent (method, headers, payload) without actually posting — useful when unsure a payload is well-formed. - -## `curl` Fallback (No Helper Available) - -Prefer the helper above — it handles multipart, content-type inference, -JSON shaping, and provenance flags for you. Use raw `curl` **only** when you -genuinely can't run the bundled Python script (e.g. a minimal environment -with no Python). Text artifacts post as JSON to `POST /api/artifacts` with -bearer-token auth: - -```bash -curl -sS -X POST "$AGENTFEED_URL/api/artifacts" \ - -H "Authorization: Bearer $AGENTFEED_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"kind":"markdown","title":"Build Report","source":"agent","content":"# Hello\n\nBody text."}' -``` - -File/image artifacts use multipart instead: - -```bash -curl -sS -X POST "$AGENTFEED_URL/api/artifacts" \ - -H "Authorization: Bearer $AGENTFEED_TOKEN" \ - -F "kind=image" -F "title=Architecture Diagram" -F "source=agent" \ - -F "file=@diagram.png" -``` - -`GET $AGENTFEED_URL/api/capabilities` (no auth needed beyond a login/token per -that instance's config) lists supported kinds, methods, and the server -version if you need to confirm the contract before posting. diff --git a/skills/agentfeed-posting/scripts/agentfeed_post.py b/skills/daybook-posting/scripts/daybook_post.py similarity index 95% rename from skills/agentfeed-posting/scripts/agentfeed_post.py rename to skills/daybook-posting/scripts/daybook_post.py index 049f78f..659fbd4 100644 --- a/skills/agentfeed-posting/scripts/agentfeed_post.py +++ b/skills/daybook-posting/scripts/daybook_post.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Post artifacts to AgentFeed without shell-specific JSON or multipart quoting. +"""Post artifacts to Daybook without shell-specific JSON or multipart quoting. Zero third-party dependencies. Content comes from a file or stdin so agents do not need to inline multiline JSON/HTML/Markdown in shell commands. @@ -26,11 +26,11 @@ DEFAULT_SOURCE = "agent" def main(): args = parse_args() - base_url = os.environ.get("AGENTFEED_URL", DEFAULT_URL).rstrip("/") - # AGENTFEED_TOKEN is your personal per-user API key (Settings -> API Keys in the app); + base_url = os.environ.get("DAYBOOK_URL", DEFAULT_URL).rstrip("/") + # DAYBOOK_TOKEN is your personal per-user token (Settings -> Account in the app); # there is no shared fallback token, so posting without it cannot work. - token = os.environ.get("AGENTFEED_TOKEN", "") - source = args.source or os.environ.get("AGENTFEED_SOURCE", DEFAULT_SOURCE) + token = os.environ.get("DAYBOOK_TOKEN", "") + source = args.source or os.environ.get("DAYBOOK_SOURCE", DEFAULT_SOURCE) if args.check: try: @@ -43,8 +43,8 @@ def main(): if not token: print( - "error: AGENTFEED_TOKEN is not set. Get your personal API key from " - f"{base_url}/settings/api-keys/ and set AGENTFEED_TOKEN.", + "error: DAYBOOK_TOKEN is not set. Get your personal API token from " + f"{base_url}/settings/account/ and set DAYBOOK_TOKEN.", file=sys.stderr, ) return 2 @@ -84,11 +84,11 @@ def main(): def parse_args(): - parser = argparse.ArgumentParser(description="Publish an artifact to AgentFeed.") + parser = argparse.ArgumentParser(description="Publish an artifact to Daybook.") parser.add_argument("path", nargs="?", help="Auto mode: one file path. Kind, content-type, and title are inferred. The simplest way to post.") parser.add_argument("--kind", choices=sorted(TEXT_KINDS), help="Text artifact kind. Omit when using --file or a positional path.") parser.add_argument("--title", help="Artifact title. Required unless --check is used.") - parser.add_argument("--source", help="Artifact source (agent-profile, script, or person name). Defaults to AGENTFEED_SOURCE or 'agent'.") + parser.add_argument("--source", help="Artifact source (agent-profile, script, or person name). Defaults to DAYBOOK_SOURCE or 'agent'.") parser.add_argument("--content-file", help="Read text artifact content from this file.") parser.add_argument("--file", help="Upload this file as image/pdf/binary, or render as markdown with --as-markdown.") parser.add_argument("--tags", help="Comma-separated tags.") @@ -352,7 +352,7 @@ def post_payload(base_url, token, payload, args): def multipart_body(payload): - boundary = f"----agentfeed-{uuid.uuid4().hex}" + boundary = f"----daybook-{uuid.uuid4().hex}" chunks = [] upload_path = payload["upload_path"] for key, value in payload.items(): @@ -394,11 +394,11 @@ def content_type_for(path): def upload_asset(base_url, token, path): - """Upload an image and return its AgentFeed URL for embedding in content.""" + """Upload an image and return its Daybook URL for embedding in content.""" if not path.is_file(): raise ValueError(f"image file does not exist: {path}") - boundary = f"----agentfeed-{uuid.uuid4().hex}" + boundary = f"----daybook-{uuid.uuid4().hex}" chunks = [ f"--{boundary}\r\n".encode("utf-8"), f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'.encode("utf-8"), diff --git a/static/agentfeed.css b/static/daybook.css similarity index 77% rename from static/agentfeed.css rename to static/daybook.css index 788b473..37002a3 100644 --- a/static/agentfeed.css +++ b/static/daybook.css @@ -128,42 +128,6 @@ body { border-color: var(--control-border); } -.avatar-button { - background: transparent; - border: 0; - border-radius: 50%; - cursor: pointer; - line-height: 0; - padding: 2px; -} - -.avatar-button:hover .avatar, -.avatar-button:focus-visible .avatar { - box-shadow: 0 0 0 2px var(--content-bg), 0 0 0 4px var(--control-border); -} - -.avatar { - align-items: center; - border-radius: 50%; - color: #fff; - display: inline-flex; - font-size: 0.9rem; - font-weight: 600; - height: 2rem; - justify-content: center; - line-height: 1; - width: 2rem; -} - -.user-menu-name { - border-bottom: 1px solid var(--control-border); - color: var(--muted); - font-size: 0.8rem; - font-weight: 600; - margin-bottom: 0.25rem; - padding: 0.5rem 0.8rem; -} - .user-menu-dropdown { background: var(--surface); border: 1px solid var(--control-border); @@ -360,7 +324,6 @@ h1 { /* Source sidebar: scrollbar hidden until hover (short list, rarely scrolls). */ .source-list { - flex: 1 1 auto; /* fill available height so system folders pin to the bottom */ scrollbar-color: transparent transparent; scrollbar-width: none; } @@ -514,18 +477,6 @@ h1 { font-size: 0.7rem; } -/* The unread badge doubles as a "mark all as read" button. */ -.source-count[data-mark-read] { - cursor: pointer; - border-radius: 8px; - padding: 0 0.35rem; -} - -.source-count[data-mark-read]:hover { - background: rgba(74, 110, 168, 0.28); - color: var(--text); -} - .pane-heading span, .artifact-time { color: var(--muted); @@ -1017,21 +968,6 @@ kbd { padding-bottom: 0.7rem !important; } -.settings-nav-group { - display: flex; - flex-direction: column; - margin-bottom: 0.9rem; -} - -.settings-nav-heading { - color: var(--muted); - font-size: 0.7rem; - font-weight: 700; - letter-spacing: 0.06em; - padding: 0.3rem 0.7rem; - text-transform: uppercase; -} - .settings-panel { max-width: 42rem; padding: 1.6rem 2rem; @@ -1054,257 +990,6 @@ kbd { word-break: break-all; } -.muted { - color: var(--muted); -} - -/* Newly live-inserted feed rows briefly highlight, then settle. */ -@keyframes agentfeed-row-arrive { - from { background: var(--surface-alt); } - to { background: transparent; } -} - -.artifact-row.just-arrived { - animation: agentfeed-row-arrive 1.5s ease-out; -} - -/* Hover/focus row actions (delete in the feed, undelete in Trash) */ -.artifact-row { - position: relative; -} - -.row-actions { - position: absolute; - right: 0.4rem; - top: 50%; - transform: translateY(-50%); - display: flex; - gap: 0.15rem; - opacity: 0; -} - -.artifact-row:hover .row-actions, -.artifact-row:focus-within .row-actions { - opacity: 1; -} - -.row-action { - cursor: pointer; - font-size: 0.95rem; - line-height: 1; - padding: 0.15rem 0.3rem; - border-radius: 4px; - background: var(--surface); - color: var(--muted); -} - -.row-action:hover { - background: var(--surface-alt); - color: var(--text); -} - -.row-delete:hover { - color: var(--danger-text-plain); -} - -/* Per-row actions menu (opened by the ⋯ trigger or right-click) */ -.row-menu { - position: fixed; - z-index: 100; - min-width: 9rem; - background: var(--surface); - border: 1px solid var(--control-border); - border-radius: 8px; - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.14); - padding: 0.25rem; - display: flex; - flex-direction: column; -} - -.row-menu[hidden] { - display: none; -} - -.row-menu button { - background: transparent; - border: 0; - border-radius: 5px; - color: var(--text); - cursor: pointer; - font-size: 0.85rem; - padding: 0.4rem 0.6rem; - text-align: left; -} - -.row-menu button:hover { - background: var(--surface-alt); -} - -.row-menu button.danger { - color: var(--danger-text-plain); -} - -.row-menu-divider { - border-top: 1px solid var(--grid-line); - margin: 0.25rem 0; -} - -.row-menu-labels { - display: flex; - flex-direction: column; - max-height: 13rem; - overflow-y: auto; -} - -.row-menu-label { - align-items: center; - display: flex; - gap: 0.45rem; -} - -.row-menu-dot { - border-radius: 50%; - flex: 0 0 auto; - height: 0.7rem; - width: 0.7rem; -} - -.row-menu-label-name { - flex: 1 1 auto; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Checkmark only visible once the label is applied to the row. */ -.row-menu-check { - flex: 0 0 auto; - opacity: 0; -} - -.row-menu-label.applied .row-menu-check { - opacity: 1; -} - -/* System folders (Keep / Deleted): pinned at the bottom of the sidebar, - below the scrolling source list. */ -.system-folders { - border-top: 1px solid var(--grid-line); - display: flex; - flex-direction: column; - flex-shrink: 0; - gap: 0.25rem; /* space Keep / Deleted apart */ - padding: 0.4rem 0.6rem 0.3rem; -} - -/* "Special" sidebar entries — "All sources" and the Keep/Deleted folders — - share a distinct look (bold + subtle blue tint) to set them apart from the - individual sources. */ -.all-sources-link, -.folder-link { - font-weight: 600; -} - -.all-sources-link:not(.selected), -.folder-link:not(.selected) { - background: rgba(74, 110, 168, 0.14); -} - -.all-sources-link:not(.selected):hover, -.folder-link:not(.selected):hover { - background: rgba(74, 110, 168, 0.22); -} - -.empty-trash-button { - background: var(--danger-bg); - border: 1px solid var(--danger-text); - border-radius: 6px; - color: var(--danger-text); - cursor: pointer; - font-size: 0.78rem; - padding: 0.2rem 0.55rem; -} - -.api-key-create { - align-items: end; - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - margin-bottom: 1.5rem; -} - -.api-key-create label { - display: flex; - flex-direction: column; - font-weight: 600; - gap: 0.3rem; -} - -.password-change { - display: flex; - flex-direction: column; - gap: 0.85rem; - max-width: 22rem; -} - -.password-change .form-field { - display: flex; - flex-direction: column; - font-weight: 600; - gap: 0.3rem; -} - -.password-change button { - align-self: start; -} - -.form-errors { - color: var(--danger, #d1453b); - font-weight: 400; - list-style: none; - margin: 0.15rem 0 0; - padding: 0; -} - -.api-key-table { - border-collapse: collapse; - width: 100%; -} - -.api-key-table th, -.api-key-table td { - border-bottom: 1px solid var(--accent-border); - padding: 0.55rem 0.7rem; - text-align: left; - vertical-align: middle; -} - -.api-key-table th { - color: var(--muted); - font-size: 0.85em; - font-weight: 600; -} - -.api-key-cell { - align-items: center; - display: flex; - gap: 0.5rem; -} - -.api-key-cell .token-value { - font-size: 0.85em; -} - -.api-key-copy { - flex-shrink: 0; -} - -.api-key-revoke { - background: var(--danger-bg); - border: 1px solid var(--danger-text); - color: var(--danger-text); -} - .about-head { align-items: center; display: flex; diff --git a/static/vendor/gridjs/gridjs.umd.js b/static/vendor/gridjs/gridjs.umd.js index 6e79315..31761d7 100644 --- a/static/vendor/gridjs/gridjs.umd.js +++ b/static/vendor/gridjs/gridjs.umd.js @@ -1 +1,2 @@ !function(t,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t||self).gridjs={})}(this,function(t){function n(t,n){for(var e=0;et.length)&&(n=t.length);for(var e=0,r=new Array(n);e=t.length?{done:!0}:{done:!1,value:t[r++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var l;!function(t){t[t.Init=0]="Init",t[t.Loading=1]="Loading",t[t.Loaded=2]="Loaded",t[t.Rendered=3]="Rendered",t[t.Error=4]="Error"}(l||(l={}));var c,f,p,d,h,_,m,v={},g=[],y=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;function b(t,n){for(var e in n)t[e]=n[e];return t}function w(t){var n=t.parentNode;n&&n.removeChild(t)}function x(t,n,e){var r,o,i,u={};for(i in n)"key"==i?r=n[i]:"ref"==i?o=n[i]:u[i]=n[i];if(arguments.length>2&&(u.children=arguments.length>3?c.call(arguments,2):e),"function"==typeof t&&null!=t.defaultProps)for(i in t.defaultProps)void 0===u[i]&&(u[i]=t.defaultProps[i]);return k(t,u,r,o,null)}function k(t,n,e,r,o){var i={type:t,props:n,key:e,ref:r,__k:null,__:null,__b:0,__e:null,__d:void 0,__c:null,__h:null,constructor:void 0,__v:null==o?++p:o};return null==o&&null!=f.vnode&&f.vnode(i),i}function P(t){return t.children}function S(t,n){this.props=t,this.context=n}function N(t,n){if(null==n)return t.__?N(t.__,t.__.__k.indexOf(t)+1):null;for(var e;n0?k(d.type,d.props,d.key,d.ref?d.ref:null,d.__v):d)){if(d.__=e,d.__b=e.__b+1,null===(p=y[c])||p&&d.key==p.key&&d.type===p.type)y[c]=void 0;else for(f=0;f0&&(this.callbacks[e].forEach(function(t){return t.apply(void 0,[].slice.call(n,1))}),!0)},t}();function Y(t,n){if(typeof t!=typeof n)return!1;if(null===t&&null===n)return!0;if("object"!=typeof t)return t===n;if(Array.isArray(t)&&Array.isArray(n)){if(t.length!==n.length)return!1;for(var e=0;e=e.__.length&&e.__.push({__V:ft}),e.__[t]}function gt(t){return lt=1,function(t,n,e){var r=vt(ot++,2);if(r.t=t,!r.__c&&(r.__=[Et(void 0,n),function(t){var n=r.__N?r.__N[0]:r.__[0],e=r.t(n,t);n!==e&&(r.__N=[e,r.__[1]],r.__c.setState({}))}],r.__c=it,!it.u)){it.u=!0;var o=it.shouldComponentUpdate;it.shouldComponentUpdate=function(t,n,e){if(!r.__c.__H)return!0;var i=r.__c.__H.__.filter(function(t){return t.__c});if(i.every(function(t){return!t.__N}))return!o||o.call(this,t,n,e);var u=!1;return i.forEach(function(t){if(t.__N){var n=t.__[0];t.__=t.__N,t.__N=void 0,n!==t.__[0]&&(u=!0)}}),!(!u&&r.__c.props===t)&&(!o||o.call(this,t,n,e))}}return r.__N||r.__}(Et,t)}function yt(t,n){var e=vt(ot++,3);!f.__s&&Ct(e.__H,n)&&(e.__=t,e.i=n,it.__H.__h.push(e))}function bt(t){return lt=5,wt(function(){return{current:t}},[])}function wt(t,n){var e=vt(ot++,7);return Ct(e.__H,n)?(e.__V=t(),e.i=n,e.__h=t,e.__V):e.__}function xt(){for(var t;t=ct.shift();)if(t.__P&&t.__H)try{t.__H.__h.forEach(St),t.__H.__h.forEach(Nt),t.__H.__h=[]}catch(n){t.__H.__h=[],f.__e(n,t.__v)}}f.__b=function(t){it=null,pt&&pt(t)},f.__r=function(t){dt&&dt(t),ot=0;var n=(it=t.__c).__H;n&&(ut===it?(n.__h=[],it.__h=[],n.__.forEach(function(t){t.__N&&(t.__=t.__N),t.__V=ft,t.__N=t.i=void 0})):(n.__h.forEach(St),n.__h.forEach(Nt),n.__h=[])),ut=it},f.diffed=function(t){ht&&ht(t);var n=t.__c;n&&n.__H&&(n.__H.__h.length&&(1!==ct.push(n)&&st===f.requestAnimationFrame||((st=f.requestAnimationFrame)||Pt)(xt)),n.__H.__.forEach(function(t){t.i&&(t.__H=t.i),t.__V!==ft&&(t.__=t.__V),t.i=void 0,t.__V=ft})),ut=it=null},f.__c=function(t,n){n.some(function(t){try{t.__h.forEach(St),t.__h=t.__h.filter(function(t){return!t.__||Nt(t)})}catch(e){n.some(function(t){t.__h&&(t.__h=[])}),n=[],f.__e(e,t.__v)}}),_t&&_t(t,n)},f.unmount=function(t){mt&&mt(t);var n,e=t.__c;e&&e.__H&&(e.__H.__.forEach(function(t){try{St(t)}catch(t){n=t}}),e.__H=void 0,n&&f.__e(n,e.__v))};var kt="function"==typeof requestAnimationFrame;function Pt(t){var n,e=function(){clearTimeout(r),kt&&cancelAnimationFrame(n),setTimeout(t)},r=setTimeout(e,100);kt&&(n=requestAnimationFrame(e))}function St(t){var n=it,e=t.__c;"function"==typeof e&&(t.__c=void 0,e()),it=n}function Nt(t){var n=it;t.__c=t.__(),it=n}function Ct(t,n){return!t||t.length!==n.length||n.some(function(n,e){return n!==t[e]})}function Et(t,n){return"function"==typeof n?n(t):n}function It(){return function(t){var n=it.context[t.__c],e=vt(ot++,9);return e.c=t,n?(null==e.__&&(e.__=!0,n.sub(it)),n.props.value):t.__}(fn)}var Tt={search:{placeholder:"Type a keyword..."},sort:{sortAsc:"Sort column ascending",sortDesc:"Sort column descending"},pagination:{previous:"Previous",next:"Next",navigate:function(t,n){return"Page "+t+" of "+n},page:function(t){return"Page "+t},showing:"Showing",of:"of",to:"to",results:"results"},loading:"Loading...",noRecordsFound:"No matching records found",error:"An error happened while fetching the data"},Lt=/*#__PURE__*/function(){function t(t){this._language=void 0,this._defaultLanguage=void 0,this._language=t,this._defaultLanguage=Tt}var n=t.prototype;return n.getString=function(t,n){if(!n||!t)return null;var e=t.split("."),r=e[0];if(n[r]){var o=n[r];return"string"==typeof o?function(){return o}:"function"==typeof o?o:this.getString(e.slice(1).join("."),o)}return null},n.translate=function(t){var n,e=this.getString(t,this._language);return(n=e||this.getString(t,this._defaultLanguage))?n.apply(void 0,[].slice.call(arguments,1)):t},t}();function At(){var t=It();return function(n){var e;return(e=t.translator).translate.apply(e,[n].concat([].slice.call(arguments,1)))}}var Ot=function(t){return function(n){return r({},n,{search:{keyword:t}})}};function jt(){return It().store}function Ht(t){var n=jt(),e=gt(t(n.getState())),r=e[0],o=e[1];return yt(function(){return n.subscribe(function(){var e=t(n.getState());r!==e&&o(e)})},[]),r}function Dt(){var t,n=gt(void 0),e=n[0],r=n[1],o=It(),i=o.search,u=At(),s=jt().dispatch,a=Ht(function(t){return t.search});yt(function(){e&&e.setProps({keyword:null==a?void 0:a.keyword})},[a,e]),yt(function(){r(i.server?new at({keyword:i.keyword,url:i.server.url,body:i.server.body}):new nt({keyword:i.keyword,columns:o.header&&o.header.columns,ignoreHiddenColumns:i.ignoreHiddenColumns||void 0===i.ignoreHiddenColumns,selector:i.selector})),i.keyword&&s(Ot(i.keyword))},[i]),yt(function(){if(e)return o.pipeline.register(e),function(){return o.pipeline.unregister(e)}},[o,e]);var l,c,f,p=function(t,n){return lt=8,wt(function(){return t},n)}((l=function(t){t.target instanceof HTMLInputElement&&s(Ot(t.target.value))},c=e instanceof at?i.debounceTimeout||250:0,function(){var t=arguments;return new Promise(function(n){f&&clearTimeout(f),f=setTimeout(function(){return n(l.apply(void 0,[].slice.call(t)))},c)})}),[i,e]);return x("div",{className:et(rt("search",null==(t=o.className)?void 0:t.search))},x("input",{type:"search",placeholder:u("search.placeholder"),"aria-label":u("search.placeholder"),onInput:p,className:rt(et("input"),et("search","input")),defaultValue:(null==a?void 0:a.keyword)||""}))}var Mt=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}o(n,t);var r=n.prototype;return r.validateProps=function(){if(isNaN(Number(this.props.limit))||isNaN(Number(this.props.page)))throw Error("Invalid parameters passed")},r._process=function(t){var n=this.props.page;return new J(t.rows.slice(n*this.props.limit,(n+1)*this.props.limit))},e(n,[{key:"type",get:function(){return K.Limit}}]),n}(tt),Rt=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}return o(n,t),n.prototype._process=function(t){var n={};return this.props.url&&(n.url=this.props.url(t.url,this.props.page,this.props.limit)),this.props.body&&(n.body=this.props.body(t.body,this.props.page,this.props.limit)),r({},t,n)},e(n,[{key:"type",get:function(){return K.ServerLimit}}]),n}(tt);function Ft(){var t=It(),n=t.pagination,e=n.server,r=n.summary,o=void 0===r||r,i=n.nextButton,u=void 0===i||i,s=n.prevButton,a=void 0===s||s,l=n.buttonsCount,c=void 0===l?3:l,f=n.limit,p=void 0===f?10:f,d=n.page,h=void 0===d?0:d,_=n.resetPageOnUpdate,m=void 0===_||_,v=bt(null),g=gt(h),y=g[0],b=g[1],w=gt(0),k=w[0],S=w[1],N=At();yt(function(){return e?(v.current=new Rt({limit:p,page:y,url:e.url,body:e.body}),t.pipeline.register(v.current)):(v.current=new Mt({limit:p,page:y}),t.pipeline.register(v.current)),v.current instanceof Rt?t.pipeline.on("afterProcess",function(t){return S(t.length)}):v.current instanceof Mt&&v.current.on("beforeProcess",function(t){return S(t.length)}),t.pipeline.on("updated",C),t.pipeline.on("error",function(){S(0),b(0)}),function(){t.pipeline.unregister(v.current),t.pipeline.off("updated",C)}},[]);var C=function(t){m&&t!==v.current&&(b(0),0!==v.current.props.page&&v.current.setProps({page:0}))},E=function(){return Math.ceil(k/p)},I=function(t){if(t>=E()||t<0||t===y)return null;b(t),v.current.setProps({page:t})};return x("div",{className:rt(et("pagination"),t.className.pagination)},x(P,null,o&&k>0&&x("div",{role:"status","aria-live":"polite",className:rt(et("summary"),t.className.paginationSummary),title:N("pagination.navigate",y+1,E())},N("pagination.showing")," ",x("b",null,N(""+(y*p+1)))," ",N("pagination.to")," ",x("b",null,N(""+Math.min((y+1)*p,k)))," ",N("pagination.of")," ",x("b",null,N(""+k))," ",N("pagination.results"))),x("div",{className:et("pages")},a&&x("button",{tabIndex:0,role:"button",disabled:0===y,onClick:function(){return I(y-1)},title:N("pagination.previous"),"aria-label":N("pagination.previous"),className:rt(t.className.paginationButton,t.className.paginationButtonPrev)},N("pagination.previous")),function(){if(c<=0)return null;var n=Math.min(E(),c),e=Math.min(y,Math.floor(n/2));return y+Math.floor(n/2)>=E()&&(e=n-(E()-y)),x(P,null,E()>n&&y-e>0&&x(P,null,x("button",{tabIndex:0,role:"button",onClick:function(){return I(0)},title:N("pagination.firstPage"),"aria-label":N("pagination.firstPage"),className:t.className.paginationButton},N("1")),x("button",{tabIndex:-1,className:rt(et("spread"),t.className.paginationButton)},"...")),Array.from(Array(n).keys()).map(function(t){return y+(t-e)}).map(function(n){return x("button",{tabIndex:0,role:"button",onClick:function(){return I(n)},className:rt(y===n?rt(et("currentPage"),t.className.paginationButtonCurrent):null,t.className.paginationButton),title:N("pagination.page",n+1),"aria-label":N("pagination.page",n+1)},N(""+(n+1)))}),E()>n&&E()>y+e+1&&x(P,null,x("button",{tabIndex:-1,className:rt(et("spread"),t.className.paginationButton)},"..."),x("button",{tabIndex:0,role:"button",onClick:function(){return I(E()-1)},title:N("pagination.page",E()),"aria-label":N("pagination.page",E()),className:t.className.paginationButton},N(""+E()))))}(),u&&x("button",{tabIndex:0,role:"button",disabled:E()===y+1||0===E(),onClick:function(){return I(y+1)},title:N("pagination.next"),"aria-label":N("pagination.next"),className:rt(t.className.paginationButton,t.className.paginationButtonNext)},N("pagination.next"))))}function Ut(t,n){return"string"==typeof t?t.indexOf("%")>-1?n/100*parseInt(t,10):parseInt(t,10):t}function Wt(t){return t?Math.floor(t)+"px":""}function Bt(t){var n=t.tableRef.cloneNode(!0);return n.style.position="absolute",n.style.width="100%",n.style.zIndex="-2147483640",n.style.visibility="hidden",x("div",{ref:function(t){t&&t.appendChild(n)}})}function qt(t){if(!t)return"";var n=t.split(" ");return 1===n.length&&/([a-z][A-Z])+/g.test(t)?t:n.map(function(t,n){return 0==n?t.toLowerCase():t.charAt(0).toUpperCase()+t.slice(1).toLowerCase()}).join("")}var zt,Vt=new(/*#__PURE__*/function(){function t(){}var n=t.prototype;return n.format=function(t,n){return"[Grid.js] ["+n.toUpperCase()+"]: "+t},n.error=function(t,n){void 0===n&&(n=!1);var e=this.format(t,"error");if(n)throw Error(e);console.error(e)},n.warn=function(t){console.warn(this.format(t,"warn"))},n.info=function(t){console.info(this.format(t,"info"))},t}());t.PluginPosition=void 0,(zt=t.PluginPosition||(t.PluginPosition={}))[zt.Header=0]="Header",zt[zt.Footer=1]="Footer",zt[zt.Cell=2]="Cell";var $t=/*#__PURE__*/function(){function t(){this.plugins=void 0,this.plugins=[]}var n=t.prototype;return n.get=function(t){return this.plugins.find(function(n){return n.id===t})},n.add=function(t){return t.id?this.get(t.id)?(Vt.error("Duplicate plugin ID: "+t.id),this):(this.plugins.push(t),this):(Vt.error("Plugin ID cannot be empty"),this)},n.remove=function(t){var n=this.get(t);return n&&this.plugins.splice(this.plugins.indexOf(n),1),this},n.list=function(t){var n;return n=null!=t||null!=t?this.plugins.filter(function(n){return n.position===t}):this.plugins,n.sort(function(t,n){return t.order&&n.order?t.order-n.order:1})},t}();function Gt(t){var n=this,e=It();if(t.pluginId){var o=e.plugin.get(t.pluginId);return o?x(P,{},x(o.component,r({plugin:o},t.props))):null}return void 0!==t.position?x(P,{},e.plugin.list(t.position).map(function(t){return x(t.component,r({plugin:t},n.props.props))})):null}var Kt=/*#__PURE__*/function(n){function i(){var t;return(t=n.call(this)||this)._columns=void 0,t._columns=[],t}o(i,n);var u=i.prototype;return u.adjustWidth=function(t,n,e){var o=t.container,u=t.autoWidth;if(!o)return this;var s=o.clientWidth,l={};n.current&&u&&(q(x(Bt,{tableRef:n.current}),e.current),l=function(t){var n=t.querySelector("table");if(!n)return{};var e=n.className,o=n.style.cssText;n.className=e+" "+et("shadowTable"),n.style.tableLayout="auto",n.style.width="auto",n.style.padding="0",n.style.margin="0",n.style.border="none",n.style.outline="none";var i=Array.from(n.parentNode.querySelectorAll("thead th")).reduce(function(t,n){var e;return n.style.width=n.clientWidth+"px",r(((e={})[n.getAttribute("data-column-id")]={minWidth:n.clientWidth},e),t)},{});return n.className=e,n.style.cssText=o,n.style.tableLayout="auto",Array.from(n.parentNode.querySelectorAll("thead th")).reduce(function(t,n){return t[n.getAttribute("data-column-id")].width=n.clientWidth,t},i)}(e.current));for(var c,f=a(i.tabularFormat(this.columns).reduce(function(t,n){return t.concat(n)},[]));!(c=f()).done;){var p=c.value;p.columns&&p.columns.length>0||(!p.width&&u?p.id in l&&(p.width=Wt(l[p.id].width),p.minWidth=Wt(l[p.id].minWidth)):p.width=Wt(Ut(p.width,s)))}return n.current&&u&&q(null,e.current),this},u.setSort=function(t,n){for(var e,o=a(n||this.columns||[]);!(e=o()).done;){var i=e.value;i.columns&&i.columns.length>0?i.sort=void 0:void 0===i.sort&&t?i.sort={}:i.sort?"object"==typeof i.sort&&(i.sort=r({},i.sort)):i.sort=void 0,i.columns&&this.setSort(t,i.columns)}},u.setResizable=function(t,n){for(var e,r=a(n||this.columns||[]);!(e=r()).done;){var o=e.value;void 0===o.resizable&&(o.resizable=t),o.columns&&this.setResizable(t,o.columns)}},u.setID=function(t){for(var n,e=a(t||this.columns||[]);!(n=e()).done;){var r=n.value;r.id||"string"!=typeof r.name||(r.id=qt(r.name)),r.id||Vt.error('Could not find a valid ID for one of the columns. Make sure a valid "id" is set for all columns.'),r.columns&&this.setID(r.columns)}},u.populatePlugins=function(n,e){for(var o,i=a(e);!(o=i()).done;){var u=o.value;void 0!==u.plugin&&n.add(r({id:u.id},u.plugin,{position:t.PluginPosition.Cell}))}},i.fromColumns=function(t){for(var n,e=new i,r=a(t);!(n=r()).done;){var o=n.value;if("string"==typeof o||d(o))e.columns.push({name:o});else if("object"==typeof o){var u=o;u.columns&&(u.columns=i.fromColumns(u.columns).columns),"object"==typeof u.plugin&&void 0===u.data&&(u.data=null),e.columns.push(o)}}return e},i.createFromConfig=function(t){var n=new i;return t.from?n.columns=i.fromHTMLTable(t.from).columns:t.columns?n.columns=i.fromColumns(t.columns).columns:!t.data||"object"!=typeof t.data[0]||t.data[0]instanceof Array||(n.columns=Object.keys(t.data[0]).map(function(t){return{name:t}})),n.columns.length?(n.setID(),n.setSort(t.sort),n.setResizable(t.resizable),n.populatePlugins(t.plugin,n.columns),n):null},i.fromHTMLTable=function(t){for(var n,e=new i,r=a(t.querySelector("thead").querySelectorAll("th"));!(n=r()).done;){var o=n.value;e.columns.push({name:o.innerHTML,width:o.width})}return e},i.tabularFormat=function(t){var n=[],e=t||[],r=[];if(e&&e.length){n.push(e);for(var o,i=a(e);!(o=i()).done;){var u=o.value;u.columns&&u.columns.length&&(r=r.concat(u.columns))}r.length&&(n=n.concat(this.tabularFormat(r)))}return n},i.leafColumns=function(t){var n=[],e=t||[];if(e&&e.length)for(var r,o=a(e);!(r=o()).done;){var i=r.value;i.columns&&0!==i.columns.length||n.push(i),i.columns&&(n=n.concat(this.leafColumns(i.columns)))}return n},i.maximumDepth=function(t){return this.tabularFormat([t]).length-1},e(i,[{key:"columns",get:function(){return this._columns},set:function(t){this._columns=t}},{key:"visibleColumns",get:function(){return this._columns.filter(function(t){return!t.hidden})}}]),i}(V),Xt=function(){},Zt=/*#__PURE__*/function(t){function n(n){var e;return(e=t.call(this)||this).data=void 0,e.set(n),e}o(n,t);var e=n.prototype;return e.get=function(){try{return Promise.resolve(this.data()).then(function(t){return{data:t,total:t.length}})}catch(t){return Promise.reject(t)}},e.set=function(t){return t instanceof Array?this.data=function(){return t}:t instanceof Function&&(this.data=t),this},n}(Xt),Jt=/*#__PURE__*/function(t){function n(n){var e;return(e=t.call(this)||this).options=void 0,e.options=n,e}o(n,t);var e=n.prototype;return e.handler=function(t){return"function"==typeof this.options.handle?this.options.handle(t):t.ok?t.json():(Vt.error("Could not fetch data: "+t.status+" - "+t.statusText,!0),null)},e.get=function(t){var n=r({},this.options,t);return"function"==typeof n.data?n.data(n):fetch(n.url,n).then(this.handler.bind(this)).then(function(t){return{data:n.then(t),total:"function"==typeof n.total?n.total(t):void 0}})},n}(Xt),Qt=/*#__PURE__*/function(){function t(){}return t.createFromConfig=function(t){var n=null;return t.data&&(n=new Zt(t.data)),t.from&&(n=new Zt(this.tableElementToArray(t.from)),t.from.style.display="none"),t.server&&(n=new Jt(t.server)),n||Vt.error("Could not determine the storage type",!0),n},t.tableElementToArray=function(t){for(var n,e,r=[],o=a(t.querySelector("tbody").querySelectorAll("tr"));!(n=o()).done;){for(var i,u=[],s=a(n.value.querySelectorAll("td"));!(i=s()).done;){var l=i.value;1===l.childNodes.length&&l.childNodes[0].nodeType===Node.TEXT_NODE?u.push((e=l.innerHTML,(new DOMParser).parseFromString(e,"text/html").documentElement.textContent)):u.push(G(l.innerHTML))}r.push(u)}return r},t}(),Yt="undefined"!=typeof Symbol?Symbol.iterator||(Symbol.iterator=Symbol("Symbol.iterator")):"@@iterator";function tn(t,n,e){if(!t.s){if(e instanceof nn){if(!e.s)return void(e.o=tn.bind(null,t,n));1&n&&(n=e.s),e=e.v}if(e&&e.then)return void e.then(tn.bind(null,t,n),tn.bind(null,t,2));t.s=n,t.v=e;var r=t.o;r&&r(t)}}var nn=/*#__PURE__*/function(){function t(){}return t.prototype.then=function(n,e){var r=new t,o=this.s;if(o){var i=1&o?n:e;if(i){try{tn(r,1,i(this.v))}catch(t){tn(r,2,t)}return r}return this}return this.o=function(t){try{var o=t.v;1&t.s?tn(r,1,n?n(o):o):e?tn(r,1,e(o)):tn(r,2,o)}catch(t){tn(r,2,t)}},r},t}();function en(t){return t instanceof nn&&1&t.s}var rn=/*#__PURE__*/function(t){function n(n){var e;return(e=t.call(this)||this)._steps=new Map,e.cache=new Map,e.lastProcessorIndexUpdated=-1,n&&n.forEach(function(t){return e.register(t)}),e}o(n,t);var r=n.prototype;return r.clearCache=function(){this.cache=new Map,this.lastProcessorIndexUpdated=-1},r.register=function(t,n){if(void 0===n&&(n=null),!t)throw Error("Processor is not defined");if(null===t.type)throw Error("Processor type is not defined");if(this.findProcessorIndexByID(t.id)>-1)throw Error("Processor ID "+t.id+" is already defined");return t.on("propsUpdated",this.processorPropsUpdated.bind(this)),this.addProcessorByPriority(t,n),this.afterRegistered(t),t},r.tryRegister=function(t,n){void 0===n&&(n=null);try{return this.register(t,n)}catch(t){}},r.unregister=function(t){if(t&&-1!==this.findProcessorIndexByID(t.id)){var n=this._steps.get(t.type);n&&n.length&&(this._steps.set(t.type,n.filter(function(n){return n!=t})),this.emit("updated",t))}},r.addProcessorByPriority=function(t,n){var e=this._steps.get(t.type);if(!e){var r=[];this._steps.set(t.type,r),e=r}if(null===n||n<0)e.push(t);else if(e[n]){var o=e.slice(0,n-1),i=e.slice(n+1);this._steps.set(t.type,o.concat(t).concat(i))}else e[n]=t},r.getStepsByType=function(t){return this.steps.filter(function(n){return n.type===t})},r.getSortedProcessorTypes=function(){return Object.keys(K).filter(function(t){return!isNaN(Number(t))}).map(function(t){return Number(t)})},r.process=function(t){try{var n=function(t){return e.lastProcessorIndexUpdated=o.length,e.emit("afterProcess",i),i},e=this,r=e.lastProcessorIndexUpdated,o=e.steps,i=t,u=function(t,n){try{var u=function(t,n,e){if("function"==typeof t[Yt]){var r,o,i,u=t[Yt]();if(function t(e){try{for(;!(r=u.next()).done;)if((e=n(r.value))&&e.then){if(!en(e))return void e.then(t,i||(i=tn.bind(null,o=new nn,2)));e=e.v}o?tn(o,1,e):o=e}catch(t){tn(o||(o=new nn),2,t)}}(),u.return){var s=function(t){try{r.done||u.return()}catch(t){}return t};if(o&&o.then)return o.then(s,function(t){throw s(t)});s()}return o}if(!("length"in t))throw new TypeError("Object is not iterable");for(var a=[],l=0;l=r)return Promise.resolve(t.process(i)).then(function(n){e.cache.set(t.id,i=n)});i=e.cache.get(t.id)}();if(o&&o.then)return o.then(function(){})})}catch(t){return n(t)}return u&&u.then?u.then(void 0,n):u}(0,function(t){throw Vt.error(t),e.emit("error",i),t});return Promise.resolve(u&&u.then?u.then(n):n())}catch(t){return Promise.reject(t)}},r.findProcessorIndexByID=function(t){return this.steps.findIndex(function(n){return n.id==t})},r.setLastProcessorIndex=function(t){var n=this.findProcessorIndexByID(t.id);this.lastProcessorIndexUpdated>n&&(this.lastProcessorIndexUpdated=n)},r.processorPropsUpdated=function(t){this.setLastProcessorIndex(t),this.emit("propsUpdated"),this.emit("updated",t)},r.afterRegistered=function(t){this.setLastProcessorIndex(t),this.emit("afterRegister"),this.emit("updated",t)},e(n,[{key:"steps",get:function(){for(var t,n=[],e=a(this.getSortedProcessorTypes());!(t=e()).done;){var r=this._steps.get(t.value);r&&r.length&&(n=n.concat(r))}return n.filter(function(t){return t})}}]),n}(Q),on=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}return o(n,t),n.prototype._process=function(t){try{return Promise.resolve(this.props.storage.get(t))}catch(t){return Promise.reject(t)}},e(n,[{key:"type",get:function(){return K.Extractor}}]),n}(tt),un=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}return o(n,t),n.prototype._process=function(t){var n=J.fromArray(t.data);return n.length=t.total,n},e(n,[{key:"type",get:function(){return K.Transformer}}]),n}(tt),sn=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}return o(n,t),n.prototype._process=function(){return Object.entries(this.props.serverStorageOptions).filter(function(t){return"function"!=typeof t[1]}).reduce(function(t,n){var e;return r({},t,((e={})[n[0]]=n[1],e))},{})},e(n,[{key:"type",get:function(){return K.Initiator}}]),n}(tt),an=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}o(n,t);var r=n.prototype;return r.castData=function(t){if(!t||!t.length)return[];if(!this.props.header||!this.props.header.columns)return t;var n=Kt.leafColumns(this.props.header.columns);return t[0]instanceof Array?t.map(function(t){var e=0;return n.map(function(n,r){return void 0!==n.data?(e++,"function"==typeof n.data?n.data(t):n.data):t[r-e]})}):"object"!=typeof t[0]||t[0]instanceof Array?[]:t.map(function(t){return n.map(function(n,e){return void 0!==n.data?"function"==typeof n.data?n.data(t):n.data:n.id?t[n.id]:(Vt.error("Could not find the correct cell for column at position "+e+".\n Make sure either 'id' or 'selector' is defined for all columns."),null)})})},r._process=function(t){return{data:this.castData(t.data),total:t.total}},e(n,[{key:"type",get:function(){return K.Transformer}}]),n}(tt),ln=/*#__PURE__*/function(){function t(){}return t.createFromConfig=function(t){var n=new rn;return t.storage instanceof Jt&&n.register(new sn({serverStorageOptions:t.server})),n.register(new on({storage:t.storage})),n.register(new an({header:t.header})),n.register(new un),n},t}(),cn=function(t){var n=this;this.state=void 0,this.listeners=[],this.isDispatching=!1,this.getState=function(){return n.state},this.getListeners=function(){return n.listeners},this.dispatch=function(t){if("function"!=typeof t)throw new Error("Reducer is not a function");if(n.isDispatching)throw new Error("Reducers may not dispatch actions");n.isDispatching=!0;var e=n.state;try{n.state=t(n.state)}finally{n.isDispatching=!1}for(var r,o=a(n.listeners);!(r=o()).done;)(0,r.value)(n.state,e);return n.state},this.subscribe=function(t){if("function"!=typeof t)throw new Error("Listener is not a function");return n.listeners=[].concat(n.listeners,[t]),function(){return n.listeners=n.listeners.filter(function(n){return n!==t})}},this.state=t},fn=function(t,n){var e={__c:n="__cC"+m++,__:null,Consumer:function(t,n){return t.children(n)},Provider:function(t){var e,r;return this.getChildContext||(e=[],(r={})[n]=this,this.getChildContext=function(){return r},this.shouldComponentUpdate=function(t){this.props.value!==t.value&&e.some(E)},this.sub=function(t){e.push(t);var n=t.componentWillUnmount;t.componentWillUnmount=function(){e.splice(e.indexOf(t),1),n&&n.call(t)}}),t.children}};return e.Provider.__=e.Consumer.contextType=e}(),pn=/*#__PURE__*/function(){function n(){Object.assign(this,n.defaultConfig())}var e=n.prototype;return e.assign=function(t){return Object.assign(this,t)},e.update=function(t){return t?(this.assign(n.fromPartialConfig(r({},this,t))),this):this},n.defaultConfig=function(){return{store:new cn({status:l.Init,header:void 0,data:null}),plugin:new $t,tableRef:{current:null},width:"100%",height:"auto",processingThrottleMs:100,autoWidth:!0,style:{},className:{}}},n.fromPartialConfig=function(e){var r=(new n).assign(e);return"boolean"==typeof e.sort&&e.sort&&r.assign({sort:{multiColumn:!0}}),r.assign({header:Kt.createFromConfig(r)}),r.assign({storage:Qt.createFromConfig(r)}),r.assign({pipeline:ln.createFromConfig(r)}),r.assign({translator:new Lt(r.language)}),r.plugin=new $t,r.search&&r.plugin.add({id:"search",position:t.PluginPosition.Header,component:Dt}),r.pagination&&r.plugin.add({id:"pagination",position:t.PluginPosition.Footer,component:Ft}),r.plugins&&r.plugins.forEach(function(t){return r.plugin.add(t)}),r},n}();function dn(t){var n,e=It();return x("td",r({role:t.role,colSpan:t.colSpan,"data-column-id":t.column&&t.column.id,className:rt(et("td"),t.className,e.className.td),style:r({},t.style,e.style.td),onClick:function(n){t.messageCell||e.eventEmitter.emit("cellClick",n,t.cell,t.column,t.row)}},(n=t.column)?"function"==typeof n.attributes?n.attributes(t.cell.data,t.row,t.column):n.attributes:{}),t.column&&"function"==typeof t.column.formatter?t.column.formatter(t.cell.data,t.row,t.column):t.column&&t.column.plugin?x(Gt,{pluginId:t.column.id,props:{column:t.column,cell:t.cell,row:t.row}}):t.cell.data)}function hn(t){var n=It(),e=Ht(function(t){return t.header});return x("tr",{className:rt(et("tr"),n.className.tr),onClick:function(e){t.messageRow||n.eventEmitter.emit("rowClick",e,t.row)}},t.children?t.children:t.row.cells.map(function(n,r){var o=function(t){if(e){var n=Kt.leafColumns(e.columns);if(n)return n[t]}return null}(r);return o&&o.hidden?null:x(dn,{key:n.id,cell:n,row:t.row,column:o})}))}function _n(t){return x(hn,{messageRow:!0},x(dn,{role:"alert",colSpan:t.colSpan,messageCell:!0,cell:new X(t.message),className:rt(et("message"),t.className?t.className:null)}))}function mn(){var t=It(),n=Ht(function(t){return t.data}),e=Ht(function(t){return t.status}),r=Ht(function(t){return t.header}),o=At(),i=function(){return r?r.visibleColumns.length:0};return x("tbody",{className:rt(et("tbody"),t.className.tbody)},n&&n.rows.map(function(t){return x(hn,{key:t.id,row:t})}),e===l.Loading&&(!n||0===n.length)&&x(_n,{message:o("loading"),colSpan:i(),className:rt(et("loading"),t.className.loading)}),e===l.Rendered&&n&&0===n.length&&x(_n,{message:o("noRecordsFound"),colSpan:i(),className:rt(et("notfound"),t.className.notfound)}),e===l.Error&&x(_n,{message:o("error"),colSpan:i(),className:rt(et("error"),t.className.error)}))}var vn=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}o(n,t);var r=n.prototype;return r.validateProps=function(){for(var t,n=a(this.props.columns);!(t=n()).done;){var e=t.value;void 0===e.direction&&(e.direction=1),1!==e.direction&&-1!==e.direction&&Vt.error("Invalid sort direction "+e.direction)}},r.compare=function(t,n){return t>n?1:t1&&(f=!0,c=!0):0===a?c=!0:a>0&&!e?(c=!0,f=!0):a>0&&e&&(c=!0),f&&(s=[]),c)s.push({index:t,direction:n,compare:o});else if(d){var h=s.indexOf(l);s[h].direction=n}else if(p){var _=s.indexOf(l);s.splice(_,1)}return r({},i,{sort:{columns:s}})}},yn=function(t,n,e){return function(o){var i=(o.sort?[].concat(o.sort.columns):[]).find(function(n){return n.index===t});return r({},o,i?gn(t,1===i.direction?-1:1,n,e)(o):gn(t,1,n,e)(o))}},bn=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}return o(n,t),n.prototype._process=function(t){var n={};return this.props.url&&(n.url=this.props.url(t.url,this.props.columns)),this.props.body&&(n.body=this.props.body(t.body,this.props.columns)),r({},t,n)},e(n,[{key:"type",get:function(){return K.ServerSort}}]),n}(tt);function wn(t){var n=It(),e=jt().dispatch,o=At(),i=gt(0),u=i[0],s=i[1],a=n.sort,l=Ht(function(t){return t.sort}),c="object"==typeof(null==a?void 0:a.server)?K.ServerSort:K.Sort,f=function(){var t=n.pipeline.getStepsByType(c);if(t.length)return t[0]};return yt(function(){var t=f()||(c===K.ServerSort?new bn(r({columns:l?l.columns:[]},a.server)):new vn({columns:l?l.columns:[]}));return n.pipeline.tryRegister(t),function(){return n.pipeline.unregister(t)}},[n]),yt(function(){if(l){var n,e=l.columns.find(function(n){return n.index===t.index});e?(0===u&&(e.direction=null!=(n=t.direction)?n:1),s(e.direction)):s(0)}},[l]),yt(function(){var t=f();t&&l&&t.setProps({columns:l.columns})},[l]),x("button",{tabIndex:-1,"aria-label":o("sort.sort"+(1===u?"Desc":"Asc")),title:o("sort.sort"+(1===u?"Desc":"Asc")),className:rt(et("sort"),et("sort",function(t){return 1===t?"asc":-1===t?"desc":"neutral"}(u)),n.className.sort),onClick:function(n){n.preventDefault(),n.stopPropagation(),e(yn(t.index,!0===n.shiftKey&&a.multiColumn,t.compare))}})}var xn=function(t,n){var e;void 0===n&&(n=100);var r=Date.now(),o=function(){r=Date.now(),t.apply(void 0,[].slice.call(arguments))};return function(){var t=[].slice.call(arguments),i=Date.now(),u=i-r;u>=n?o.apply(void 0,t):(e&&clearTimeout(e),e=setTimeout(function(){o.apply(void 0,t),e=null},n-u))}};function kn(t){var n,e=function(t){return t instanceof MouseEvent?Math.floor(t.pageX):Math.floor(t.changedTouches[0].pageX)},r=function(r){r.stopPropagation();var u=parseInt(t.thRef.current.style.width,10)-e(r);n=xn(function(t){return o(t,u)},10),document.addEventListener("mouseup",i),document.addEventListener("touchend",i),document.addEventListener("mousemove",n),document.addEventListener("touchmove",n)},o=function(n,r){n.stopPropagation();var o=t.thRef.current;r+e(n)>=parseInt(o.style.minWidth,10)&&(o.style.width=r+e(n)+"px")},i=function t(e){e.stopPropagation(),document.removeEventListener("mouseup",t),document.removeEventListener("mousemove",n),document.removeEventListener("touchmove",n),document.removeEventListener("touchend",t)};return x("div",{className:rt(et("th"),et("resizable")),onMouseDown:r,onTouchStart:r,onClick:function(t){return t.stopPropagation()}})}function Pn(t){var n=It(),e=bt(null),o=gt({}),i=o[0],u=o[1],s=jt().dispatch;yt(function(){if(n.fixedHeader&&e.current){var t=e.current.offsetTop;"number"==typeof t&&u({top:t})}},[e]);var a,l=function(){return null!=t.column.sort},c=function(e){e.stopPropagation(),l()&&s(yn(t.index,!0===e.shiftKey&&n.sort.multiColumn,t.column.sort.compare))};return x("th",r({ref:e,"data-column-id":t.column&&t.column.id,className:rt(et("th"),l()?et("th","sort"):null,n.fixedHeader?et("th","fixed"):null,n.className.th),onClick:c,style:r({},n.style.th,{minWidth:t.column.minWidth,width:t.column.width},i,t.style),onKeyDown:function(t){l()&&13===t.which&&c(t)},rowSpan:t.rowSpan>1?t.rowSpan:void 0,colSpan:t.colSpan>1?t.colSpan:void 0},(a=t.column)?"function"==typeof a.attributes?a.attributes(null,null,t.column):a.attributes:{},l()?{tabIndex:0}:{}),x("div",{className:et("th","content")},void 0!==t.column.name?t.column.name:void 0!==t.column.plugin?x(Gt,{pluginId:t.column.plugin.id,props:{column:t.column}}):null),l()&&x(wn,r({index:t.index},t.column.sort)),t.column.resizable&&t.index0?(Vt.error("The container element "+t+" is not empty. Make sure the container is empty and call render() again"),this):(this.config.container=t,q(this.createElement(),t),this)},n}(Q);t.Cell=X,t.Component=S,t.Config=pn,t.Grid=Ln,t.Row=Z,t.className=et,t.createElement=x,t.createRef=function(){return{current:null}},t.h=x,t.html=G,t.useConfig=It,t.useEffect=yt,t.useRef=bt,t.useSelector=Ht,t.useState=gt,t.useStore=jt,t.useTranslator=At}); +//# sourceMappingURL=gridjs.umd.js.map diff --git a/templates/artifacts/_artifact_rows.html b/templates/artifacts/_artifact_rows.html deleted file mode 100644 index 37c9ed1..0000000 --- a/templates/artifacts/_artifact_rows.html +++ /dev/null @@ -1,42 +0,0 @@ -{% for artifact in artifacts %} -
- - - {{ artifact.title }} - {% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %} - K - 🔗 - {% if artifact.label_chips %} - - {% for label in artifact.label_chips %} - {{ label.name }} - {% endfor %} - - {% endif %} - - {{ artifact.source }} - - {% if in_trash %} - ↩ - {% else %} - 🗑 - ⋯ - {% endif %} - - -{% endfor %} diff --git a/templates/artifacts/_day_groups.html b/templates/artifacts/_day_groups.html index 63971f4..111fb86 100644 --- a/templates/artifacts/_day_groups.html +++ b/templates/artifacts/_day_groups.html @@ -1,10 +1,43 @@ {% for group in day_groups %} -
+ {% endfor %} {% if has_more %} diff --git a/templates/artifacts/_topbar.html b/templates/artifacts/_topbar.html index cda9526..5dbcde5 100644 --- a/templates/artifacts/_topbar.html +++ b/templates/artifacts/_topbar.html @@ -1,4 +1,3 @@ -{% load agentfeed_extras %}

AgentFeed

@@ -7,12 +6,12 @@ {% if topbar_toolbar %}{% include topbar_toolbar %}{% endif %}
-