diff --git a/.claude/commands/seed-demo.md b/.claude/commands/seed-demo.md index ccca3ce..18145b7 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 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. +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. 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 new file mode 100644 index 0000000..2cb6ed3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,280 @@ +# 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 f612d1d..86baea8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Project -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. +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. 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,10 +11,11 @@ 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 whole app, including the feed and API reads. Ingest (POST/PATCH) stays per-user bearer-token authenticated, since agents don't have browser sessions. +- 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. - 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`. @@ -44,7 +45,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/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. +- 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. - 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 new file mode 100644 index 0000000..a2bacf2 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# 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 0a0d7f2..347749d 100644 --- a/artifacts/admin.py +++ b/artifacts/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Artifact, Asset, UserProfile +from .models import ApiKey, Artifact, Asset, TagColor, UserProfile @admin.register(Artifact) @@ -21,3 +21,18 @@ 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 fa09eba..205752b 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 daybook_exception_handler(exc, context): +def agentfeed_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 44f8e98..52f2d29 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 DaybookLoginForm(AuthenticationForm): +class AgentFeedLoginForm(AuthenticationForm): remember_me = forms.BooleanField(required=False, initial=True) diff --git a/artifacts/migrations/0014_apikey.py b/artifacts/migrations/0014_apikey.py new file mode 100644 index 0000000..09e31ce --- /dev/null +++ b/artifacts/migrations/0014_apikey.py @@ -0,0 +1,31 @@ +# 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 new file mode 100644 index 0000000..46bb6e7 --- /dev/null +++ b/artifacts/migrations/0015_migrate_tokens_to_apikeys.py @@ -0,0 +1,32 @@ +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 new file mode 100644 index 0000000..0454f2f --- /dev/null +++ b/artifacts/migrations/0016_artifact_deleted_at.py @@ -0,0 +1,18 @@ +# 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 new file mode 100644 index 0000000..aedcc1a --- /dev/null +++ b/artifacts/migrations/0017_label.py @@ -0,0 +1,29 @@ +# 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 446454f..dcba524 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -1,7 +1,14 @@ +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" @@ -53,6 +60,9 @@ 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 @@ -67,6 +77,10 @@ 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"] @@ -82,6 +96,23 @@ 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 @@ -92,6 +123,28 @@ 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 1ac53cd..df53526 100644 --- a/artifacts/storage.py +++ b/artifacts/storage.py @@ -7,9 +7,11 @@ 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 @@ -70,8 +72,13 @@ 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).aggregate(total=Sum("size_bytes")).get("total") or 0 + Artifact.objects.filter(owner=user, deleted_at__isnull=True) + .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 @@ -113,8 +120,10 @@ 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).order_by("created_at") + Artifact.objects.filter(owner=user, is_kept=False, deleted_at__isnull=True).order_by("created_at") ) # Check whether eviction can possibly succeed before deleting anything - # otherwise a doomed request would still destroy every unkept artifact on @@ -137,6 +146,55 @@ 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 new file mode 100644 index 0000000..e69de29 diff --git a/artifacts/templatetags/agentfeed_extras.py b/artifacts/templatetags/agentfeed_extras.py new file mode 100644 index 0000000..bb27bde --- /dev/null +++ b/artifacts/templatetags/agentfeed_extras.py @@ -0,0 +1,30 @@ +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 98323e3..d7adc5d 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -6,18 +6,30 @@ 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 555c121..bd2fccc 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -15,28 +15,29 @@ import nh3 import openpyxl from django.conf import settings from django.contrib import messages -from django.contrib.auth import views as auth_views +from django.contrib.auth import update_session_auth_hash, 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, Q, Sum +from django.db.models import Count, Max, 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 slugify +from django.template.defaultfilters import date as date_filter, slugify +from django.template.loader import render_to_string from django.urls import reverse -from django.utils.dateparse import parse_date +from django.utils.dateparse import parse_date, parse_datetime 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 DaybookLoginForm -from .models import Artifact, Asset, TagColor +from .forms import AgentFeedLoginForm +from .models import ApiKey, Artifact, Asset, Label, TagColor from .storage import ( QuotaExceeded, SIDECAR_SUFFIXES, @@ -44,6 +45,8 @@ 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, @@ -67,7 +70,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 Daybook asset URL, e.g. /assets/ab12cd34ef56 +# Matches the asset id in a AgentFeed asset URL, e.g. /assets/ab12cd34ef56 ASSET_URL_RE = re.compile(r"/assets/([0-9a-f]{12})") @@ -83,12 +86,14 @@ DEFAULT_THEMES = { } -@method_decorator(login_required, name="get") class ArtifactListCreateView(APIView): parser_classes = [JSONParser, FormParser, MultiPartParser] def get(self, request): - artifacts = Artifact.objects.filter(owner=request.user) + 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) if source := request.query_params.get("source"): artifacts = artifacts.filter(source=source) if label := request.query_params.get("label"): @@ -224,10 +229,12 @@ 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): - artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) + 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) return Response(serialize_artifact(artifact, request)) def patch(self, request, artifact_id): @@ -235,7 +242,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) + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True) data = request.data not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys()) if not_editable: @@ -294,21 +301,67 @@ 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) - delete_artifact_files(artifact) - artifact.delete() + 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 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): - if not request.user.is_authenticated and resolve_token_user(request) is None: - return HttpResponseForbidden("Login or a valid API token is required") + # Public: capability/contract info only, no user data. 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__, @@ -318,15 +371,121 @@ 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": { - "/api/artifacts": ["GET", "POST"], - "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], - "/api/assets": ["POST"], - }, + "methods": methods, + "skill_url": request.build_absolute_uri(reverse("skill-api")), + "key_provisioning_api": settings.AGENTFEED_KEY_PROVISIONING_API, } ) +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 @@ -364,21 +523,40 @@ 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 = "" - all_artifacts = Artifact.objects.filter(owner=request.user) + # 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 artifacts = apply_feed_filters( - all_artifacts, selected_source, selected_label, selected_kind, title_query + base, selected_source, selected_label, selected_kind, title_query ) - 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() + 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() overrides = tag_color_overrides() day_groups = group_artifacts_by_day(window_artifacts, overrides) @@ -388,6 +566,8 @@ 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: @@ -402,18 +582,22 @@ def feed(request): toggle_query["view"] = "raw" raw_url = f"?{urlencode(toggle_query)}" - # Source counts come from the other filters only, so the sidebar still shows - # every source available under the current label/type/name filters. + # Source counts + labels always come from live artifacts, so the sidebar is + # the same whichever folder you're viewing. source_scoped = apply_feed_filters( - all_artifacts, "", selected_label, selected_kind, title_query + live, "", 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))) - .order_by("source") + .annotate(count=Count("id", filter=Q(read_at__isnull=True)), latest=Max("created_at")) + .order_by("-latest") ) + 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 all_artifacts.values_list("tags", flat=True) for tag in tags}) + all_labels = sorted({tag for tags in live.values_list("tags", flat=True) for tag in tags}) return render( request, "artifacts/feed.html", @@ -422,8 +606,14 @@ 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, @@ -436,6 +626,7 @@ def feed(request): "today": today, "title_query": title_query, "kind_choices": Artifact.KIND_CHOICES, + "label_palette": request.user.labels.all(), }, ) @@ -450,6 +641,31 @@ 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): @@ -476,12 +692,58 @@ 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), + Artifact.objects.filter(owner=request.user, deleted_at__isnull=True), request.GET.get("source", ""), request.GET.get("label", ""), request.GET.get("kind", ""), @@ -515,6 +777,58 @@ 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): @@ -742,7 +1056,7 @@ def create_binary_artifact(data, request, user): # Allowlist for sanitizing the "themed" text kinds (markdown, deck, -# html_fragment) — kinds where Daybook, not the poster, owns presentation, so +# html_fragment) — kinds where AgentFeed, 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 @@ -847,14 +1161,14 @@ def wrap_json_viewer(content, title, request): - -
+ +
@@ -891,18 +1205,18 @@ def wrap_csv_viewer(content, title, request): - -
+ +
@@ -946,9 +1260,24 @@ def resolve_token_user(request): if not key: return None try: - return Token.objects.select_related("user").get(key=key).user - except Token.DoesNotExist: + api_key = ApiKey.objects.select_related("user").get(key=key) + except ApiKey.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): @@ -1140,6 +1469,7 @@ 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, @@ -1184,9 +1514,9 @@ def raw_artifact_path(artifact): return None -class DaybookLoginView(auth_views.LoginView): +class AgentFeedLoginView(auth_views.LoginView): template_name = "artifacts/login.html" - authentication_form = DaybookLoginForm + authentication_form = AgentFeedLoginForm def form_valid(self, form): response = super().form_valid(form) @@ -1204,16 +1534,94 @@ def settings_home(request): @login_required def settings_account(request): - 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") + 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) return render( request, "artifacts/settings_account.html", - {"token": token, "active_section": "account"}, + { + "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()}, ) @@ -1223,7 +1631,7 @@ def settings_users(request): return HttpResponseForbidden("Admin access required") created_user = None - created_token = None + created_key = None if request.method == "POST": username = request.POST.get("username", "").strip() password = request.POST.get("password", "") @@ -1237,7 +1645,7 @@ def settings_users(request): if grant_admin: created_user.is_superuser = True created_user.save(update_fields=["is_superuser"]) - created_token = Token.objects.create(user=created_user) + created_key = ApiKey.objects.create(user=created_user, name="default") messages.success(request, f"Created user '{username}'.") return render( @@ -1247,7 +1655,7 @@ def settings_users(request): "active_section": "users", "users": User.objects.order_by("username"), "created_user": created_user, - "created_token": created_token, + "created_key": created_key, }, ) @@ -1317,7 +1725,7 @@ def raw_unavailable_html(artifact):

Raw source unavailable

-

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

+

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

diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index 29f4719..a5bfd5e 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.1.0" +__version__ = "0.7.1" diff --git a/daybook_project/settings.py b/daybook_project/settings.py index ee85175..a041730 100644 --- a/daybook_project/settings.py +++ b/daybook_project/settings.py @@ -3,12 +3,26 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent -DATA_DIR = Path(os.environ.get("DAYBOOK_DATA_DIR", BASE_DIR)).resolve() +DATA_DIR = Path(os.environ.get("AGENTFEED_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. @@ -60,7 +74,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.daybook_exception_handler", + "EXCEPTION_HANDLER": "artifacts.exception_handlers.agentfeed_exception_handler", } DATABASES = { diff --git a/daybook_project/urls.py b/daybook_project/urls.py index 4204d51..b08c354 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 DaybookLoginView +from artifacts.views import AgentFeedLoginView urlpatterns = [ path("admin/", admin.site.urls), - path("accounts/login/", DaybookLoginView.as_view(), name="login"), + path("accounts/login/", AgentFeedLoginView.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 d90f5db..dfbde41 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 -Daybook on a cloud VM. Treat it as a guide and a source of Daybook-specific +AgentFeed on a cloud VM. Treat it as a guide and a source of AgentFeed-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, Daybook-specific +this file is the recommended path plus the non-obvious, AgentFeed-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 "daybook-deploy-agent" -f ~/.ssh/daybook_deploy -N "" + ssh-keygen -t ed25519 -C "agentfeed-deploy-agent" -f ~/.ssh/agentfeed_deploy -N "" ``` -2. Ask the operator to add the **public** key (`~/.ssh/daybook_deploy.pub`) as +2. Ask the operator to add the **public** key (`~/.ssh/agentfeed_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/daybook_deploy + IdentityFile ~/.ssh/agentfeed_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 -DAYBOOK_DATA_DIR=/data +AGENTFEED_DATA_DIR=/data DATABASE_PATH=/data/db.sqlite3 ARTIFACTS_ROOT=/data/artifacts_store ``` -There is no shared API token to set — Daybook issues a personal token per +There is no shared API token to set — AgentFeed 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 Daybook is not optional — it's the only path.** Bearer tokens are +in front of AgentFeed 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 `DAYBOOK_BIND_ADDR=127.0.0.1` in +- **Internet-facing (the common case):** set `AGENTFEED_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 `DAYBOOK_BIND_ADDR` unset. + reach it (never `0.0.0.0/0`), and leave `AGENTFEED_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 Daybook's persistent data at it before -starting: set `DAYBOOK_HOST_DATA_DIR=/path/on/that/volume` in `deploy/.env`. +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`. 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 DAYBOOK_HOST_DATA_DIR path if you set one above +mkdir -p deploy/data # or the AGENTFEED_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 daybook python manage.py createsuperuser +docker compose -f deploy/docker-compose.yml exec agentfeed 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 -`DAYBOOK_BIND_ADDR` is set. Log in from a browser at whichever address is +`AGENTFEED_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,30 +202,52 @@ Confirm the response includes `id`, `url`, and `api_url`, and that opening ## 7. Redeploy Loop (routine updates) -This is the command you run every time you're asked to ship a new version: +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: ```bash cd /opt/daybook -PREV_SHA=$(git rev-parse HEAD) -git pull +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) docker compose -f deploy/docker-compose.yml up -d --build ``` -Then repeat the verification in step 6 (capabilities check at minimum). If it -fails: +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: ```bash -git checkout "$PREV_SHA" +git checkout "$PREV_TAG" docker compose -f deploy/docker-compose.yml up -d --build ``` -to roll back, then report the failure (build log / migration error / failed -health check) back to the operator rather than retrying blindly. +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. -`deploy/.env` and the data directory (wherever `DAYBOOK_HOST_DATA_DIR` points, +`deploy/.env` and the data directory (wherever `AGENTFEED_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 @@ -255,14 +277,14 @@ instead. Apache example: ```apache - ServerName daybook.example.com + ServerName agentfeed.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/daybook.example.com/fullchain.pem - SSLCertificateKeyFile /etc/letsencrypt/live/daybook.example.com/privkey.pem + SSLCertificateFile /etc/letsencrypt/live/agentfeed.example.com/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/agentfeed.example.com/privkey.pem ``` @@ -274,7 +296,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 -daybook.example.com { +agentfeed.example.com { reverse_proxy 127.0.0.1:8000 { header_up X-Forwarded-Proto https } @@ -283,15 +305,15 @@ daybook.example.com { ## 9. Backup -If you set `DAYBOOK_HOST_DATA_DIR` in step 5, the real data lives there, not +If you set `AGENTFEED_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 '(?<=^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")" +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")" ``` Copy the resulting archive off the VM (to object storage, the operator's @@ -309,12 +331,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 `daybook` healthy. +- [ ] `docker compose -f deploy/docker-compose.yml ps` shows `agentfeed` 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 `DAYBOOK_HOST_DATA_DIR` +- [ ] A backup of the data directory (`deploy/data`, or `AGENTFEED_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 cbb276c..f3a3376 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,4 +1,4 @@ -# ${VAR} substitution below (DAYBOOK_BIND_ADDR, DAYBOOK_HOST_DATA_DIR) reads +# ${VAR} substitution below (AGENTFEED_BIND_ADDR, AGENTFEED_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: - daybook: + agentfeed: build: context: .. dockerfile: deploy/Dockerfile - container_name: daybook + container_name: agentfeed restart: unless-stopped env_file: - .env mem_limit: 384m mem_reservation: 128m ports: - - "${DAYBOOK_BIND_ADDR:-0.0.0.0}:8000:8000" + - "${AGENTFEED_BIND_ADDR:-0.0.0.0}:8000:8000" volumes: - - ${DAYBOOK_HOST_DATA_DIR:-./data}:/data + - ${AGENTFEED_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 cdd620d..aaea9ca 100644 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -1,7 +1,7 @@ #!/usr/bin/env sh set -eu -mkdir -p "${DAYBOOK_DATA_DIR:-/data}" "${ARTIFACTS_ROOT:-/data/artifacts_store}" +mkdir -p "${AGENTFEED_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 5fe9ca3..6c034a9 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -1,21 +1,26 @@ 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 Daybook sits behind a TLS-terminating reverse proxy (Apache, +# Required if AgentFeed 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 -DAYBOOK_DATA_DIR=/data +# 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 DATABASE_PATH=/data/db.sqlite3 ARTIFACTS_ROOT=/data/artifacts_store # Optional, read by deploy/docker-compose.yml directly (not by the app): -# DAYBOOK_BIND_ADDR=127.0.0.1 +# AGENTFEED_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. -# DAYBOOK_HOST_DATA_DIR=/mnt/some-larger-volume/daybook-data +# AGENTFEED_HOST_DATA_DIR=/mnt/some-larger-volume/agentfeed-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 d6ba619..29339ac 100644 --- a/docs/agent_quickstart.md +++ b/docs/agent_quickstart.md @@ -1,11 +1,12 @@ # Agent Quickstart -This is the shortest path for an agent to publish and retrieve artifacts from Daybook. +This is the shortest path for an agent to publish and retrieve artifacts from AgentFeed. -## 0. Get Your API Token +## 0. Get Your API Key -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 +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 feed, and only yours. ## 1. Check Capabilities @@ -14,7 +15,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 @@ -38,7 +39,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 @@ -68,7 +69,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 ``` @@ -77,7 +78,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" ` @@ -119,7 +120,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 ``` @@ -130,7 +131,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 8d95c6f..413a238 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,6 @@ -# Daybook API +# AgentFeed API -Daybook is built around agents publishing artifacts. Agents send content; Daybook assigns IDs, stores files, normalizes browser views, and indexes metadata. +AgentFeed is built around agents publishing artifacts. Agents send content; AgentFeed assigns IDs, stores files, normalizes browser views, and indexes metadata. Base URL while running locally: @@ -8,19 +8,23 @@ Base URL while running locally: http://127.0.0.1:8001 ``` -Authentication for write endpoints (POST/PATCH) — a personal per-user API token, from Settings → Account in the app: +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: ```http -Authorization: Bearer +Authorization: Bearer ``` -Every artifact belongs to the user whose token authenticated the POST — feeds are private per user. +Every artifact belongs to the user whose key authenticated the POST — feeds are private per user. -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. +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. -`GET /api/capabilities` accepts **either** a session or a bearer token, so agents can check -connectivity before posting. +`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. ## Artifact Kinds @@ -28,7 +32,7 @@ Currently supported kinds: | Kind | Upload type | Rendered behavior | Raw support | |---|---|---|---| -| `html_fragment` | JSON text | Wrapped with a Daybook theme; tables styled automatically; **HTML sanitized** | No | +| `html_fragment` | JSON text | Wrapped with a AgentFeed 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 | @@ -104,7 +108,7 @@ Endpoint: ```http POST /api/artifacts Content-Type: application/json -Authorization: Bearer +Authorization: Bearer ``` Required fields: @@ -184,7 +188,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 ``` @@ -237,7 +241,7 @@ Endpoint: ```http POST /api/artifacts Content-Type: multipart/form-data -Authorization: Bearer +Authorization: Bearer ``` Required fields: @@ -284,7 +288,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" ` @@ -301,7 +305,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 | @@ -343,7 +347,7 @@ Endpoint: GET /api/artifacts ``` -Requires a logged-in browser session. Returns only the current user's artifacts. +Accepts a bearer token or a browser session. Returns only the authenticated user's artifacts. Supported filters (combine freely; all are ANDed): @@ -388,7 +392,7 @@ Endpoint: ```http PATCH /api/artifacts/{id} Content-Type: application/json -Authorization: Bearer +Authorization: Bearer ``` Editable fields: @@ -434,7 +438,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 ``` @@ -445,44 +449,50 @@ Endpoint: ```http DELETE /api/artifacts/{id} -Authorization: Bearer +Authorization: Bearer ``` -Removes the artifact's database row and its files (primary file, markdown -sidecar, and any attached assets). Returns `204` with no body on success. +**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. -You can only delete artifacts your own token owns; others return `404`, as -does a second delete of the same id. +**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. ```bash curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \ - -H "Authorization: Bearer " + -H "Authorization: Bearer " ``` ## Rendered And Raw Content -Rendered browser view: +**For agents (bearer token):** fetch the full content from the API — separate +from the metadata `GET /api/artifacts/{id}`: ```http -GET /a/{id} +GET /api/artifacts/{id}/raw ``` -Raw/source view: +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. -```http -GET /a/{id}?raw=1 -``` +- `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). -Raw currently exists for: +Byte-faithful to what's stored; note the deploy (Linux) preserves `\n` line +endings. -| 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. | +**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. -If raw content is unavailable, Daybook returns a small HTML fallback page. +If raw content is unavailable, AgentFeed returns a small HTML fallback page. ## Sharing @@ -503,7 +513,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 Daybook account. + break for a visitor without a AgentFeed 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. @@ -529,25 +539,84 @@ Example response: "methods": { "/api/artifacts": ["GET", "POST"], "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], - "/api/assets": ["POST"] - } + "/api/assets": ["POST"], + "/api/capabilities": ["GET"], + "/api/skill": ["GET"] + }, + "skill_url": "http://127.0.0.1:8001/api/skill" } ``` 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 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: +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: ```bash -curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/capabilities +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 ``` ## Current MVP Constraints -- Each user has a personal ingest API token; there is no shared token. +- Each user can hold several personal API keys (Settings → API Keys), each + individually revocable; there is no shared key. - 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 aafbfad..27d1fad 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,6 +1,6 @@ # Deployment -Daybook deploys the same way on any Linux host with Docker: a cloud VM, a home +AgentFeed 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 - daybook container + agentfeed 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 Daybook image. | -| `docker-compose.yml` | Runs Daybook with a persistent `./data:/data` bind mount. | +| `Dockerfile` | Builds the AgentFeed image. | +| `docker-compose.yml` | Runs AgentFeed 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,14 +59,15 @@ Edit `deploy/.env` and set at minimum: DJANGO_DEBUG=0 DJANGO_SECRET_KEY= DJANGO_ALLOWED_HOSTS=,localhost,127.0.0.1 -DAYBOOK_DATA_DIR=/data +AGENTFEED_DATA_DIR=/data DATABASE_PATH=/data/db.sqlite3 ARTIFACTS_ROOT=/data/artifacts_store ``` -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`. +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`. Create the persistent data directory and bring the stack up: @@ -76,10 +77,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 token to post through: +a personal API key to post through: ```bash -docker compose -f deploy/docker-compose.yml exec daybook python manage.py createsuperuser +docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py createsuperuser ``` ## Verify Deployment @@ -94,18 +95,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 grab a personal token from Settings → Account and use it for a +created, then create a personal key under Settings → API Keys 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\nDaybook accepted an artifact." + "content": "# Deployment Smoke Test\n\nAgentFeed accepted an artifact." }' ``` @@ -126,37 +127,68 @@ picked up automatically. `deploy/.env` and `deploy/data/` are untouched by ## Operational Commands ```bash -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 logs -f agentfeed +docker compose -f deploy/docker-compose.yml restart agentfeed 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 daybook python manage.py check -docker compose -f deploy/docker-compose.yml exec daybook sh +docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py check +docker compose -f deploy/docker-compose.yml exec agentfeed 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 daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data +tar -czf agentfeed-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 daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data +tar -czf agentfeed-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 Daybook, replace `deploy/data/` with the backup contents, +To restore: stop AgentFeed, replace `deploy/data/` with the backup contents, then start it again. ## Reverse Proxy Notes -If putting Daybook behind Nginx, Caddy, Traefik, or a cloud load balancer: +If putting AgentFeed behind Nginx, Caddy, Traefik, or a cloud load balancer: - Proxy to `http://:8000`. - Add the public hostname to `DJANGO_ALLOWED_HOSTS`. @@ -168,8 +200,8 @@ If putting Daybook behind Nginx, Caddy, Traefik, or a cloud load balancer: terminates TLS and forwards plain HTTP to the container. ```text -DJANGO_ALLOWED_HOSTS=daybook.example.com,localhost,127.0.0.1 -DJANGO_CSRF_TRUSTED_ORIGINS=https://daybook.example.com +DJANGO_ALLOWED_HOSTS=agentfeed.example.com,localhost,127.0.0.1 +DJANGO_CSRF_TRUSTED_ORIGINS=https://agentfeed.example.com ``` Forwarding the proto header, by proxy: @@ -188,15 +220,16 @@ 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 `daybook` healthy. +- `docker compose -f deploy/docker-compose.yml ps` shows `agentfeed` 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 token - from Settings → Account. +- At least one superuser exists (`manage.py createsuperuser`) with a key + from Settings → API Keys. ## Known MVP Constraints - SQLite is used by default — fine for a single-VM self-hosted deployment. -- Per-user ingest tokens; no shared/global token. +- Per-user API keys (multiple per user, individually revocable); no + shared/global key. - 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 6cafeb6..75b4508 100644 --- a/docs/llm_posting_helper.md +++ b/docs/llm_posting_helper.md @@ -1,8 +1,8 @@ # LLM Posting Helper -Agents should publish to Daybook with `skills/daybook-posting/scripts/daybook_post.py` instead of hand-building HTTP, JSON, curl, or PowerShell multipart requests. +Agents should publish to AgentFeed with `skills/agentfeed-posting/scripts/agentfeed_post.py` instead of hand-building HTTP, JSON, curl, or PowerShell multipart requests. -The project also keeps `scripts/daybook_post.py` as a compatibility wrapper. +The project also keeps `scripts/agentfeed_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 -DAYBOOK_URL=http://127.0.0.1:8001 -DAYBOOK_TOKEN=your-personal-api-token -DAYBOOK_SOURCE=agent +AGENTFEED_URL=http://127.0.0.1:8001 +AGENTFEED_TOKEN=your-personal-api-key +AGENTFEED_SOURCE=agent ``` -`DAYBOOK_TOKEN` is a personal per-user token, from Settings → Account in the app — not a token shared across users. Override these per environment. +`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. ## Commands Check the instance: ```bash -python skills/daybook-posting/scripts/daybook_post.py --check +python skills/agentfeed-posting/scripts/agentfeed_post.py --check ``` Post Markdown from a file: ```bash -python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report +python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report ``` Post Markdown from stdin: ```bash -cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" +cat report.md | python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report" ``` Post a deck: ```bash -python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Deployment Plan" --content-file deck.html +python skills/agentfeed-posting/scripts/agentfeed_post.py --kind deck --title "Deployment Plan" --content-file deck.html ``` `deck.html` must contain top-level `
...
` blocks. @@ -57,44 +57,44 @@ python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Deplo Post an image/PDF/binary file: ```bash -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" +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" ``` Post CSV or JSON as an interactive table/tree (auto-mode detects the kind from the extension; explicit `--kind` works too): ```bash -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 +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 ``` Post an `.xlsx` spreadsheet (converted server-side to a `csv` artifact — active sheet only, saved formula results only, no formatting preserved): ```bash -python skills/daybook-posting/scripts/daybook_post.py balance_sheet.xlsx +python skills/agentfeed-posting/scripts/agentfeed_post.py balance_sheet.xlsx ``` Render other code as a Markdown fenced block instead (no dedicated `code` kind yet): ```bash -python skills/daybook-posting/scripts/daybook_post.py --file script.py --as-markdown --title "Generated Script" +python skills/agentfeed-posting/scripts/agentfeed_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/daybook-posting/scripts/daybook_post.py --upload-image chart.png +python skills/agentfeed-posting/scripts/agentfeed_post.py --upload-image chart.png # -> prints .../assets/; put that in your markdown, then post normally ``` Dry run: ```bash -python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Draft" --content-file report.md --dry-run +python skills/agentfeed-posting/scripts/agentfeed_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/daybook-posting/scripts/daybook_post.py \ +python skills/agentfeed-posting/scripts/agentfeed_post.py \ --kind markdown \ --title "Run Summary" \ --content-file summary.md \ @@ -129,7 +129,7 @@ python skills/daybook-posting/scripts/daybook_post.py \ Or pass a JSON object: ```bash -python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Run Summary" --content-file summary.md --meta-json '{"run_id":"run-001"}' +python skills/agentfeed-posting/scripts/agentfeed_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` | `DAYBOOK_SOURCE` env, else `agent` | What produced this — an agent-profile, script, or person name. | +| `--source` | `AGENTFEED_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/daybook-posting/SKILL.md +skills/agentfeed-posting/SKILL.md ``` -Its key instruction is: do not hand-build requests; use `scripts/daybook_post.py`. +Its key instruction is: do not hand-build requests; use `scripts/agentfeed_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 deleted file mode 100644 index 1303fb9..0000000 --- a/issues/007-handover-to-llm-test-2026-09-02.md +++ /dev/null @@ -1,54 +0,0 @@ -# 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 deleted file mode 100644 index 4cae7ac..0000000 --- a/issues/013-handover-to-llm-test-round-2.md +++ /dev/null @@ -1,121 +0,0 @@ -# 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 deleted file mode 100644 index 88edbd9..0000000 --- a/issues/014-stored-xss-in-rendered-text-artifacts.md +++ /dev/null @@ -1,169 +0,0 @@ -# 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:** Daybook can run fully self-hosted/air-gapped. Any +**Offline caveat:** AgentFeed 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:** Daybook already +**Self-hosted alternative for tables/JSON — no CDN needed:** AgentFeed 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`: @@ -108,7 +138,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 Daybook, not the +`markdown`/`html_fragment`/`deck`. Those kinds exist so AgentFeed, not the poster, owns styling; injecting a default CDN habit into them would undermine that. @@ -119,11 +149,12 @@ artifact), upload it first and reference the returned URL: ```bash # 1. Upload -> prints the URL as the last line -python skills/daybook-posting/scripts/daybook_post.py --upload-image chart.png +python "$AGENTFEED_POST" --upload-image chart.png -# 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 +# 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 ``` The image is linked to the artifact that references it and is deleted along with it. @@ -136,19 +167,19 @@ just post the file positionally; auto-mode detects `.csv`/`.json` and picks the right kind automatically: ```bash -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 +python "$AGENTFEED_POST" results.csv +python "$AGENTFEED_POST" --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 skills/daybook-posting/scripts/daybook_post.py balance_sheet.xlsx +python "$AGENTFEED_POST" balance_sheet.xlsx ``` The server converts it to a `csv` artifact automatically. Known -limitations (see `docs/api.md` for the full list): +limitations (see the instance's `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 @@ -165,7 +196,7 @@ as a binary, use `--file path --as-markdown` to wrap it in a Markdown fenced code block (still gets syntax highlighting): ```bash -python skills/daybook-posting/scripts/daybook_post.py --file script.py --as-markdown --title "Generated Script" +python "$AGENTFEED_POST" --file script.py --as-markdown --title "Generated Script" ``` Without `--as-markdown`, it uploads as a downloadable binary file instead. @@ -177,7 +208,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 `DAYBOOK_SOURCE` if `--source` wasn't given) | Human-readable name, e.g. "Hermes Agent". | +| `--source-name` | Same as the effective source (`--source`, or `AGENTFEED_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. | @@ -188,9 +219,38 @@ context like `run_id` or `model` — these end up in the artifact's ## Rules -- 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). +- 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). - 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/daybook-posting/scripts/daybook_post.py b/skills/agentfeed-posting/scripts/agentfeed_post.py similarity index 95% rename from skills/daybook-posting/scripts/daybook_post.py rename to skills/agentfeed-posting/scripts/agentfeed_post.py index 659fbd4..049f78f 100644 --- a/skills/daybook-posting/scripts/daybook_post.py +++ b/skills/agentfeed-posting/scripts/agentfeed_post.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Post artifacts to Daybook without shell-specific JSON or multipart quoting. +"""Post artifacts to AgentFeed 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("DAYBOOK_URL", DEFAULT_URL).rstrip("/") - # DAYBOOK_TOKEN is your personal per-user token (Settings -> Account in the app); + 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); # there is no shared fallback token, so posting without it cannot work. - token = os.environ.get("DAYBOOK_TOKEN", "") - source = args.source or os.environ.get("DAYBOOK_SOURCE", DEFAULT_SOURCE) + token = os.environ.get("AGENTFEED_TOKEN", "") + source = args.source or os.environ.get("AGENTFEED_SOURCE", DEFAULT_SOURCE) if args.check: try: @@ -43,8 +43,8 @@ def main(): if not token: print( - "error: DAYBOOK_TOKEN is not set. Get your personal API token from " - f"{base_url}/settings/account/ and set DAYBOOK_TOKEN.", + "error: AGENTFEED_TOKEN is not set. Get your personal API key from " + f"{base_url}/settings/api-keys/ and set AGENTFEED_TOKEN.", file=sys.stderr, ) return 2 @@ -84,11 +84,11 @@ def main(): def parse_args(): - parser = argparse.ArgumentParser(description="Publish an artifact to Daybook.") + parser = argparse.ArgumentParser(description="Publish an artifact to AgentFeed.") 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 DAYBOOK_SOURCE or 'agent'.") + parser.add_argument("--source", help="Artifact source (agent-profile, script, or person name). Defaults to AGENTFEED_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"----daybook-{uuid.uuid4().hex}" + boundary = f"----agentfeed-{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 Daybook URL for embedding in content.""" + """Upload an image and return its AgentFeed URL for embedding in content.""" if not path.is_file(): raise ValueError(f"image file does not exist: {path}") - boundary = f"----daybook-{uuid.uuid4().hex}" + boundary = f"----agentfeed-{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/daybook.css b/static/agentfeed.css similarity index 77% rename from static/daybook.css rename to static/agentfeed.css index 37002a3..788b473 100644 --- a/static/daybook.css +++ b/static/agentfeed.css @@ -128,6 +128,42 @@ 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); @@ -324,6 +360,7 @@ 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; } @@ -477,6 +514,18 @@ 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); @@ -968,6 +1017,21 @@ 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; @@ -990,6 +1054,257 @@ 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 31761d7..6e79315 100644 --- a/static/vendor/gridjs/gridjs.umd.js +++ b/static/vendor/gridjs/gridjs.umd.js @@ -1,2 +1 @@ !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 new file mode 100644 index 0000000..37c9ed1 --- /dev/null +++ b/templates/artifacts/_artifact_rows.html @@ -0,0 +1,42 @@ +{% 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 111fb86..63971f4 100644 --- a/templates/artifacts/_day_groups.html +++ b/templates/artifacts/_day_groups.html @@ -1,43 +1,10 @@ {% for group in day_groups %} -
+ {% endfor %} {% if has_more %} diff --git a/templates/artifacts/_topbar.html b/templates/artifacts/_topbar.html index 5dbcde5..cda9526 100644 --- a/templates/artifacts/_topbar.html +++ b/templates/artifacts/_topbar.html @@ -1,3 +1,4 @@ +{% load agentfeed_extras %}

AgentFeed

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