Compare commits
17 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2a354809a | |||
| fcd7da853f | |||
| 89936ebf58 | |||
| 7b161f1276 | |||
| c1be2face7 | |||
| ab3e92268a | |||
| 548ba3a669 | |||
| 57ad58ff2d | |||
| 4254164bef | |||
| 6c1823e65e | |||
| cae414c307 | |||
| f6f004c689 | |||
| 2b901f7844 | |||
| d30e2436ab | |||
| 4cacd8f64e | |||
| 4fa20e05c2 | |||
| b382eb857b |
62 changed files with 2541 additions and 1172 deletions
|
|
@ -8,6 +8,6 @@ Run the demo seeding script to populate today with one tagged sample artifact pe
|
||||||
python scripts/seed_demo_artifacts.py
|
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.
|
After running, report each posted artifact's kind and URL, and remind the user to reload the feed for today.
|
||||||
|
|
|
||||||
195
CHANGELOG.md
195
CHANGELOG.md
|
|
@ -1,13 +1,189 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to Daybook are recorded here. Format loosely follows
|
All notable changes to AgentFeed are recorded here. Format loosely follows
|
||||||
[Keep a Changelog](https://keepachangelog.com/); versions are SemVer and match
|
[Keep a Changelog](https://keepachangelog.com/); versions are SemVer and match
|
||||||
the git tags (`vX.Y.Z`) and `daybook_project.__version__`.
|
the git tags (`vX.Y.Z`) and `__version__`. (Entries before 0.3.0 use the app's
|
||||||
|
former name, "Daybook".)
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
_Nothing yet._
|
_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
|
## [0.2.0] — 2026-09-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
@ -85,7 +261,20 @@ First tagged release.
|
||||||
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
|
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
|
||||||
runbook (`deploy/cloud_agent_deploy.md`) and health check.
|
runbook (`deploy/cloud_agent_deploy.md`) and health check.
|
||||||
|
|
||||||
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...HEAD
|
[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.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.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
|
[0.1.0]: https://git.cygnus.systems/peter/daybook/releases/tag/v0.1.0
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Project
|
## 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.
|
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.
|
- 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.
|
- 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.
|
- `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.
|
- 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.
|
- 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.
|
- `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.
|
- Use random 8-character artifact IDs.
|
||||||
- Start as a plain Django project; add Docker after core ingest and viewer work.
|
- Start as a plain Django project; add Docker after core ingest and viewer work.
|
||||||
- Preserve original markdown as `.md` alongside rendered `.html`.
|
- 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`.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Treat the NUC container as the local live/staging target. Deploy there only at explicit stable checkpoints, such as an end-of-day build.
|
||||||
|
|
|
||||||
56
README.md
Normal file
56
README.md
Normal file
|
|
@ -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 <http://127.0.0.1:8001/> and log in.
|
||||||
|
|
||||||
|
- **Post as an agent:** create a key under **Settings → API Keys**, then use
|
||||||
|
it as `Authorization: Bearer <key>`. 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`.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Artifact, Asset, UserProfile
|
from .models import ApiKey, Artifact, Asset, TagColor, UserProfile
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Artifact)
|
@admin.register(Artifact)
|
||||||
|
|
@ -21,3 +21,18 @@ class AssetAdmin(admin.ModelAdmin):
|
||||||
list_display = ("id", "owner", "artifact", "content_type", "size_bytes", "created_at")
|
list_display = ("id", "owner", "artifact", "content_type", "size_bytes", "created_at")
|
||||||
list_filter = ("owner", "content_type")
|
list_filter = ("owner", "content_type")
|
||||||
search_fields = ("id",)
|
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",)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from rest_framework.response import Response
|
||||||
from rest_framework.views import exception_handler
|
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
|
"""DRF exception handler that additionally maps pathological-input crashes
|
||||||
to a clean 400 instead of an unhandled 500.
|
to a clean 400 instead of an unhandled 500.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@ from django import forms
|
||||||
from django.contrib.auth.forms import AuthenticationForm
|
from django.contrib.auth.forms import AuthenticationForm
|
||||||
|
|
||||||
|
|
||||||
class DaybookLoginForm(AuthenticationForm):
|
class AgentFeedLoginForm(AuthenticationForm):
|
||||||
remember_me = forms.BooleanField(required=False, initial=True)
|
remember_me = forms.BooleanField(required=False, initial=True)
|
||||||
|
|
|
||||||
18
artifacts/migrations/0016_artifact_deleted_at.py
Normal file
18
artifacts/migrations/0016_artifact_deleted_at.py
Normal file
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
29
artifacts/migrations/0017_label.py
Normal file
29
artifacts/migrations/0017_label.py
Normal file
|
|
@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -5,7 +5,7 @@ from django.db import models
|
||||||
|
|
||||||
|
|
||||||
def generate_api_key():
|
def generate_api_key():
|
||||||
"""`dbk_` prefix (recognizable as a Daybook key) + 40 hex chars."""
|
"""`dbk_` prefix (recognizable as a AgentFeed key) + 40 hex chars."""
|
||||||
return "dbk_" + secrets.token_hex(20)
|
return "dbk_" + secrets.token_hex(20)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -60,6 +60,9 @@ class Artifact(models.Model):
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
read_at = models.DateTimeField(null=True, blank=True)
|
read_at = models.DateTimeField(null=True, blank=True)
|
||||||
is_kept = models.BooleanField(default=False)
|
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)
|
size_bytes = models.BigIntegerField(default=0)
|
||||||
# NULL = not shared. Many rows share NULL (SQL treats each NULL as
|
# NULL = not shared. Many rows share NULL (SQL treats each NULL as
|
||||||
# distinct, so `unique=True` still holds); a real value must be unique
|
# distinct, so `unique=True` still holds); a real value must be unique
|
||||||
|
|
@ -74,6 +77,10 @@ class Artifact(models.Model):
|
||||||
def is_shared(self):
|
def is_shared(self):
|
||||||
return bool(self.share_token)
|
return bool(self.share_token)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_deleted(self):
|
||||||
|
return self.deleted_at is not None
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
|
@ -89,6 +96,23 @@ class TagColor(models.Model):
|
||||||
return f"{self.name} ({self.color})"
|
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):
|
class UserProfile(models.Model):
|
||||||
DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB
|
DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ SUM rather than a stat of every file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import threading
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import connection
|
||||||
from django.db.models import F, Sum
|
from django.db.models import F, Sum
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
@ -70,8 +72,13 @@ def artifact_total_bytes(artifact):
|
||||||
|
|
||||||
|
|
||||||
def user_usage_bytes(user):
|
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_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_bytes = (
|
||||||
Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
|
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:
|
if used + incoming_bytes <= quota:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# Trashed artifacts are eviction-protected (recovery window is guaranteed)
|
||||||
|
# and already off-quota, so they're never eviction candidates.
|
||||||
unkept = list(
|
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 -
|
# Check whether eviction can possibly succeed before deleting anything -
|
||||||
# otherwise a doomed request would still destroy every unkept artifact on
|
# otherwise a doomed request would still destroy every unkept artifact on
|
||||||
|
|
@ -137,6 +146,55 @@ def make_room_for(user, incoming_bytes):
|
||||||
return evicted
|
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)
|
ASSET_ORPHAN_GRACE = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
0
artifacts/templatetags/__init__.py
Normal file
0
artifacts/templatetags/__init__.py
Normal file
30
artifacts/templatetags/agentfeed_extras.py
Normal file
30
artifacts/templatetags/agentfeed_extras.py
Normal file
|
|
@ -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 "?"
|
||||||
|
|
@ -6,9 +6,15 @@ from . import views
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.feed, name="feed"),
|
path("", views.feed, name="feed"),
|
||||||
path("feed/older", views.feed_older, name="feed-older"),
|
path("feed/older", views.feed_older, name="feed-older"),
|
||||||
|
path("feed/updates", views.feed_updates, name="feed-updates"),
|
||||||
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
|
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
|
||||||
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
|
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
|
||||||
path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"),
|
path("a/<str:artifact_id>/share", views.toggle_artifact_share, name="artifact-toggle-share"),
|
||||||
|
path("a/<str:artifact_id>/label", views.toggle_artifact_label, name="artifact-toggle-label"),
|
||||||
|
path("a/<str:artifact_id>/delete", views.delete_artifact, name="artifact-delete"),
|
||||||
|
path("a/<str:artifact_id>/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/<str:artifact_id>", views.artifact_view, name="artifact"),
|
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
|
||||||
path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
|
path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
|
||||||
path("assets/<str:asset_id>", views.asset_view, name="asset"),
|
path("assets/<str:asset_id>", views.asset_view, name="asset"),
|
||||||
|
|
@ -19,9 +25,11 @@ urlpatterns = [
|
||||||
path("api/keys/<int:key_id>", views.ApiKeyDetailView.as_view(), name="api-key-detail"),
|
path("api/keys/<int:key_id>", views.ApiKeyDetailView.as_view(), name="api-key-detail"),
|
||||||
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
||||||
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
||||||
|
path("api/artifacts/<str:artifact_id>/raw", views.ArtifactRawView.as_view(), name="artifact-raw-api"),
|
||||||
path("settings/", views.settings_home, name="settings-home"),
|
path("settings/", views.settings_home, name="settings-home"),
|
||||||
path("settings/account/", views.settings_account, name="settings-account"),
|
path("settings/account/", views.settings_account, name="settings-account"),
|
||||||
path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"),
|
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/users/", views.settings_users, name="settings-users"),
|
||||||
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
|
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
|
||||||
path("settings/storage/", views.settings_storage, name="settings-storage"),
|
path("settings/storage/", views.settings_storage, name="settings-storage"),
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,18 @@ import nh3
|
||||||
import openpyxl
|
import openpyxl
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
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.decorators import login_required
|
||||||
|
from django.contrib.auth.forms import PasswordChangeForm
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import OperationalError, transaction
|
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.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
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.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 import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||||
|
|
@ -34,8 +36,8 @@ from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from .demo_data import create_demo_artifacts
|
from .demo_data import create_demo_artifacts
|
||||||
from .forms import DaybookLoginForm
|
from .forms import AgentFeedLoginForm
|
||||||
from .models import ApiKey, Artifact, Asset, TagColor
|
from .models import ApiKey, Artifact, Asset, Label, TagColor
|
||||||
from .storage import (
|
from .storage import (
|
||||||
QuotaExceeded,
|
QuotaExceeded,
|
||||||
SIDECAR_SUFFIXES,
|
SIDECAR_SUFFIXES,
|
||||||
|
|
@ -43,6 +45,8 @@ from .storage import (
|
||||||
delete_artifact_files,
|
delete_artifact_files,
|
||||||
disk_free_bytes,
|
disk_free_bytes,
|
||||||
make_room_for,
|
make_room_for,
|
||||||
|
purge_trashed,
|
||||||
|
spawn_trash_purge,
|
||||||
sweep_orphan_assets,
|
sweep_orphan_assets,
|
||||||
total_allocated_bytes,
|
total_allocated_bytes,
|
||||||
user_quota_bytes,
|
user_quota_bytes,
|
||||||
|
|
@ -66,7 +70,7 @@ REMEMBER_ME_SESSION_AGE = 60 * 60 * 24 * 30 # 30 days
|
||||||
|
|
||||||
MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB per uploaded image
|
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})")
|
ASSET_URL_RE = re.compile(r"/assets/([0-9a-f]{12})")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -82,12 +86,14 @@ DEFAULT_THEMES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(login_required, name="get")
|
|
||||||
class ArtifactListCreateView(APIView):
|
class ArtifactListCreateView(APIView):
|
||||||
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
||||||
|
|
||||||
def get(self, request):
|
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"):
|
if source := request.query_params.get("source"):
|
||||||
artifacts = artifacts.filter(source=source)
|
artifacts = artifacts.filter(source=source)
|
||||||
if label := request.query_params.get("label"):
|
if label := request.query_params.get("label"):
|
||||||
|
|
@ -223,10 +229,12 @@ def asset_view(request, asset_id):
|
||||||
return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream")
|
return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(login_required, name="get")
|
|
||||||
class ArtifactDetailView(APIView):
|
class ArtifactDetailView(APIView):
|
||||||
def get(self, request, artifact_id):
|
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))
|
return Response(serialize_artifact(artifact, request))
|
||||||
|
|
||||||
def patch(self, request, artifact_id):
|
def patch(self, request, artifact_id):
|
||||||
|
|
@ -234,7 +242,7 @@ class ArtifactDetailView(APIView):
|
||||||
if user is None:
|
if user is None:
|
||||||
return HttpResponseForbidden("Invalid or missing API token")
|
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
|
data = request.data
|
||||||
not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys())
|
not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys())
|
||||||
if not_editable:
|
if not_editable:
|
||||||
|
|
@ -293,29 +301,64 @@ class ArtifactDetailView(APIView):
|
||||||
if user is None:
|
if user is None:
|
||||||
return HttpResponseForbidden("Invalid or missing API token")
|
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)
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||||
delete_artifact_files(artifact)
|
if artifact.deleted_at is None:
|
||||||
artifact.delete()
|
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)
|
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):
|
class CapabilitiesView(APIView):
|
||||||
"""Static capability info. Reachable by a logged-in session or a valid API token,
|
"""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."""
|
so agents can check connectivity before posting. Exposes no user data."""
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
if not request.user.is_authenticated and resolve_token_user(request) is None:
|
# Public: capability/contract info only, no user data.
|
||||||
return HttpResponseForbidden("Login or a valid API token is required")
|
|
||||||
from daybook_project import __version__
|
from daybook_project import __version__
|
||||||
|
|
||||||
methods = {
|
methods = {
|
||||||
"/api/artifacts": ["GET", "POST"],
|
"/api/artifacts": ["GET", "POST"],
|
||||||
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
|
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
|
||||||
|
"/api/artifacts/{id}/raw": ["GET"],
|
||||||
"/api/assets": ["POST"],
|
"/api/assets": ["POST"],
|
||||||
"/api/capabilities": ["GET"],
|
"/api/capabilities": ["GET"],
|
||||||
"/api/skill": ["GET"],
|
"/api/skill": ["GET"],
|
||||||
}
|
}
|
||||||
if settings.DAYBOOK_KEY_PROVISIONING_API:
|
if settings.AGENTFEED_KEY_PROVISIONING_API:
|
||||||
methods["/api/keys"] = ["GET", "POST"]
|
methods["/api/keys"] = ["GET", "POST"]
|
||||||
methods["/api/keys/{id}"] = ["DELETE"]
|
methods["/api/keys/{id}"] = ["DELETE"]
|
||||||
|
|
||||||
|
|
@ -330,30 +373,30 @@ class CapabilitiesView(APIView):
|
||||||
"auth": "bearer_token",
|
"auth": "bearer_token",
|
||||||
"methods": methods,
|
"methods": methods,
|
||||||
"skill_url": request.build_absolute_uri(reverse("skill-api")),
|
"skill_url": request.build_absolute_uri(reverse("skill-api")),
|
||||||
"key_provisioning_api": settings.DAYBOOK_KEY_PROVISIONING_API,
|
"key_provisioning_api": settings.AGENTFEED_KEY_PROVISIONING_API,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
SKILL_MD_PATH = settings.BASE_DIR / "skills" / "daybook-posting" / "SKILL.md"
|
SKILL_MD_PATH = settings.BASE_DIR / "skills" / "agentfeed-posting" / "SKILL.md"
|
||||||
|
|
||||||
|
|
||||||
def render_posting_skill(origin):
|
def render_posting_skill(origin):
|
||||||
"""Return the bundled posting SKILL.md with its DAYBOOK_URL placeholder
|
"""Return the bundled posting SKILL.md with its AGENTFEED_URL placeholder
|
||||||
rewritten to this instance's origin, so text handed to an agent already
|
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;
|
points at the right server. Only the config-block assignment is touched;
|
||||||
the surrounding prose (which explains DAYBOOK_URL) is left alone."""
|
the surrounding prose (which explains AGENTFEED_URL) is left alone."""
|
||||||
text = SKILL_MD_PATH.read_text(encoding="utf-8")
|
text = SKILL_MD_PATH.read_text(encoding="utf-8")
|
||||||
return re.sub(
|
return re.sub(
|
||||||
r"(?m)^(DAYBOOK_URL=)\S+",
|
r"(?m)^(AGENTFEED_URL=)\S+",
|
||||||
lambda m: m.group(1) + origin,
|
lambda m: m.group(1) + origin,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SkillView(APIView):
|
class SkillView(APIView):
|
||||||
"""Serve the bundled agent-posting skill (skills/daybook-posting/SKILL.md),
|
"""Serve the bundled agent-posting skill (skills/agentfeed-posting/SKILL.md),
|
||||||
with DAYBOOK_URL pre-filled to this instance so a recipient posts to the
|
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
|
right server. Same auth as capabilities: a logged-in user can copy it, or
|
||||||
an agent can fetch it with its API token.
|
an agent can fetch it with its API token.
|
||||||
|
|
||||||
|
|
@ -362,9 +405,9 @@ class SkillView(APIView):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
if not request.user.is_authenticated and resolve_token_user(request) is None:
|
# Public: the skill is bootstrapping instructions (how to authenticate
|
||||||
return HttpResponseForbidden("Login or a valid API token is required")
|
# 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("/")
|
origin = request.build_absolute_uri("/").rstrip("/")
|
||||||
try:
|
try:
|
||||||
skill = render_posting_skill(origin)
|
skill = render_posting_skill(origin)
|
||||||
|
|
@ -390,7 +433,7 @@ def serialize_api_key(api_key):
|
||||||
|
|
||||||
class ApiKeyListCreateView(APIView):
|
class ApiKeyListCreateView(APIView):
|
||||||
"""Programmatic API-key management, for automated test installations that
|
"""Programmatic API-key management, for automated test installations that
|
||||||
provision their own agents. Gated by settings.DAYBOOK_KEY_PROVISIONING_API,
|
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
|
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
|
off, every method returns 403. Authenticated by session or an existing
|
||||||
bearer token, and only ever operates on that user's own keys.
|
bearer token, and only ever operates on that user's own keys.
|
||||||
|
|
@ -405,7 +448,7 @@ class ApiKeyListCreateView(APIView):
|
||||||
return resolve_token_user(request)
|
return resolve_token_user(request)
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
if not settings.DAYBOOK_KEY_PROVISIONING_API:
|
if not settings.AGENTFEED_KEY_PROVISIONING_API:
|
||||||
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
|
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
|
||||||
user = self._resolve_user(request)
|
user = self._resolve_user(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|
@ -413,7 +456,7 @@ class ApiKeyListCreateView(APIView):
|
||||||
return Response([serialize_api_key(k) for k in user.api_keys.all()])
|
return Response([serialize_api_key(k) for k in user.api_keys.all()])
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
if not settings.DAYBOOK_KEY_PROVISIONING_API:
|
if not settings.AGENTFEED_KEY_PROVISIONING_API:
|
||||||
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
|
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
|
||||||
user = self._resolve_user(request)
|
user = self._resolve_user(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|
@ -432,7 +475,7 @@ class ApiKeyDetailView(APIView):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def delete(self, request, key_id):
|
def delete(self, request, key_id):
|
||||||
if not settings.DAYBOOK_KEY_PROVISIONING_API:
|
if not settings.AGENTFEED_KEY_PROVISIONING_API:
|
||||||
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
|
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)
|
user = request.user if request.user.is_authenticated else resolve_token_user(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|
@ -480,21 +523,40 @@ def feed(request):
|
||||||
selected_artifact_id = request.GET.get("artifact", "")
|
selected_artifact_id = request.GET.get("artifact", "")
|
||||||
preview_none = request.GET.get("preview") == "none"
|
preview_none = request.GET.get("preview") == "none"
|
||||||
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
|
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(
|
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 selected_folder:
|
||||||
if not artifacts.filter(day__gte=window_start).exists():
|
# System folders show everything (no 7-day window / infinite scroll).
|
||||||
# Nothing recent for these filters: fall back to the most recent days that
|
window_start = today
|
||||||
# do match, so selecting a source is never met with a blank list.
|
window_artifacts = list(artifacts.order_by("-created_at"))
|
||||||
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
|
older_count = 0
|
||||||
if recent_days:
|
else:
|
||||||
window_start = recent_days[-1]
|
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
|
||||||
window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at"))
|
if not artifacts.filter(day__gte=window_start).exists():
|
||||||
older_count = artifacts.filter(day__lt=window_start).count()
|
# 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()
|
overrides = tag_color_overrides()
|
||||||
day_groups = group_artifacts_by_day(window_artifacts, overrides)
|
day_groups = group_artifacts_by_day(window_artifacts, overrides)
|
||||||
|
|
@ -504,6 +566,8 @@ def feed(request):
|
||||||
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
|
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
|
||||||
|
|
||||||
toggle_query = {"view": "rendered"}
|
toggle_query = {"view": "rendered"}
|
||||||
|
if selected_folder:
|
||||||
|
toggle_query["folder"] = selected_folder
|
||||||
if selected_source:
|
if selected_source:
|
||||||
toggle_query["source"] = selected_source
|
toggle_query["source"] = selected_source
|
||||||
if selected_label:
|
if selected_label:
|
||||||
|
|
@ -518,18 +582,22 @@ def feed(request):
|
||||||
toggle_query["view"] = "raw"
|
toggle_query["view"] = "raw"
|
||||||
raw_url = f"?{urlencode(toggle_query)}"
|
raw_url = f"?{urlencode(toggle_query)}"
|
||||||
|
|
||||||
# Source counts come from the other filters only, so the sidebar still shows
|
# Source counts + labels always come from live artifacts, so the sidebar is
|
||||||
# every source available under the current label/type/name filters.
|
# the same whichever folder you're viewing.
|
||||||
source_scoped = apply_feed_filters(
|
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_counts = (
|
||||||
source_scoped.values("source")
|
source_scoped.values("source")
|
||||||
.annotate(count=Count("id", filter=Q(read_at__isnull=True)))
|
.annotate(count=Count("id", filter=Q(read_at__isnull=True)), latest=Max("created_at"))
|
||||||
.order_by("source")
|
.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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"artifacts/feed.html",
|
"artifacts/feed.html",
|
||||||
|
|
@ -538,8 +606,14 @@ def feed(request):
|
||||||
"day_groups": day_groups,
|
"day_groups": day_groups,
|
||||||
"older_count": older_count,
|
"older_count": older_count,
|
||||||
"window_start": window_start,
|
"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,
|
"source_counts": source_counts,
|
||||||
"total_count": source_scoped.filter(read_at__isnull=True).count(),
|
"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),
|
"all_labels": labels_with_colors_list(all_labels, overrides),
|
||||||
"raw_url": raw_url,
|
"raw_url": raw_url,
|
||||||
"rendered_url": rendered_url,
|
"rendered_url": rendered_url,
|
||||||
|
|
@ -552,6 +626,7 @@ def feed(request):
|
||||||
"today": today,
|
"today": today,
|
||||||
"title_query": title_query,
|
"title_query": title_query,
|
||||||
"kind_choices": Artifact.KIND_CHOICES,
|
"kind_choices": Artifact.KIND_CHOICES,
|
||||||
|
"label_palette": request.user.labels.all(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -566,6 +641,31 @@ def mark_artifact_read(request, artifact_id):
|
||||||
return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()})
|
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
|
@login_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def toggle_artifact_keep(request, artifact_id):
|
def toggle_artifact_keep(request, artifact_id):
|
||||||
|
|
@ -592,12 +692,58 @@ def toggle_artifact_share(request, artifact_id):
|
||||||
return JsonResponse({"id": artifact.id, "is_shared": artifact.is_shared, "share_url": share_url})
|
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
|
@login_required
|
||||||
def feed_older(request):
|
def feed_older(request):
|
||||||
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
|
"""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()
|
before = parse_date(request.GET.get("before", "")) or timezone.localdate()
|
||||||
artifacts = apply_feed_filters(
|
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("source", ""),
|
||||||
request.GET.get("label", ""),
|
request.GET.get("label", ""),
|
||||||
request.GET.get("kind", ""),
|
request.GET.get("kind", ""),
|
||||||
|
|
@ -631,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
|
@login_required
|
||||||
@xframe_options_sameorigin
|
@xframe_options_sameorigin
|
||||||
def artifact_view(request, artifact_id):
|
def artifact_view(request, artifact_id):
|
||||||
|
|
@ -858,7 +1056,7 @@ def create_binary_artifact(data, request, user):
|
||||||
|
|
||||||
|
|
||||||
# Allowlist for sanitizing the "themed" text kinds (markdown, deck,
|
# 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
|
# they must never be a script-execution vector (issue #014). html_full is the
|
||||||
# deliberate exception (verbatim, poster owns everything) and is NOT run
|
# deliberate exception (verbatim, poster owns everything) and is NOT run
|
||||||
# through this. The list keeps ordinary formatting + tables + code-highlight
|
# through this. The list keeps ordinary formatting + tables + code-highlight
|
||||||
|
|
@ -963,14 +1161,14 @@ def wrap_json_viewer(content, title, request):
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="artifact">
|
<body class="artifact">
|
||||||
<script type="application/json" id="daybook-data">{safe_json}</script>
|
<script type="application/json" id="agentfeed-data">{safe_json}</script>
|
||||||
<div id="daybook-json-view"></div>
|
<div id="agentfeed-json-view"></div>
|
||||||
<script>
|
<script>
|
||||||
(function () {{
|
(function () {{
|
||||||
var raw = document.getElementById("daybook-data").textContent;
|
var raw = document.getElementById("agentfeed-data").textContent;
|
||||||
var data = JSON.parse(raw);
|
var data = JSON.parse(raw);
|
||||||
renderjson.set_show_to_level(2);
|
renderjson.set_show_to_level(2);
|
||||||
document.getElementById("daybook-json-view").appendChild(renderjson(data));
|
document.getElementById("agentfeed-json-view").appendChild(renderjson(data));
|
||||||
}})();
|
}})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
@ -1007,18 +1205,18 @@ def wrap_csv_viewer(content, title, request):
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="artifact">
|
<body class="artifact">
|
||||||
<script type="application/json" id="daybook-data">{table_json}</script>
|
<script type="application/json" id="agentfeed-data">{table_json}</script>
|
||||||
<div id="daybook-csv-view"></div>
|
<div id="agentfeed-csv-view"></div>
|
||||||
<script>
|
<script>
|
||||||
(function () {{
|
(function () {{
|
||||||
var table = JSON.parse(document.getElementById("daybook-data").textContent);
|
var table = JSON.parse(document.getElementById("agentfeed-data").textContent);
|
||||||
new gridjs.Grid({{
|
new gridjs.Grid({{
|
||||||
columns: table.columns,
|
columns: table.columns,
|
||||||
data: table.data,
|
data: table.data,
|
||||||
search: true,
|
search: true,
|
||||||
sort: true,
|
sort: true,
|
||||||
pagination: {{ limit: 20 }},
|
pagination: {{ limit: 20 }},
|
||||||
}}).render(document.getElementById("daybook-csv-view"));
|
}}).render(document.getElementById("agentfeed-csv-view"));
|
||||||
}})();
|
}})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
@ -1073,6 +1271,15 @@ def resolve_token_user(request):
|
||||||
return api_key.user
|
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):
|
def normalize_tags(tags):
|
||||||
if isinstance(tags, str):
|
if isinstance(tags, str):
|
||||||
return [tag.strip() for tag in tags.split(",") if tag.strip()]
|
return [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||||||
|
|
@ -1262,6 +1469,7 @@ def serialize_artifact(artifact, request):
|
||||||
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
|
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
|
||||||
"api_url": request.build_absolute_uri(reverse("artifact-detail-api", 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"),
|
"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,
|
"raw_available": raw_artifact_path(artifact) is not None,
|
||||||
"source": artifact.source,
|
"source": artifact.source,
|
||||||
"title": artifact.title,
|
"title": artifact.title,
|
||||||
|
|
@ -1306,9 +1514,9 @@ def raw_artifact_path(artifact):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class DaybookLoginView(auth_views.LoginView):
|
class AgentFeedLoginView(auth_views.LoginView):
|
||||||
template_name = "artifacts/login.html"
|
template_name = "artifacts/login.html"
|
||||||
authentication_form = DaybookLoginForm
|
authentication_form = AgentFeedLoginForm
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
|
|
@ -1326,10 +1534,25 @@ def settings_home(request):
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def settings_account(request):
|
def settings_account(request):
|
||||||
|
if request.method == "POST":
|
||||||
|
password_form = PasswordChangeForm(request.user, request.POST)
|
||||||
|
if password_form.is_valid():
|
||||||
|
user = password_form.save()
|
||||||
|
# Keep the current session valid — changing the password rotates the
|
||||||
|
# session auth hash, which would otherwise log the user out.
|
||||||
|
update_session_auth_hash(request, user)
|
||||||
|
messages.success(request, "Password changed.")
|
||||||
|
return redirect("settings-account")
|
||||||
|
else:
|
||||||
|
password_form = PasswordChangeForm(request.user)
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"artifacts/settings_account.html",
|
"artifacts/settings_account.html",
|
||||||
{"active_section": "account", "key_count": request.user.api_keys.count()},
|
{
|
||||||
|
"active_section": "account",
|
||||||
|
"key_count": request.user.api_keys.count(),
|
||||||
|
"password_form": password_form,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1359,6 +1582,49 @@ def settings_api_keys(request):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def settings_users(request):
|
def settings_users(request):
|
||||||
if not request.user.is_superuser:
|
if not request.user.is_superuser:
|
||||||
|
|
@ -1459,7 +1725,7 @@ def raw_unavailable_html(artifact):
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>Raw source unavailable</h1>
|
<h1>Raw source unavailable</h1>
|
||||||
<p>Daybook does not have preserved raw source for <strong>{html.escape(artifact.title)}</strong>.</p>
|
<p>AgentFeed does not have preserved raw source for <strong>{html.escape(artifact.title)}</strong>.</p>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Single source of truth for the app version. Bump this, commit, then tag the
|
# 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
|
# commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in
|
||||||
# GET /api/capabilities ("version").
|
# GET /api/capabilities ("version").
|
||||||
__version__ = "0.2.0"
|
__version__ = "0.7.1"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
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")
|
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"}
|
DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
@ -11,14 +11,17 @@ ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS",
|
||||||
|
|
||||||
# Allow creating/listing/revoking API keys over the ingest API (POST /api/keys).
|
# Allow creating/listing/revoking API keys over the ingest API (POST /api/keys).
|
||||||
# Meant for automated test installations that provision their own agents. It
|
# Meant for automated test installations that provision their own agents. It
|
||||||
# exposes key material over the API, so it is OFF in deployment. Unset, it
|
# exposes key material over the API, so it is DISABLED BY DEFAULT and must be
|
||||||
# follows DEBUG (on locally, off in production); set DAYBOOK_KEY_PROVISIONING_API
|
# turned on explicitly — set AGENTFEED_KEY_PROVISIONING_API=1 only on a throwaway
|
||||||
# explicitly (0/1) to override either way.
|
# test install. Never enable it in a real deployment. (Independent of DEBUG, so
|
||||||
_key_api_env = os.environ.get("DAYBOOK_KEY_PROVISIONING_API")
|
# a misconfigured DEBUG=1 production box still won't expose it.)
|
||||||
if _key_api_env is None:
|
AGENTFEED_KEY_PROVISIONING_API = os.environ.get(
|
||||||
DAYBOOK_KEY_PROVISIONING_API = DEBUG
|
"AGENTFEED_KEY_PROVISIONING_API", ""
|
||||||
else:
|
).lower() in {"1", "true", "yes", "on"}
|
||||||
DAYBOOK_KEY_PROVISIONING_API = _key_api_env.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.):
|
# Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.):
|
||||||
# Django otherwise thinks every request is plain HTTP, which breaks CSRF checks
|
# Django otherwise thinks every request is plain HTTP, which breaks CSRF checks
|
||||||
|
|
@ -71,7 +74,7 @@ WSGI_APPLICATION = "daybook_project.wsgi.application"
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
# Maps deeply-nested-JSON RecursionErrors (raised in DRF's parser, before
|
# Maps deeply-nested-JSON RecursionErrors (raised in DRF's parser, before
|
||||||
# view validation) to a 400 instead of an unhandled 500. See issue #015.
|
# 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 = {
|
DATABASES = {
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ from django.contrib import admin
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
from artifacts.views import DaybookLoginView
|
from artifacts.views import AgentFeedLoginView
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
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("accounts/logout/", auth_views.LogoutView.as_view(next_page="login"), name="logout"),
|
||||||
path("", include("artifacts.urls")),
|
path("", include("artifacts.urls")),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Cloud Deployment Runbook For A Dedicated Deploy Agent
|
# Cloud Deployment Runbook For A Dedicated Deploy Agent
|
||||||
|
|
||||||
This is written for a capable (Opus-class) agent deploying and operating
|
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
|
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
|
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
|
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
|
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
|
internet-facing, whether a reverse proxy/secondary volume exists), ask the
|
||||||
operator once, then proceed autonomously for routine redeploys. The rest of
|
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)
|
gotchas (SSH port, CSRF-behind-proxy, bind address, data volume, healthcheck)
|
||||||
that are easy to get wrong.
|
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):
|
1. Generate an SSH key pair on the VM (or ask the operator for one to install):
|
||||||
```bash
|
```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 →
|
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
|
read-only is sufficient), or hand it to them directly — never ask them to
|
||||||
give you their personal account credentials.
|
give you their personal account credentials.
|
||||||
|
|
@ -52,7 +52,7 @@ a token tied to a full user account.
|
||||||
cat >> ~/.ssh/config <<'EOF'
|
cat >> ~/.ssh/config <<'EOF'
|
||||||
Host git.cygnus.systems
|
Host git.cygnus.systems
|
||||||
Port 2222
|
Port 2222
|
||||||
IdentityFile ~/.ssh/daybook_deploy
|
IdentityFile ~/.ssh/agentfeed_deploy
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
@ -112,12 +112,12 @@ Edit `deploy/.env`:
|
||||||
DJANGO_DEBUG=0
|
DJANGO_DEBUG=0
|
||||||
DJANGO_SECRET_KEY=<generated-secret>
|
DJANGO_SECRET_KEY=<generated-secret>
|
||||||
DJANGO_ALLOWED_HOSTS=<vm-public-ip>,<domain-if-any>,localhost,127.0.0.1
|
DJANGO_ALLOWED_HOSTS=<vm-public-ip>,<domain-if-any>,localhost,127.0.0.1
|
||||||
DAYBOOK_DATA_DIR=/data
|
AGENTFEED_DATA_DIR=/data
|
||||||
DATABASE_PATH=/data/db.sqlite3
|
DATABASE_PATH=/data/db.sqlite3
|
||||||
ARTIFACTS_ROOT=/data/artifacts_store
|
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
|
user from Settings → Account once a user exists (step 5). Never commit
|
||||||
`deploy/.env`; it is already gitignored.
|
`deploy/.env`; it is already gitignored.
|
||||||
|
|
||||||
|
|
@ -131,17 +131,17 @@ if unsure which applies here.
|
||||||
## 4. Network Access
|
## 4. Network Access
|
||||||
|
|
||||||
**If this VM is reachable from the public internet, a reverse proxy with TLS
|
**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
|
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
|
HTTP straight to a port on the public IP. Direct port exposure (below) is
|
||||||
only for private/VPN-only networks with no public exposure.
|
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
|
`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
|
all, then open `80`/`443` in the VM's firewall/security group for the
|
||||||
reverse proxy (see §8). Do not open `8000` externally.
|
reverse proxy (see §8). Do not open `8000` externally.
|
||||||
- **Private/VPN-only network:** open TCP `8000` to the addresses that should
|
- **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 —
|
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.
|
`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
|
## 5. Build, Start, Create A User
|
||||||
|
|
||||||
If the target has a separate, larger data volume than its main disk (common
|
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
|
on space-constrained VMs), point AgentFeed's persistent data at it before
|
||||||
starting: set `DAYBOOK_HOST_DATA_DIR=/path/on/that/volume` in `deploy/.env`.
|
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
|
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
|
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
|
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:
|
unset) before starting the stack:
|
||||||
|
|
||||||
```bash
|
```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 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
|
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
|
container's own healthcheck hits the login page instead, which is 200
|
||||||
without auth.) This `curl` runs
|
without auth.) This `curl` runs
|
||||||
against `127.0.0.1` from on the VM itself, so it works regardless of whether
|
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://<vm-ip>:8000/`
|
actually externally reachable at this point in the runbook — `http://<vm-ip>:8000/`
|
||||||
if you're on the private/VPN-only path from §4, or `https://<domain>/` through
|
if you're on the private/VPN-only path from §4, or `https://<domain>/` through
|
||||||
the reverse proxy once §8 is done if you're on the internet-facing path — with
|
the reverse proxy once §8 is done if you're on the internet-facing path — with
|
||||||
|
|
@ -230,13 +230,13 @@ 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
|
migration, restore the pre-deploy `deploy/data` backup (see §9) alongside
|
||||||
the code checkout.
|
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 —
|
or `deploy/data` by default) are untracked, so `git pull` never touches them —
|
||||||
no need to back them up before a routine redeploy.
|
no need to back them up before a routine redeploy.
|
||||||
|
|
||||||
**If the container crash-loops right after a build** (`docker compose ps`
|
**If the container crash-loops right after a build** (`docker compose ps`
|
||||||
shows `restarting`, health never goes green), the most likely cause is the
|
shows `restarting`, health never goes green), the most likely cause is the
|
||||||
entrypoint's `collectstatic` step failing — check `docker logs daybook`
|
entrypoint's `collectstatic` step failing — check `docker logs agentfeed`
|
||||||
before assuming an env/network problem. A `whitenoise ... MissingFileError`
|
before assuming an env/network problem. A `whitenoise ... MissingFileError`
|
||||||
there means a static asset references a file that isn't present (this is what
|
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
|
bit v0.1.0). No data or schema impact; fix the asset in the repo, tag, and
|
||||||
|
|
@ -277,14 +277,14 @@ instead. Apache example:
|
||||||
|
|
||||||
```apache
|
```apache
|
||||||
<VirtualHost *:443>
|
<VirtualHost *:443>
|
||||||
ServerName daybook.example.com
|
ServerName agentfeed.example.com
|
||||||
ProxyPreserveHost On
|
ProxyPreserveHost On
|
||||||
RequestHeader set X-Forwarded-Proto "https"
|
RequestHeader set X-Forwarded-Proto "https"
|
||||||
ProxyPass / http://127.0.0.1:8000/
|
ProxyPass / http://127.0.0.1:8000/
|
||||||
ProxyPassReverse / http://127.0.0.1:8000/
|
ProxyPassReverse / http://127.0.0.1:8000/
|
||||||
SSLEngine on
|
SSLEngine on
|
||||||
SSLCertificateFile /etc/letsencrypt/live/daybook.example.com/fullchain.pem
|
SSLCertificateFile /etc/letsencrypt/live/agentfeed.example.com/fullchain.pem
|
||||||
SSLCertificateKeyFile /etc/letsencrypt/live/daybook.example.com/privkey.pem
|
SSLCertificateKeyFile /etc/letsencrypt/live/agentfeed.example.com/privkey.pem
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -296,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:
|
nothing on 80/443 yet — it still needs the same header:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
daybook.example.com {
|
agentfeed.example.com {
|
||||||
reverse_proxy 127.0.0.1:8000 {
|
reverse_proxy 127.0.0.1:8000 {
|
||||||
header_up X-Forwarded-Proto https
|
header_up X-Forwarded-Proto https
|
||||||
}
|
}
|
||||||
|
|
@ -305,15 +305,15 @@ daybook.example.com {
|
||||||
|
|
||||||
## 9. Backup
|
## 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
|
at `deploy/data` — back up whichever path is actually configured, read
|
||||||
straight from `deploy/.env` rather than assumed, so this keeps working
|
straight from `deploy/.env` rather than assumed, so this keeps working
|
||||||
whether or not that override is set:
|
whether or not that override is set:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/daybook
|
cd /opt/daybook
|
||||||
DATA_DIR="$(grep -oP '(?<=^DAYBOOK_HOST_DATA_DIR=).*' deploy/.env || echo deploy/data)"
|
DATA_DIR="$(grep -oP '(?<=^AGENTFEED_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")"
|
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
|
Copy the resulting archive off the VM (to object storage, the operator's
|
||||||
|
|
@ -331,12 +331,12 @@ Before reporting the deployment complete, confirm all of these:
|
||||||
public HTTPS origin, and the proxy forwards `X-Forwarded-Proto: https`.
|
public HTTPS origin, and the proxy forwards `X-Forwarded-Proto: https`.
|
||||||
Verify by actually logging in through the proxy, not just curling
|
Verify by actually logging in through the proxy, not just curling
|
||||||
`/api/capabilities` — CSRF only bites on POST (login, artifact writes).
|
`/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.
|
- [ ] `/api/capabilities` returns `403` unauthenticated, `200` with a token.
|
||||||
- [ ] At least one superuser exists with a working personal API token.
|
- [ ] At least one superuser exists with a working personal API token.
|
||||||
- [ ] A smoke-test artifact posts and renders successfully.
|
- [ ] A smoke-test artifact posts and renders successfully.
|
||||||
- [ ] If internet-facing: TLS is in front of the app, not raw HTTP on `8000`.
|
- [ ] 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 you set it) has been taken and copied off the VM.
|
||||||
- [ ] If the target host has its own service-inventory, monitoring, or
|
- [ ] If the target host has its own service-inventory, monitoring, or
|
||||||
changelog conventions for new services, they've been followed — ask
|
changelog conventions for new services, they've been followed — ask
|
||||||
|
|
|
||||||
|
|
@ -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*
|
# from deploy/.env because Compose resolves .env relative to *this file's*
|
||||||
# directory, not the caller's cwd. That relies on being invoked as
|
# directory, not the caller's cwd. That relies on being invoked as
|
||||||
# `docker compose -f deploy/docker-compose.yml` from the repo root, per
|
# `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
|
# the defaults (0.0.0.0 bind, ./data) would be a security regression, not
|
||||||
# just a broken build.
|
# just a broken build.
|
||||||
services:
|
services:
|
||||||
daybook:
|
agentfeed:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/Dockerfile
|
dockerfile: deploy/Dockerfile
|
||||||
container_name: daybook
|
container_name: agentfeed
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
mem_limit: 384m
|
mem_limit: 384m
|
||||||
mem_reservation: 128m
|
mem_reservation: 128m
|
||||||
ports:
|
ports:
|
||||||
- "${DAYBOOK_BIND_ADDR:-0.0.0.0}:8000:8000"
|
- "${AGENTFEED_BIND_ADDR:-0.0.0.0}:8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ${DAYBOOK_HOST_DATA_DIR:-./data}:/data
|
- ${AGENTFEED_HOST_DATA_DIR:-./data}:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# Hit the login page, not /api/capabilities: capabilities returns 403
|
# Hit the login page, not /api/capabilities: capabilities returns 403
|
||||||
# when unauthenticated, and `curl --fail` treats 403 as an error, which
|
# when unauthenticated, and `curl --fail` treats 403 as an error, which
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
set -eu
|
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 migrate --noinput
|
||||||
python manage.py collectstatic --noinput
|
python manage.py collectstatic --noinput
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
DJANGO_DEBUG=0
|
DJANGO_DEBUG=0
|
||||||
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
|
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
|
||||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-vm-ip,your-domain.example
|
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
|
# Nginx, Traefik, a cloud load balancer). Without it, login/POST fails with
|
||||||
# "CSRF verification failed" because Django can't tell the original request
|
# "CSRF verification failed" because Django can't tell the original request
|
||||||
# was HTTPS. Set to the public HTTPS origin(s) users actually visit:
|
# was HTTPS. Set to the public HTTPS origin(s) users actually visit:
|
||||||
DJANGO_CSRF_TRUSTED_ORIGINS=https://your-domain.example
|
DJANGO_CSRF_TRUSTED_ORIGINS=https://your-domain.example
|
||||||
# Leave OFF in deployment. When on, POST /api/keys can create/list/revoke API
|
# Disabled by default. When on, POST /api/keys can create/list/revoke API keys
|
||||||
# keys over the network (it returns key material), which is only appropriate
|
# over the network (it returns key material), which is only appropriate for
|
||||||
# for automated test installations. Unset, it follows DJANGO_DEBUG — so with
|
# automated test installations. Leave unset in deployment; set to 1 only on a
|
||||||
# DJANGO_DEBUG=0 above it is already off. Set to 1 only on a throwaway test box.
|
# throwaway test box.
|
||||||
# DAYBOOK_KEY_PROVISIONING_API=0
|
# AGENTFEED_KEY_PROVISIONING_API=0
|
||||||
DAYBOOK_DATA_DIR=/data
|
AGENTFEED_DATA_DIR=/data
|
||||||
DATABASE_PATH=/data/db.sqlite3
|
DATABASE_PATH=/data/db.sqlite3
|
||||||
ARTIFACTS_ROOT=/data/artifacts_store
|
ARTIFACTS_ROOT=/data/artifacts_store
|
||||||
|
|
||||||
# Optional, read by deploy/docker-compose.yml directly (not by the app):
|
# 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
|
# 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
|
# front this service — this stops the container port from being reachable
|
||||||
# directly from the network. Leave unset (binds all interfaces) only for
|
# directly from the network. Leave unset (binds all interfaces) only for
|
||||||
# private/VPN-only deployments with no proxy in front.
|
# 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
|
# 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.
|
# main disk is space-constrained. Defaults to ./data next to this file.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Agent Quickstart
|
# 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 Key
|
## 0. Get Your API Key
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ Also confirms your token is valid:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" `
|
Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" `
|
||||||
-Headers @{ Authorization = "Bearer <your-personal-token>" }
|
-Headers @{ Authorization = "Bearer <your-personal-key>" }
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Publish Markdown
|
## 2. Publish Markdown
|
||||||
|
|
@ -39,7 +39,7 @@ $body = @{
|
||||||
$artifact = Invoke-RestMethod `
|
$artifact = Invoke-RestMethod `
|
||||||
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
||||||
-Method Post `
|
-Method Post `
|
||||||
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||||
-ContentType "application/json" `
|
-ContentType "application/json" `
|
||||||
-Body $body
|
-Body $body
|
||||||
|
|
||||||
|
|
@ -69,7 +69,7 @@ $body = @{
|
||||||
Invoke-RestMethod `
|
Invoke-RestMethod `
|
||||||
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
||||||
-Method Post `
|
-Method Post `
|
||||||
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||||
-ContentType "application/json" `
|
-ContentType "application/json" `
|
||||||
-Body $body
|
-Body $body
|
||||||
```
|
```
|
||||||
|
|
@ -78,7 +78,7 @@ Invoke-RestMethod `
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
|
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
|
||||||
-H "Authorization: Bearer <your-personal-token>" `
|
-H "Authorization: Bearer <your-personal-key>" `
|
||||||
-F "source=orion" `
|
-F "source=orion" `
|
||||||
-F "source_type=agent" `
|
-F "source_type=agent" `
|
||||||
-F "source_name=Orion Agent" `
|
-F "source_name=Orion Agent" `
|
||||||
|
|
@ -120,7 +120,7 @@ $body = @{
|
||||||
Invoke-RestMethod `
|
Invoke-RestMethod `
|
||||||
-Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" `
|
-Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" `
|
||||||
-Method Patch `
|
-Method Patch `
|
||||||
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||||
-ContentType "application/json" `
|
-ContentType "application/json" `
|
||||||
-Body $body
|
-Body $body
|
||||||
```
|
```
|
||||||
|
|
@ -131,7 +131,7 @@ Invoke-RestMethod `
|
||||||
Invoke-RestMethod `
|
Invoke-RestMethod `
|
||||||
-Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" `
|
-Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" `
|
||||||
-Method Delete `
|
-Method Delete `
|
||||||
-Headers @{ Authorization = "Bearer <your-personal-token>" }
|
-Headers @{ Authorization = "Bearer <your-personal-key>" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Deletes the artifact's database row and its files. You can only delete artifacts your own token owns.
|
Deletes the artifact's database row and its files. You can only delete artifacts your own token owns.
|
||||||
|
|
|
||||||
116
docs/api.md
116
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:
|
Base URL while running locally:
|
||||||
|
|
||||||
|
|
@ -16,11 +16,15 @@ Authorization: Bearer <your-personal-key>
|
||||||
|
|
||||||
Every artifact belongs to the user whose key 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
|
Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) accept
|
||||||
logged-in browser session, not a bearer token — visit the app in a browser and log in first.
|
**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
|
`GET /api/capabilities` and `GET /api/skill` are **public** — no auth required (they expose
|
||||||
connectivity before posting.
|
no user data), so an agent can discover the contract and fetch posting instructions before
|
||||||
|
it has a key.
|
||||||
|
|
||||||
## Artifact Kinds
|
## Artifact Kinds
|
||||||
|
|
||||||
|
|
@ -28,7 +32,7 @@ Currently supported kinds:
|
||||||
|
|
||||||
| Kind | Upload type | Rendered behavior | Raw support |
|
| 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 |
|
| `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 |
|
| `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 |
|
| `deck` | JSON text | Wrapped as a simple HTML slide/deck page; **HTML sanitized** | No |
|
||||||
|
|
@ -104,7 +108,7 @@ Endpoint:
|
||||||
```http
|
```http
|
||||||
POST /api/artifacts
|
POST /api/artifacts
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: Bearer <your-personal-token>
|
Authorization: Bearer <your-personal-key>
|
||||||
```
|
```
|
||||||
|
|
||||||
Required fields:
|
Required fields:
|
||||||
|
|
@ -184,7 +188,7 @@ $body = @{
|
||||||
Invoke-RestMethod `
|
Invoke-RestMethod `
|
||||||
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
||||||
-Method Post `
|
-Method Post `
|
||||||
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||||
-ContentType "application/json" `
|
-ContentType "application/json" `
|
||||||
-Body $body
|
-Body $body
|
||||||
```
|
```
|
||||||
|
|
@ -237,7 +241,7 @@ Endpoint:
|
||||||
```http
|
```http
|
||||||
POST /api/artifacts
|
POST /api/artifacts
|
||||||
Content-Type: multipart/form-data
|
Content-Type: multipart/form-data
|
||||||
Authorization: Bearer <your-personal-token>
|
Authorization: Bearer <your-personal-key>
|
||||||
```
|
```
|
||||||
|
|
||||||
Required fields:
|
Required fields:
|
||||||
|
|
@ -284,7 +288,7 @@ PowerShell/curl example:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
|
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
|
||||||
-H "Authorization: Bearer <your-personal-token>" `
|
-H "Authorization: Bearer <your-personal-key>" `
|
||||||
-F "source=orion" `
|
-F "source=orion" `
|
||||||
-F "source_type=agent" `
|
-F "source_type=agent" `
|
||||||
-F "source_name=Orion Agent" `
|
-F "source_name=Orion Agent" `
|
||||||
|
|
@ -301,7 +305,7 @@ artifact), upload it first and reference the returned URL in your content.
|
||||||
```http
|
```http
|
||||||
POST /api/assets
|
POST /api/assets
|
||||||
Content-Type: multipart/form-data
|
Content-Type: multipart/form-data
|
||||||
Authorization: Bearer <your-personal-token>
|
Authorization: Bearer <your-personal-key>
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Notes |
|
| Field | Notes |
|
||||||
|
|
@ -343,7 +347,7 @@ Endpoint:
|
||||||
GET /api/artifacts
|
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):
|
Supported filters (combine freely; all are ANDed):
|
||||||
|
|
||||||
|
|
@ -388,7 +392,7 @@ Endpoint:
|
||||||
```http
|
```http
|
||||||
PATCH /api/artifacts/{id}
|
PATCH /api/artifacts/{id}
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: Bearer <your-personal-token>
|
Authorization: Bearer <your-personal-key>
|
||||||
```
|
```
|
||||||
|
|
||||||
Editable fields:
|
Editable fields:
|
||||||
|
|
@ -434,7 +438,7 @@ $body = @{
|
||||||
Invoke-RestMethod `
|
Invoke-RestMethod `
|
||||||
-Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" `
|
-Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" `
|
||||||
-Method Patch `
|
-Method Patch `
|
||||||
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
|
||||||
-ContentType "application/json" `
|
-ContentType "application/json" `
|
||||||
-Body $body
|
-Body $body
|
||||||
```
|
```
|
||||||
|
|
@ -445,44 +449,50 @@ Endpoint:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
DELETE /api/artifacts/{id}
|
DELETE /api/artifacts/{id}
|
||||||
Authorization: Bearer <your-personal-token>
|
Authorization: Bearer <your-personal-key>
|
||||||
```
|
```
|
||||||
|
|
||||||
Removes the artifact's database row and its files (primary file, markdown
|
**Soft-delete:** moves the artifact to Trash rather than removing it — it's
|
||||||
sidecar, and any attached assets). Returns `204` with no body on success.
|
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
|
**Idempotent:** deleting an already-trashed id returns `204` again (not `404`).
|
||||||
does a second delete of the same id.
|
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
|
```bash
|
||||||
curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \
|
curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \
|
||||||
-H "Authorization: Bearer <your-personal-token>"
|
-H "Authorization: Bearer <your-personal-key>"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rendered And Raw Content
|
## 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
|
```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
|
- `markdown` / `html_full` / `json` / `csv` → the exact posted source, as
|
||||||
GET /a/{id}?raw=1
|
`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 |
|
**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
|
||||||
| `markdown` | Preserved `.md` source. |
|
above for token access.
|
||||||
| `html_full` | Stored HTML file served as text. |
|
|
||||||
| `json` | Preserved original JSON text. |
|
|
||||||
| `csv` | Preserved original CSV text. |
|
|
||||||
|
|
||||||
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
|
## Sharing
|
||||||
|
|
||||||
|
|
@ -503,7 +513,7 @@ GET /s/{share_token}
|
||||||
with the link can view it.
|
with the link can view it.
|
||||||
- Images embedded in a shared artifact's content are also served
|
- Images embedded in a shared artifact's content are also served
|
||||||
unauthenticated while that artifact is shared, so inline images don't
|
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
|
- `is_shared` and `share_url` (both in every artifact response — see
|
||||||
`GET /api/artifacts/{id}`) report current sharing status; `share_url` is
|
`GET /api/artifacts/{id}`) report current sharing status; `share_url` is
|
||||||
`null` when not shared.
|
`null` when not shared.
|
||||||
|
|
@ -540,12 +550,11 @@ Example response:
|
||||||
This is generated from the server's actual configuration, so it's the
|
This is generated from the server's actual configuration, so it's the
|
||||||
authoritative source if this document and a running instance ever disagree.
|
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
|
Agents should call this endpoint if they need to adapt to the current AgentFeed instance, or
|
||||||
simply to check the instance is reachable and their token is valid. Unlike the other read
|
simply to check the instance is reachable. It's public — no auth required:
|
||||||
endpoints, it accepts a bearer token as well as a browser session:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H "Authorization: Bearer <your-personal-token>" http://127.0.0.1:8001/api/capabilities
|
curl http://127.0.0.1:8001/api/capabilities
|
||||||
```
|
```
|
||||||
|
|
||||||
## Posting Skill
|
## Posting Skill
|
||||||
|
|
@ -557,20 +566,19 @@ GET /api/skill
|
||||||
GET /api/skill?format=json
|
GET /api/skill?format=json
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the bundled agent-posting skill (`skills/daybook-posting/SKILL.md`) —
|
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
|
the same instructions you'd give an AI so it knows how to publish to this
|
||||||
instance. The server rewrites the `DAYBOOK_URL` line to **this instance's own
|
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;
|
origin**, so the text a caller receives already points at the right server;
|
||||||
a recipient only has to fill in their `DAYBOOK_TOKEN`.
|
a recipient only has to fill in their `AGENTFEED_TOKEN`.
|
||||||
|
|
||||||
Same auth as capabilities (a browser session **or** a bearer token), so a
|
**Public** — no auth required (like capabilities), so an agent can fetch its
|
||||||
logged-in user can copy it to hand to their agent, or an agent can fetch it
|
posting instructions before it has a key. The default response is raw Markdown
|
||||||
directly. The default response is raw Markdown (`text/markdown`); add
|
(`text/markdown`); add `?format=json` for `{"version", "url", "skill"}`.
|
||||||
`?format=json` for `{"version", "url", "skill"}`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Fetch the ready-to-use instructions for this instance
|
# Fetch the ready-to-use instructions for this instance (no auth needed)
|
||||||
curl -H "Authorization: Bearer <your-personal-token>" http://127.0.0.1:8001/api/skill
|
curl http://127.0.0.1:8001/api/skill
|
||||||
```
|
```
|
||||||
|
|
||||||
Discover it programmatically via `skill_url` in the capabilities response.
|
Discover it programmatically via `skill_url` in the capabilities response.
|
||||||
|
|
@ -582,11 +590,11 @@ For automated test installations, a programmatic surface can provision them
|
||||||
too — but it is **gated** and **off in deployment**, because it returns key
|
too — but it is **gated** and **off in deployment**, because it returns key
|
||||||
material over the API.
|
material over the API.
|
||||||
|
|
||||||
The gate is `DAYBOOK_KEY_PROVISIONING_API`. Unset, it follows `DJANGO_DEBUG`
|
The gate is `AGENTFEED_KEY_PROVISIONING_API`. It is **disabled by default** and
|
||||||
(on locally, off in production); set it explicitly (`0`/`1`) to override.
|
independent of `DJANGO_DEBUG`; set `AGENTFEED_KEY_PROVISIONING_API=1` to enable it
|
||||||
`GET /api/capabilities` reports the current state as `key_provisioning_api`,
|
(test installations only). `GET /api/capabilities` reports the current state as
|
||||||
and only lists the `/api/keys` methods when it's enabled. When disabled, every
|
`key_provisioning_api`, and only lists the `/api/keys` methods when it's
|
||||||
method below returns `403`.
|
enabled. When disabled, every method below returns `403`.
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/keys # list this user's keys (incl. key values)
|
GET /api/keys # list this user's keys (incl. key values)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Deployment
|
# 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
|
server, a Proxmox guest, whatever. It runs as a single Docker container
|
||||||
(Gunicorn) with all persistent state — the SQLite database and artifact files —
|
(Gunicorn) with all persistent state — the SQLite database and artifact files —
|
||||||
on a bind-mounted `./data` directory outside the image.
|
on a bind-mounted `./data` directory outside the image.
|
||||||
|
|
@ -8,7 +8,7 @@ on a bind-mounted `./data` directory outside the image.
|
||||||
```text
|
```text
|
||||||
Target host (cloud VM, home server, ...)
|
Target host (cloud VM, home server, ...)
|
||||||
Docker Engine
|
Docker Engine
|
||||||
daybook container
|
agentfeed container
|
||||||
/app application code baked into the image
|
/app application code baked into the image
|
||||||
/data persistent volume: db.sqlite3, artifacts_store/
|
/data persistent volume: db.sqlite3, artifacts_store/
|
||||||
```
|
```
|
||||||
|
|
@ -17,8 +17,8 @@ Target host (cloud VM, home server, ...)
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Dockerfile` | Builds the Daybook image. |
|
| `Dockerfile` | Builds the AgentFeed image. |
|
||||||
| `docker-compose.yml` | Runs Daybook with a persistent `./data:/data` bind mount. |
|
| `docker-compose.yml` | Runs AgentFeed with a persistent `./data:/data` bind mount. |
|
||||||
| `entrypoint.sh` | Runs migrations, collects static files, then starts Gunicorn. |
|
| `entrypoint.sh` | Runs migrations, collects static files, then starts Gunicorn. |
|
||||||
| `env.example` | Template for deployment environment variables. |
|
| `env.example` | Template for deployment environment variables. |
|
||||||
|
|
||||||
|
|
@ -59,12 +59,12 @@ Edit `deploy/.env` and set at minimum:
|
||||||
DJANGO_DEBUG=0
|
DJANGO_DEBUG=0
|
||||||
DJANGO_SECRET_KEY=<generated-secret>
|
DJANGO_SECRET_KEY=<generated-secret>
|
||||||
DJANGO_ALLOWED_HOSTS=<vm-ip-or-dns-name>,localhost,127.0.0.1
|
DJANGO_ALLOWED_HOSTS=<vm-ip-or-dns-name>,localhost,127.0.0.1
|
||||||
DAYBOOK_DATA_DIR=/data
|
AGENTFEED_DATA_DIR=/data
|
||||||
DATABASE_PATH=/data/db.sqlite3
|
DATABASE_PATH=/data/db.sqlite3
|
||||||
ARTIFACTS_ROOT=/data/artifacts_store
|
ARTIFACTS_ROOT=/data/artifacts_store
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no shared API token to configure — Daybook uses per-user API keys
|
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
|
created under Settings → API Keys once a user exists (each user can hold
|
||||||
several, one per agent, each individually revocable). Do not commit
|
several, one per agent, each individually revocable). Do not commit
|
||||||
`deploy/.env`.
|
`deploy/.env`.
|
||||||
|
|
@ -77,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
|
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
|
```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
|
## Verify Deployment
|
||||||
|
|
@ -100,13 +100,13 @@ smoke-test post:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \
|
curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \
|
||||||
-H "Authorization: Bearer <your-personal-token>" \
|
-H "Authorization: Bearer <your-personal-key>" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"source": "deploy-smoke",
|
"source": "deploy-smoke",
|
||||||
"kind": "markdown",
|
"kind": "markdown",
|
||||||
"title": "Deployment Smoke Test",
|
"title": "Deployment Smoke Test",
|
||||||
"content": "# Deployment Smoke Test\n\nDaybook accepted an artifact."
|
"content": "# Deployment Smoke Test\n\nAgentFeed accepted an artifact."
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -127,37 +127,68 @@ picked up automatically. `deploy/.env` and `deploy/data/` are untouched by
|
||||||
## Operational Commands
|
## Operational Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f deploy/docker-compose.yml logs -f daybook
|
docker compose -f deploy/docker-compose.yml logs -f agentfeed
|
||||||
docker compose -f deploy/docker-compose.yml restart daybook
|
docker compose -f deploy/docker-compose.yml restart agentfeed
|
||||||
docker compose -f deploy/docker-compose.yml down
|
docker compose -f deploy/docker-compose.yml down
|
||||||
docker compose -f deploy/docker-compose.yml up -d
|
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 agentfeed python manage.py check
|
||||||
docker compose -f deploy/docker-compose.yml exec daybook sh
|
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
|
## Backup And Restore
|
||||||
|
|
||||||
Back up `deploy/data/` — it contains the SQLite database and all artifact
|
Back up `deploy/data/` — it contains the SQLite database and all artifact
|
||||||
files:
|
files:
|
||||||
|
|
||||||
```bash
|
```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:
|
For a consistent snapshot during heavy writes, stop the container first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f deploy/docker-compose.yml down
|
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
|
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.
|
then start it again.
|
||||||
|
|
||||||
## Reverse Proxy Notes
|
## 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://<vm-ip>:8000`.
|
- Proxy to `http://<vm-ip>:8000`.
|
||||||
- Add the public hostname to `DJANGO_ALLOWED_HOSTS`.
|
- Add the public hostname to `DJANGO_ALLOWED_HOSTS`.
|
||||||
|
|
@ -169,8 +200,8 @@ If putting Daybook behind Nginx, Caddy, Traefik, or a cloud load balancer:
|
||||||
terminates TLS and forwards plain HTTP to the container.
|
terminates TLS and forwards plain HTTP to the container.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DJANGO_ALLOWED_HOSTS=daybook.example.com,localhost,127.0.0.1
|
DJANGO_ALLOWED_HOSTS=agentfeed.example.com,localhost,127.0.0.1
|
||||||
DJANGO_CSRF_TRUSTED_ORIGINS=https://daybook.example.com
|
DJANGO_CSRF_TRUSTED_ORIGINS=https://agentfeed.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Forwarding the proto header, by proxy:
|
Forwarding the proto header, by proxy:
|
||||||
|
|
@ -189,7 +220,7 @@ Caddy: header_up X-Forwarded-Proto https
|
||||||
- If behind a reverse proxy: `DJANGO_CSRF_TRUSTED_ORIGINS` is set and the
|
- If behind a reverse proxy: `DJANGO_CSRF_TRUSTED_ORIGINS` is set and the
|
||||||
proxy forwards `X-Forwarded-Proto: https` (login will 403 otherwise).
|
proxy forwards `X-Forwarded-Proto: https` (login will 403 otherwise).
|
||||||
- `deploy/data/` exists and persists across `git pull` + rebuild.
|
- `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
|
- `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).
|
session (check the status code — don't use `curl -f`, which errors on 403).
|
||||||
- At least one superuser exists (`manage.py createsuperuser`) with a key
|
- At least one superuser exists (`manage.py createsuperuser`) with a key
|
||||||
|
|
@ -198,6 +229,7 @@ Caddy: header_up X-Forwarded-Proto https
|
||||||
## Known MVP Constraints
|
## Known MVP Constraints
|
||||||
|
|
||||||
- SQLite is used by default — fine for a single-VM self-hosted deployment.
|
- 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.
|
- Reader/browser access requires login; feeds are private per user.
|
||||||
- Manual drag/drop upload and content revisions are deferred.
|
- Manual drag/drop upload and content revisions are deferred.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# LLM Posting Helper
|
# 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.
|
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.
|
them, or set them in your agent harness's own env config.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DAYBOOK_URL=http://127.0.0.1:8001
|
AGENTFEED_URL=http://127.0.0.1:8001
|
||||||
DAYBOOK_TOKEN=your-personal-api-token
|
AGENTFEED_TOKEN=your-personal-api-key
|
||||||
DAYBOOK_SOURCE=agent
|
AGENTFEED_SOURCE=agent
|
||||||
```
|
```
|
||||||
|
|
||||||
`DAYBOOK_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.
|
`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
|
## Commands
|
||||||
|
|
||||||
Check the instance:
|
Check the instance:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --check
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --check
|
||||||
```
|
```
|
||||||
|
|
||||||
Post Markdown from a file:
|
Post Markdown from a file:
|
||||||
|
|
||||||
```bash
|
```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:
|
Post Markdown from stdin:
|
||||||
|
|
||||||
```bash
|
```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:
|
Post a deck:
|
||||||
|
|
||||||
```bash
|
```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 `<section>...</section>` blocks.
|
`deck.html` must contain top-level `<section>...</section>` blocks.
|
||||||
|
|
@ -57,44 +57,44 @@ python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Deplo
|
||||||
Post an image/PDF/binary file:
|
Post an image/PDF/binary file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram"
|
python skills/agentfeed-posting/scripts/agentfeed_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 report.pdf --title "Generated Report"
|
||||||
```
|
```
|
||||||
|
|
||||||
Post CSV or JSON as an interactive table/tree (auto-mode detects the kind
|
Post CSV or JSON as an interactive table/tree (auto-mode detects the kind
|
||||||
from the extension; explicit `--kind` works too):
|
from the extension; explicit `--kind` works too):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py results.csv
|
python skills/agentfeed-posting/scripts/agentfeed_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 --kind json --title "Run Summary" --content-file summary.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Post an `.xlsx` spreadsheet (converted server-side to a `csv` artifact —
|
Post an `.xlsx` spreadsheet (converted server-side to a `csv` artifact —
|
||||||
active sheet only, saved formula results only, no formatting preserved):
|
active sheet only, saved formula results only, no formatting preserved):
|
||||||
|
|
||||||
```bash
|
```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`
|
Render other code as a Markdown fenced block instead (no dedicated `code`
|
||||||
kind yet):
|
kind yet):
|
||||||
|
|
||||||
```bash
|
```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
|
Embed an image inside a markdown/HTML artifact (upload first, then
|
||||||
reference the printed URL in your content):
|
reference the printed URL in your content):
|
||||||
|
|
||||||
```bash
|
```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/<id>; put that in your markdown, then post normally
|
# -> prints .../assets/<id>; put that in your markdown, then post normally
|
||||||
```
|
```
|
||||||
|
|
||||||
Dry run:
|
Dry run:
|
||||||
|
|
||||||
```bash
|
```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.
|
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:
|
Use repeatable `--meta key=value` flags:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py \
|
python skills/agentfeed-posting/scripts/agentfeed_post.py \
|
||||||
--kind markdown \
|
--kind markdown \
|
||||||
--title "Run Summary" \
|
--title "Run Summary" \
|
||||||
--content-file summary.md \
|
--content-file summary.md \
|
||||||
|
|
@ -129,7 +129,7 @@ python skills/daybook-posting/scripts/daybook_post.py \
|
||||||
Or pass a JSON object:
|
Or pass a JSON object:
|
||||||
|
|
||||||
```bash
|
```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
|
`--meta` and `--meta-json` are merged if both are given. Metadata lives in
|
||||||
|
|
@ -140,7 +140,7 @@ filtering).
|
||||||
|
|
||||||
| Flag | Default | Notes |
|
| 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-type` | `agent` | One of `agent`, `person`, `system`. |
|
||||||
| `--source-name` | The effective `--source` | Human-readable name, e.g. "Hermes Agent". |
|
| `--source-name` | The effective `--source` | Human-readable name, e.g. "Hermes Agent". |
|
||||||
| `--source-id` | Empty | A stable external ID, if one exists. |
|
| `--source-id` | Empty | A stable external ID, if one exists. |
|
||||||
|
|
@ -152,7 +152,7 @@ filtering).
|
||||||
A thin LLM-facing wrapper lives at:
|
A thin LLM-facing wrapper lives at:
|
||||||
|
|
||||||
```text
|
```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`.
|
||||||
|
|
|
||||||
|
|
@ -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`/`<!doctype`/
|
|
||||||
`<body`/`<section` substring checks (e.g. `<HTML>`, `< 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).
|
|
||||||
|
|
@ -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/<token>`? 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/<token>` 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 `</script>` 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.
|
|
||||||
|
|
@ -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` | `<script>…</script>`, `<img src=x onerror=…>`, `<a href="javascript:…">` | **All pass through verbatim into rendered HTML — executes.** |
|
|
||||||
| `deck` | `<section>…<script>alert(1)</script>` | **Script passes through into the themed deck page — executes.** |
|
|
||||||
| `html_fragment` | `<p>hi</p><script>…</script><svg onload=…>` | **Script/`svg onload` pass through into the themed page — executes.** (The #004 content-shape check blocks `<html>/<body>/<!doctype>` but never `<script>`.) |
|
|
||||||
| `html_full` | broken/garbage doc with `<script>` | Stored verbatim. **By design** — documented as "you own all presentation." Not claimed as a bug here. |
|
|
||||||
| `json` | `{"note":"</SCRIPT><img src=x onerror=…>"}` | **Defended.** `escape_script_body` (`re.compile(r"</script", re.IGNORECASE)`) turns it into `<\/script` inside the `<script type="application/json">` data block; renderjson shows it as inert text. Case-variation bypass (`</SCRIPT>`) correctly caught. |
|
|
||||||
| `csv` | `</script>` 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 `<script>`/`<img onerror>` that ends up verbatim in the
|
|
||||||
posted markdown.
|
|
||||||
|
|
||||||
## Why this is High, not self-XSS
|
|
||||||
|
|
||||||
Two things raise the stakes well past "you attacked your own account":
|
|
||||||
|
|
||||||
1. **No `Content-Security-Policy` header.** Rendered artifact responses
|
|
||||||
carry only `X-Frame-Options: SAMEORIGIN` and `X-Content-Type-Options:
|
|
||||||
nosniff` — nothing stops inline script execution. (A CSP was probably
|
|
||||||
avoided because the json/csv/deck viewers themselves use inline
|
|
||||||
scripts; a nonce-based CSP that tags Daybook's own inline scripts but
|
|
||||||
not user content would fix both this and the by-design kinds at once.)
|
|
||||||
|
|
||||||
2. **Escalation chain against the owner's own session.** When the owner
|
|
||||||
views the malicious artifact at `/a/{id}` (their normal feed preview),
|
|
||||||
the script runs same-origin with the owner's session cookie. Reads are
|
|
||||||
session-authed, so it can `GET /api/artifacts` and exfiltrate every
|
|
||||||
private artifact's metadata. Worse, the `csrftoken` cookie is not
|
|
||||||
HttpOnly, so injected JS can read it and forge session+CSRF POSTs — e.g.
|
|
||||||
hit `POST /a/{id}/share` for every artifact, minting public share links
|
|
||||||
for the owner's entire private feed and exfiltrating them. (Bearer-token
|
|
||||||
writes like PATCH/DELETE are safe — the token isn't in the browser — but
|
|
||||||
share-toggle, mark-read, and keep are session+CSRF and thus reachable.)
|
|
||||||
|
|
||||||
3. **Unauthenticated blast radius via sharing (`91ea19d`).** A shared
|
|
||||||
artifact at `/s/{token}` serves this same rendered HTML with no auth, so
|
|
||||||
a malicious/compromised artifact runs arbitrary JS in the browser of
|
|
||||||
anyone the owner sends the link to — a drive-by/phishing vector on the
|
|
||||||
Daybook origin, not just a self-inflicted one.
|
|
||||||
|
|
||||||
## Suggested Fix
|
|
||||||
|
|
||||||
- **Sanitize rendered output for the non-authoring kinds** — at minimum
|
|
||||||
`markdown`, and I'd argue `deck` too (a "slide sections" kind isn't
|
|
||||||
obviously an arbitrary-script host). Run the generated HTML through an
|
|
||||||
**allowlist** sanitizer that keeps normal formatting (headings, lists,
|
|
||||||
tables, code, images, links) but drops everything else — `<script>`,
|
|
||||||
`on*` handlers, `javascript:`/`data:` URLs. Pygments/codehilite output is
|
|
||||||
a fixed structure, so an allowlist that permits `<pre>/<code>/<span>` plus
|
|
||||||
the highlight `class` names preserves highlighting.
|
|
||||||
|
|
||||||
Two things to get right, both confirmed against current (2026) tooling:
|
|
||||||
|
|
||||||
1. **Use `nh3`, not `bleach`.** `bleach` — the library most people reach
|
|
||||||
for first — is **deprecated and no longer receives security fixes**
|
|
||||||
(it was built on the unmaintained `html5lib`). `nh3` is the Python
|
|
||||||
binding to Rust's `ammonia` sanitizer: allowlist-based, actively
|
|
||||||
maintained, ~20× faster, and single-purpose. It exposes exactly the
|
|
||||||
knobs needed here (`tags`, `attributes`, `allowed_classes`,
|
|
||||||
`url_schemes`).
|
|
||||||
2. **Allowlist, never blocklist.** Do *not* try to fix this by detecting
|
|
||||||
or stripping "bad" patterns (`<script>`, known `on*` handlers, etc.) —
|
|
||||||
that approach loses to case tricks, encoding, novel event handlers,
|
|
||||||
and SVG/MathML vectors, and is the historical source of XSS filter
|
|
||||||
bypasses. Parse-to-DOM + keep-only-known-safe (what `nh3`/`ammonia`
|
|
||||||
does) is the only robust model. Validity checking is a *separate*
|
|
||||||
concern and gives no security benefit — a perfectly valid document can
|
|
||||||
host a malicious `<script>` — so it must not be mistaken for the fix
|
|
||||||
here (it has a legitimate but lesser role as poster-facing "your
|
|
||||||
html_fragment is malformed" UX feedback, tracked separately if wanted).
|
|
||||||
- **Decide `html_fragment` explicitly.** It's marketed as themed
|
|
||||||
"reports/cards/tables," not full pages, and it's shareable — either
|
|
||||||
sanitize it like markdown, or if script is intended, document that
|
|
||||||
`html_fragment` executes arbitrary JS so posters treat it like
|
|
||||||
`html_full` trust-wise. Right now it's an in-between with neither
|
|
||||||
sanitization nor a warning.
|
|
||||||
- **Add a `Content-Security-Policy`** to artifact/shared views. A
|
|
||||||
nonce-based policy (nonce on Daybook's own renderjson/gridjs/deck inline
|
|
||||||
scripts, `script-src 'self' 'nonce-…'`) neuters injected inline scripts
|
|
||||||
even in the HTML-authoring kinds, and is the single highest-leverage
|
|
||||||
mitigation since it covers every kind at once.
|
|
||||||
- **CSV formula injection** (low/debatable): consider prefixing cells that
|
|
||||||
begin with `= + - @` with a `'` in the raw sidecar, or just document it.
|
|
||||||
Many tools treat this as the spreadsheet app's problem; noting it only
|
|
||||||
for completeness, not urging a fix.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- html_full is intentionally verbatim and is **not** being reported as a
|
|
||||||
bug — only flagged so the fix doesn't accidentally start sanitizing it
|
|
||||||
and break its documented contract.
|
|
||||||
- json/csv defenses are genuinely solid; this issue is not about them
|
|
||||||
beyond recording that they were tested and held.
|
|
||||||
|
|
||||||
## Resolution
|
|
||||||
|
|
||||||
Fixed with two layers (both in `artifacts/views.py`):
|
|
||||||
|
|
||||||
1. **Allowlist sanitization at create time** for the themed kinds
|
|
||||||
(`markdown`, `deck`, `html_fragment`) via `nh3` (the maintained
|
|
||||||
`ammonia`/Rust sanitizer, per the issue's recommendation — not the
|
|
||||||
deprecated `bleach`). `sanitize_html()` keeps ordinary formatting,
|
|
||||||
tables, images, links, and the codehilite/Pygments markup, and drops
|
|
||||||
`<script>`, `on*` handlers, `<style>`, unknown tags, and
|
|
||||||
`javascript:`/`data:` URLs (`url_schemes` limited to http/https/mailto;
|
|
||||||
relative `/assets/<id>` image URLs pass through). Verified live that
|
|
||||||
markdown/deck/html_fragment strip script/onerror/onload/svg while tables,
|
|
||||||
syntax highlighting, and relative images survive. `html_full` is
|
|
||||||
deliberately **not** sanitized — it remains the documented verbatim
|
|
||||||
escape hatch.
|
|
||||||
|
|
||||||
2. **Script-blocking CSP at serve time** for the sanitized kinds:
|
|
||||||
`default-src 'self'; script-src 'none'; object-src 'none'; base-uri
|
|
||||||
'none'`, set in `serve_artifact_file` (so it covers both `/a/{id}` and
|
|
||||||
the unauthenticated `/s/{token}` share route). Because it's a response
|
|
||||||
header, it also neutralizes any script in artifacts stored *before* this
|
|
||||||
fix — closing the retroactive gap that create-time sanitization alone
|
|
||||||
would miss. Not applied to `html_full` (its scripts are by contract) or
|
|
||||||
`json`/`csv` (their viewers need JS; their user data is separately
|
|
||||||
escaped via `escape_script_body`).
|
|
||||||
|
|
||||||
Regression tests added in `tests/api/test_artifacts.py` (7): script/handler
|
|
||||||
stripping for each themed kind, formatting/highlighting/relative-image
|
|
||||||
preservation, html_full staying verbatim, and the CSP header being present
|
|
||||||
for sanitized kinds and absent for html_full.
|
|
||||||
|
|
||||||
**Not addressed here (separate, lower-priority items the issue noted):**
|
|
||||||
- CSV formula injection (`=cmd|…` in a cell) — left as documented behavior.
|
|
||||||
- `CSRF_COOKIE_HTTPONLY` — moot now that the XSS vector is closed, but a
|
|
||||||
cheap extra hardening if wanted (our JS reads the token from a form input,
|
|
||||||
not the cookie, so enabling it wouldn't break anything).
|
|
||||||
- A nonce/hash CSP for the `json`/`csv`/`html_full` pages — those keep their
|
|
||||||
current behavior; the strict CSP is scoped to the no-JS themed kinds where
|
|
||||||
it's provably safe.
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
# 015 — Deeply nested JSON raises `RecursionError` → unhandled HTTP 500 (two code paths)
|
|
||||||
|
|
||||||
**Status:** Fixed
|
|
||||||
**Severity:** Medium — unhandled 500 where a 400 is correct; small (~30 KB)
|
|
||||||
request crashes the handling worker (minor DoS shape); full traceback
|
|
||||||
exposed when `DEBUG=True`.
|
|
||||||
|
|
||||||
Round-3 probe of the `json` kind ("deeply nested / very large" from the
|
|
||||||
#013 handover).
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
Two distinct code paths turn deeply-nested JSON into an uncaught
|
|
||||||
`RecursionError` and a 500. The root cause is shared: Python's
|
|
||||||
`RecursionError` is a subclass of `RuntimeError`, **not** `ValueError`, so
|
|
||||||
the usual `except (TypeError, ValueError)` guards around JSON parsing don't
|
|
||||||
catch it.
|
|
||||||
|
|
||||||
### Path 1 — `json` kind content validation
|
|
||||||
|
|
||||||
`validate_content_shape` (`artifacts/views.py:987`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
if kind == Artifact.JSON:
|
|
||||||
try:
|
|
||||||
json.loads(content)
|
|
||||||
except (TypeError, ValueError) as exc: # <-- misses RecursionError
|
|
||||||
return Response({"detail": f"content must be valid JSON: {exc}"}, status=400)
|
|
||||||
```
|
|
||||||
|
|
||||||
Posting a `json` artifact whose `content` is `"[" * 5000 + "]" * 5000`
|
|
||||||
(valid, just deep) raises `RecursionError: maximum recursion depth
|
|
||||||
exceeded while decoding a JSON array from a unicode string`, which escapes
|
|
||||||
the `except` and becomes a 500.
|
|
||||||
|
|
||||||
Reproduced:
|
|
||||||
```
|
|
||||||
POST /api/artifacts kind=json content="[[[...5000...]]]"
|
|
||||||
-> 500 (RecursionError at views.py:987, via post() at views.py:133)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Path 2 — DRF request-body parser (affects ALL kinds)
|
|
||||||
|
|
||||||
The same class of payload in the **request body itself** (e.g. a deeply
|
|
||||||
nested `metadata` object, or nesting anywhere in the JSON body) crashes in
|
|
||||||
DRF's `JSONParser` *before the view runs*, so it isn't `json`-kind
|
|
||||||
specific:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/artifacts kind=markdown metadata={"n":{"n":{...5000...}}}
|
|
||||||
(body ~30 KB, built as a raw string so it's well-formed)
|
|
||||||
-> 500 (RecursionError inside DRF JSONParser)
|
|
||||||
|
|
||||||
Control: same shape at depth 100 -> 201 (confirms it's depth, not size)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Not a size issue
|
|
||||||
|
|
||||||
A 50,000-key flat object (~978 KB) posts fine (`201`). It's specifically
|
|
||||||
nesting *depth* that triggers it, so a byte-size limit wouldn't address
|
|
||||||
this.
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- Wrong status: an unhandled 500 where malformed/pathological input should
|
|
||||||
be a clean 400.
|
|
||||||
- Minor DoS shape: a single ~30 KB request reliably errors out the worker
|
|
||||||
handling it. Not a resource-exhaustion amplifier, but a cheap way to
|
|
||||||
generate server-side 500s.
|
|
||||||
- Info exposure *only* on a `DEBUG=True` instance (like the local dev one
|
|
||||||
this was found on): the full Django traceback page is returned. Prod
|
|
||||||
should have `DEBUG=False`, so there this degrades to a generic 500 — the
|
|
||||||
status-code bug remains either way. (Flagging the traceback exposure only
|
|
||||||
as a reminder to confirm prod `DEBUG` is off, not as the core issue.)
|
|
||||||
|
|
||||||
## Suggested Fix
|
|
||||||
|
|
||||||
- **Path 1 (cheap, local):** add `RecursionError` to the caught exceptions
|
|
||||||
at `views.py:988` (it's a `RuntimeError`, so either list it explicitly or
|
|
||||||
catch `(TypeError, ValueError, RecursionError)`), returning the same 400.
|
|
||||||
- **Path 2 (needs a global guard):** DRF's default exception handler
|
|
||||||
doesn't map `RecursionError` to a 4xx. Options: a custom
|
|
||||||
`REST_FRAMEWORK["EXCEPTION_HANDLER"]` that converts `RecursionError`
|
|
||||||
(and other parse-time `RuntimeError`s) to `400`; and/or a bounded JSON
|
|
||||||
parser / max-nesting-depth check applied to request bodies. A depth cap
|
|
||||||
(e.g. reject bodies nested beyond N levels) is the most robust since it
|
|
||||||
rejects before the recursive descent exhausts the stack.
|
|
||||||
- Consider a single shared "parse JSON safely" helper used by both the
|
|
||||||
content validator and any metadata/tag_colors parsing, so the guard
|
|
||||||
lives in one place.
|
|
||||||
|
|
||||||
## Resolution
|
|
||||||
|
|
||||||
Both paths fixed:
|
|
||||||
|
|
||||||
- **Path 1** (`json`-kind content validation): `validate_content_shape` now
|
|
||||||
catches `RecursionError` alongside `(TypeError, ValueError)`, returning
|
|
||||||
the same `400 content must be valid JSON: …`.
|
|
||||||
- **Path 2** (deep nesting anywhere in the request body, crashing DRF's
|
|
||||||
parser before the view): added a custom DRF `EXCEPTION_HANDLER`
|
|
||||||
(`artifacts/exception_handlers.py`, wired via `REST_FRAMEWORK` in
|
|
||||||
settings) that maps otherwise-unhandled `RecursionError` to
|
|
||||||
`400 Request payload is nested too deeply to parse.` This also covers the
|
|
||||||
same class of error in `metadata`/`tag_colors` string parsing. Anything
|
|
||||||
that isn't a RecursionError still falls through to Django's normal 500.
|
|
||||||
|
|
||||||
Verified live: deep `json` content and a ~5000-deep `metadata` body both
|
|
||||||
return 400; shallow payloads still 201. Tests added in
|
|
||||||
`tests/api/test_artifacts.py`. (A depth-cap parser was considered but the
|
|
||||||
exception-handler approach is simpler and covers every recursive-descent
|
|
||||||
site at once; prod `DEBUG=False` already suppresses the traceback page.)
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
# 016 — Ragged CSV rows silently lose data in the rendered table (server doesn't normalize row width)
|
|
||||||
|
|
||||||
**Status:** Fixed
|
|
||||||
**Severity:** Low-Medium — silent data loss in the primary rendered view;
|
|
||||||
raw sidecar is unaffected, so it's a fidelity/display bug, not permanent
|
|
||||||
loss.
|
|
||||||
|
|
||||||
Round-3 probe of the `csv` kind (ragged rows, from the #013 handover).
|
|
||||||
Confirmed with an actual headless-Chrome render, not just server inspection.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
`wrap_csv_viewer` (`artifacts/views.py`) parses CSV server-side and hands
|
|
||||||
Grid.js `{columns: rows[0], data: rows[1:]}` **without reconciling each
|
|
||||||
data row's width against the header**. Grid.js keys body cells by
|
|
||||||
column-id, so any row that doesn't match the header column count renders
|
|
||||||
wrong:
|
|
||||||
|
|
||||||
- **Row longer than header → extra cells silently dropped.**
|
|
||||||
- **Row shorter than header → missing cells rendered blank.**
|
|
||||||
|
|
||||||
Daybook's stated principle is "the server owns normalization," but row
|
|
||||||
width isn't normalized here, so the display silently disagrees with the
|
|
||||||
data.
|
|
||||||
|
|
||||||
## Reproduction (verified via headless Chrome render)
|
|
||||||
|
|
||||||
Posted `csv` content:
|
|
||||||
|
|
||||||
```
|
|
||||||
a,b,c
|
|
||||||
1,2,3,4,5
|
|
||||||
6,7
|
|
||||||
```
|
|
||||||
|
|
||||||
Server hands Grid.js: `{"columns": ["a","b","c"], "data": [["1","2","3","4","5"], ["6","7"]]}`
|
|
||||||
|
|
||||||
Rendered DOM (headless Chrome, JS executed):
|
|
||||||
|
|
||||||
```
|
|
||||||
| a | b | c |
|
|
||||||
| 1 | 2 | 3 | <- values 4 and 5 are GONE, not shown anywhere
|
|
||||||
| 6 | 7 | | <- third cell blank
|
|
||||||
```
|
|
||||||
|
|
||||||
The `4` and `5` from the first data row do not appear in the rendered
|
|
||||||
table at all. `GET /a/{id}?raw=1` still returns the original CSV intact, so
|
|
||||||
the data isn't destroyed — but a user browsing the artifact (the normal
|
|
||||||
path, and the shared-link path) sees fewer values than the CSV contains,
|
|
||||||
with no truncation indicator.
|
|
||||||
|
|
||||||
This is realistic input: trailing commas, inconsistent exports, and
|
|
||||||
hand-edited CSVs routinely produce rows that don't all match the header
|
|
||||||
width.
|
|
||||||
|
|
||||||
## Other CSV edge cases tested (all fine)
|
|
||||||
|
|
||||||
For the record — these were probed in the same pass and behaved correctly:
|
|
||||||
|
|
||||||
- Embedded commas, quotes (`""` escaping), and newlines inside quoted
|
|
||||||
fields: parsed correctly (Python's `csv` module).
|
|
||||||
- `duplicate columns` (`id,id,id`): rendered (3 same-named headers) — a
|
|
||||||
bit odd for sorting but not a data bug.
|
|
||||||
- `header only`: empty table, fine.
|
|
||||||
- `empty content`: rejected `400`, consistent with `json`.
|
|
||||||
- `single giant cell` (20 KB): rendered fine.
|
|
||||||
- `just commas`: empty-named columns, rendered.
|
|
||||||
|
|
||||||
So the parsing is robust; the gap is specifically row-width normalization
|
|
||||||
before the data reaches Grid.js.
|
|
||||||
|
|
||||||
## Suggested Fix
|
|
||||||
|
|
||||||
In `wrap_csv_viewer`, normalize each data row to `len(columns)` before
|
|
||||||
serializing — pad short rows with `""`, and for over-long rows either (a)
|
|
||||||
truncate but surface it, or better (b) widen `columns` to the maximum row
|
|
||||||
width (auto-naming the extra columns, e.g. `column_4`, `column_5`) so no
|
|
||||||
cell is ever dropped. Option (b) preserves all data, which matches the
|
|
||||||
"server owns normalization, never silently lose content" spirit. Whichever
|
|
||||||
is chosen, the rendered table should never contain fewer values than the
|
|
||||||
raw sidecar.
|
|
||||||
|
|
||||||
## Resolution
|
|
||||||
|
|
||||||
Implemented option (b) in `wrap_csv_viewer`: normalize every row to the
|
|
||||||
maximum row width before handing data to Grid.js. Rows longer than the
|
|
||||||
header widen the column set (extra columns auto-named `Column N`); rows
|
|
||||||
shorter than the width are padded with empty strings. No cell is ever
|
|
||||||
dropped, matching "server owns normalization, never silently lose content."
|
|
||||||
|
|
||||||
Verified live with the issue's exact input (`a,b,c` / `1,2,3,4,5` / `6,7`):
|
|
||||||
grid data becomes `columns=[a,b,c,Column 4,Column 5]`,
|
|
||||||
`data=[[1,2,3,4,5],[6,7,"","",""]]` — the previously-dropped `4` and `5`
|
|
||||||
now render. Test added in `tests/api/test_artifacts.py`.
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
# 018 — Handover to AGENT_DAYBOOK__LLM_TEST (round 3)
|
|
||||||
|
|
||||||
**Status:** For information — a handback covering everything fixed/changed
|
|
||||||
since the round-2 handoff (#013).
|
|
||||||
|
|
||||||
Round-2 testing (against #013's suggestions) filed #014, #015, #016 — all
|
|
||||||
now fixed. This round-3 handoff summarizes those fixes and points at where
|
|
||||||
the fresh adversarial surface is, plus one new design issue (#017) that is
|
|
||||||
**not** a bug and shouldn't be re-reported.
|
|
||||||
|
|
||||||
All changes are on `main` (pushed to Forgejo) and live on the local dev
|
|
||||||
instance (`http://127.0.0.1:8001`) via autoreload — no restart needed.
|
|
||||||
Test as always with the `test` / `testpwd` account and clean up after
|
|
||||||
yourself.
|
|
||||||
|
|
||||||
## Fixes since #013 (issues you filed)
|
|
||||||
|
|
||||||
- **#014 (High) — stored XSS.** `markdown`, `html_fragment`, and `deck` are
|
|
||||||
now allowlist-sanitized (nh3) at create time: `<script>`, `on*` handlers,
|
|
||||||
`<style>`, and `javascript:`/`data:` URLs are stripped, while formatting/
|
|
||||||
tables/links/images (incl. relative `/assets/<id>`) survive. Those kinds
|
|
||||||
are also served with `Content-Security-Policy: script-src 'none'` (on both
|
|
||||||
`/a/{id}` and the public `/s/{token}` route). `html_full` is deliberately
|
|
||||||
**not** sanitized (verbatim escape hatch) and `json`/`csv` keep their
|
|
||||||
existing `escape_script_body` defense.
|
|
||||||
- **#015 (Med) — deeply nested JSON → 500.** Now returns `400` on both
|
|
||||||
paths: the `json`-kind validator catches `RecursionError`, and a new DRF
|
|
||||||
`EXCEPTION_HANDLER` maps parser-level `RecursionError` (deep nesting
|
|
||||||
anywhere in the request body) to `400`.
|
|
||||||
- **#016 (Low-Med) — ragged CSV rows.** `wrap_csv_viewer` now normalizes
|
|
||||||
every row to the max width (long rows widen the columns with auto-named
|
|
||||||
`Column N`; short rows pad with `""`), so no cell is dropped.
|
|
||||||
|
|
||||||
## Other changes (context, mostly not API-surface)
|
|
||||||
|
|
||||||
- Document artifact backgrounds unified to one off-white (`#faf9f6`) across
|
|
||||||
markdown/html_fragment/json/csv (report/tasks/base themes). Deck stays
|
|
||||||
dark; html_full/pdf unchanged. Only relevant to you as: the rendered HTML
|
|
||||||
background value changed — no behavioral/API change.
|
|
||||||
- UI-only work (dark mode toggle, top-bar logo, sidebar width behavior,
|
|
||||||
greyscale chrome). No API surface.
|
|
||||||
- **#017 filed — customizable theme variants.** A *design issue* (feature
|
|
||||||
request), not a bug. Don't re-report theme/color inflexibility as a bug;
|
|
||||||
it's tracked.
|
|
||||||
|
|
||||||
## Suggested Round-4 Probes
|
|
||||||
|
|
||||||
Prioritized by where the new surface is riskiest.
|
|
||||||
|
|
||||||
### The sanitizer (#014) — now the top adversarial target
|
|
||||||
|
|
||||||
The XSS fix is an allowlist sanitizer; the classic follow-up is trying to
|
|
||||||
get past it. Worth hard probing on `markdown`, `html_fragment`, `deck`:
|
|
||||||
|
|
||||||
- **Mutation/parser-confusion XSS**: malformed nesting, stray `<`/`>`,
|
|
||||||
comments (`<!-- -->`), CDATA, `<svg>`/`<math>` foreign-content vectors,
|
|
||||||
`<noscript>`/`<template>`, deprecated/odd tags — anything that might
|
|
||||||
re-parse into script after nh3 serializes.
|
|
||||||
- **Attribute vectors**: `on*` variants, `style=` (should be stripped),
|
|
||||||
`href`/`src` with `javascript:`, `data:`, `vbscript:`, whitespace/newline/
|
|
||||||
encoding tricks (`java\tscript:`, `javascript:`), `srcset`, `formaction`.
|
|
||||||
- **Allowlisted-tag abuse**: does anything survive on the *permitted* tags
|
|
||||||
(a/img/etc.) that shouldn't — e.g. `<a href>` protocol bypasses, `<img
|
|
||||||
src>` with odd schemes?
|
|
||||||
- **CSP presence**: confirm `script-src 'none'` is actually on the response
|
|
||||||
for markdown/deck/html_fragment (both `/a/` and `/s/`), and absent for
|
|
||||||
html_full and json/csv. Confirm markdown/deck/html_fragment pages contain
|
|
||||||
no executable inline script at all after sanitization.
|
|
||||||
- **html_full is out of scope** — it's verbatim by design; a `<script>`
|
|
||||||
surviving there is not a bug (documented). Don't file it.
|
|
||||||
- Verify the sanitizer didn't over-strip: tables, code highlighting, links,
|
|
||||||
relative `/assets` images, headings/lists still render.
|
|
||||||
|
|
||||||
### #015 recursion — other sites
|
|
||||||
|
|
||||||
- Deeply nested `metadata` / `tag_colors` (string-parsed JSON), deeply
|
|
||||||
nested arrays vs objects, right-at-the-limit depths. Confirm 400 not 500.
|
|
||||||
|
|
||||||
### #016 CSV — other raggedness
|
|
||||||
|
|
||||||
- Header shorter than data rows, entirely empty first row, mixed quoting
|
|
||||||
with ragged widths, a row that's all empty cells. Confirm no data lost and
|
|
||||||
the auto-named columns behave.
|
|
||||||
|
|
||||||
### Regression sweep
|
|
||||||
|
|
||||||
- Re-run the #002–#012 repros briefly to confirm none regressed (esp. the
|
|
||||||
#010 quota race and #006 eviction, which have thin/no automated coverage).
|
|
||||||
|
|
||||||
As always: use `test`/`testpwd`, clean up, check `artifacts/views.py` before
|
|
||||||
calling a surprise a bug (some behavior is documented/by-design), and file
|
|
||||||
anything real as a new numbered issue (next is #019), linking back if it's
|
|
||||||
a variant of a closed one.
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
# 019 — Handover to AGENT_DAYBOOK__LLM_TEST (round 4 — pre-deploy sign-off)
|
|
||||||
|
|
||||||
**Status:** For information — final regression pass before deployment.
|
|
||||||
|
|
||||||
This is the **last test round before the app is deployed**. Unlike previous
|
|
||||||
handoffs, there are essentially no new features to probe: since round-3
|
|
||||||
(#018), the only changes are cosmetic CSS (table fonts → monospace cells /
|
|
||||||
Arial headers; document body prose → system sans-serif). **No API or
|
|
||||||
behavioral surface changed.** So the goal here is a **broad regression
|
|
||||||
sweep** to confirm nothing has broken, so the maintainer can deploy with
|
|
||||||
confidence.
|
|
||||||
|
|
||||||
Everything is on `main` (pushed) and live on the local dev instance
|
|
||||||
(`http://127.0.0.1:8001`). Use the `test` / `testpwd` account and clean up
|
|
||||||
what you create.
|
|
||||||
|
|
||||||
## What to do: full regression sweep
|
|
||||||
|
|
||||||
Confirm the whole API still behaves. Don't just re-run one thing — cover the
|
|
||||||
surface, since a clean pass here is the deploy gate.
|
|
||||||
|
|
||||||
### Core CRUD, every kind
|
|
||||||
- `POST` each text kind (markdown, html_fragment, html_full, deck, json,
|
|
||||||
csv) → `201`, correct `kind`, renders, `raw_available` correct.
|
|
||||||
- `POST` each file kind (image, pdf, binary) and `.xlsx` (→ converts to csv).
|
|
||||||
- `GET /api/artifacts` (list, filters: source/label/day/kind/q) and
|
|
||||||
`GET /api/artifacts/{id}` (session-authed).
|
|
||||||
- `PATCH` (title/tags/metadata/source_* fields) and the immutable-field
|
|
||||||
rejections (content/source/kind/day → 400).
|
|
||||||
- `DELETE` → 204, files gone, second delete 404s.
|
|
||||||
- `/api/capabilities` returns the current kind/method/filter lists.
|
|
||||||
|
|
||||||
### Auth boundaries (important pre-deploy)
|
|
||||||
- No token / bad token on writes → 403.
|
|
||||||
- One user's token cannot read/patch/delete another user's artifact (404).
|
|
||||||
- List/detail reads require a session, not just a bearer token.
|
|
||||||
|
|
||||||
### Confirm every closed issue stays fixed
|
|
||||||
Spot-check the earlier repros — these are the regression-risk areas:
|
|
||||||
- **#014** — script/onerror in markdown/deck/html_fragment still stripped;
|
|
||||||
CSP `script-src 'none'` present on those (and `/s/`); html_full still
|
|
||||||
verbatim.
|
|
||||||
- **#015** — deeply nested JSON (content *and* request body) → 400, not 500.
|
|
||||||
- **#016** — ragged CSV rows don't drop cells.
|
|
||||||
- **#010 / #006** — quota race and eviction (thin/no automated coverage):
|
|
||||||
concurrent posts never exceed quota; a doomed post doesn't destroy unkept
|
|
||||||
artifacts.
|
|
||||||
- **#008** — non-string source_* → 400.
|
|
||||||
- **#003 / #004** — length limits and content-shape checks still enforced.
|
|
||||||
|
|
||||||
### Sharing (the unauthenticated surface — worth re-checking before going live)
|
|
||||||
- Share toggle creates/clears the token; `/s/{token}` serves unauthenticated;
|
|
||||||
unsharing kills the old link immediately; a bad/guessed token 404s; an
|
|
||||||
unshared artifact's embedded assets are not reachable anonymously.
|
|
||||||
|
|
||||||
## Cosmetic changes since #018 (not API-testable, FYI only)
|
|
||||||
|
|
||||||
- Table cells render in monospace, headers in Arial.
|
|
||||||
- Document body prose is system sans-serif (was serif in the report theme).
|
|
||||||
- Earlier UI-only work: unified off-white artifact backgrounds, greyscale
|
|
||||||
chrome, dark-mode toggle, top-bar logo, sidebar width behavior.
|
|
||||||
|
|
||||||
None of these change API responses; verify only if a render looks broken.
|
|
||||||
|
|
||||||
## Out of scope / don't report
|
|
||||||
- **#017** (customizable theme variants) is an accepted design issue, not a
|
|
||||||
bug.
|
|
||||||
- `html_full` being verbatim/unsanitized is by design.
|
|
||||||
- `pdf` using the browser's grey viewer chrome is expected.
|
|
||||||
|
|
||||||
## Outcome
|
|
||||||
- **Clean sweep → the maintainer deploys.** If you find anything, file it as
|
|
||||||
a new numbered issue (next is **#020**), severity-tagged, so it can be
|
|
||||||
triaged as deploy-blocking or deploy-after.
|
|
||||||
|
|
||||||
As always: `test`/`testpwd`, clean up, and check `artifacts/views.py` before
|
|
||||||
calling a surprise a bug.
|
|
||||||
43
issues/020-in-app-user-guide.md
Normal file
43
issues/020-in-app-user-guide.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# 020 — In-app user guide (Settings → Guide)
|
||||||
|
|
||||||
|
**Status:** Open
|
||||||
|
**Type:** Feature
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Accounts get created for other people (via Settings → Users), who then log in
|
||||||
|
with **zero context**. The repo `docs/` are invisible to anyone without
|
||||||
|
repository access, and the About page only shows the version. A new user has
|
||||||
|
no in-product answer to "what is this and how do I use it?"
|
||||||
|
|
||||||
|
The fix is a short, task-oriented **in-app user guide** — not a copy of the
|
||||||
|
repo docs (those target agent builders and operators and would rot if
|
||||||
|
duplicated).
|
||||||
|
|
||||||
|
## Proposed approach
|
||||||
|
|
||||||
|
- A new **Settings → Guide** page, in the nav under the "You" group near
|
||||||
|
About (see the You/Admin grouping added in v0.2.1).
|
||||||
|
- Concise, human-facing content: what a feed is, labels (source + tags),
|
||||||
|
getting an API key (link to Settings → API Keys), sharing links, dark mode.
|
||||||
|
- A "Posting from an agent" section that points at (or inlines) the bundled
|
||||||
|
posting skill — which is already served at `GET /api/skill` — so the
|
||||||
|
agent-facing part never drifts from the real skill.
|
||||||
|
|
||||||
|
Two implementation options for the body:
|
||||||
|
1. Hand-written template content (simplest, fully styled).
|
||||||
|
2. Hybrid: a written human intro + the skill rendered/linked for the agent
|
||||||
|
part (keeps agent instructions auto-synced with `SKILL.md`).
|
||||||
|
|
||||||
|
Recommend the hybrid.
|
||||||
|
|
||||||
|
## Scope / notes
|
||||||
|
|
||||||
|
- Keep it a **user** guide; leave API reference, deployment, and `/admin/`
|
||||||
|
in the repo `docs/`. Link out rather than duplicate.
|
||||||
|
- No new models needed; a view + template + one nav entry.
|
||||||
|
- Candidate release: a future minor (e.g. v0.2.2), not urgent.
|
||||||
|
|
||||||
|
## Resolution
|
||||||
|
|
||||||
|
_(open)_
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
# Daybook Posting Helper
|
# AgentFeed Posting Helper
|
||||||
|
|
||||||
`scripts/daybook_post.py` is a compatibility wrapper for the canonical zero-dependency helper at `skills/daybook-posting/scripts/daybook_post.py`.
|
`scripts/agentfeed_post.py` is a compatibility wrapper for the canonical zero-dependency helper at `skills/agentfeed-posting/scripts/agentfeed_post.py`.
|
||||||
|
|
||||||
The helper is for agents and scripts that need to publish artifacts to Daybook without hand-crafting JSON, curl, or multipart requests.
|
The helper is for agents and scripts that need to publish artifacts to AgentFeed without hand-crafting JSON, curl, or multipart requests.
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DAYBOOK_URL=http://127.0.0.1:8001
|
AGENTFEED_URL=http://127.0.0.1:8001
|
||||||
DAYBOOK_TOKEN=your-personal-api-token
|
AGENTFEED_TOKEN=your-personal-api-token
|
||||||
DAYBOOK_SOURCE=agent
|
AGENTFEED_SOURCE=agent
|
||||||
```
|
```
|
||||||
|
|
||||||
`DAYBOOK_TOKEN` is a personal per-user token from Settings → Account in the app, not shared across users.
|
`AGENTFEED_TOKEN` is a personal per-user token from Settings → Account in the app, not shared across users.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --check
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --check
|
||||||
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
|
||||||
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"
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Plan" --content-file deck.html
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --kind deck --title "Plan" --content-file deck.html
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram"
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --file diagram.svg --title "Architecture Diagram"
|
||||||
python skills/daybook-posting/scripts/daybook_post.py --file results.csv --as-markdown --title "Results CSV"
|
python skills/agentfeed-posting/scripts/agentfeed_post.py --file results.csv --as-markdown --title "Results CSV"
|
||||||
```
|
```
|
||||||
|
|
||||||
The artifact browser URL is printed as the final stdout line.
|
The artifact browser URL is printed as the final stdout line.
|
||||||
|
|
|
||||||
16
scripts/agentfeed_post.py
Normal file
16
scripts/agentfeed_post.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compatibility wrapper for the AgentFeed posting helper.
|
||||||
|
|
||||||
|
The canonical helper lives inside the self-contained skill folder:
|
||||||
|
skills/agentfeed-posting/scripts/agentfeed_post.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import runpy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
HELPER = Path(__file__).resolve().parent.parent / "skills" / "agentfeed-posting" / "scripts" / "agentfeed_post.py"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
runpy.run_path(str(HELPER), run_name="__main__")
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Compatibility wrapper for the Daybook posting helper.
|
|
||||||
|
|
||||||
The canonical helper lives inside the self-contained skill folder:
|
|
||||||
skills/daybook-posting/scripts/daybook_post.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import runpy
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
HELPER = Path(__file__).resolve().parent.parent / "skills" / "daybook-posting" / "scripts" / "daybook_post.py"
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
runpy.run_path(str(HELPER), run_name="__main__")
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Post one demo artifact of every kind, tagged, dated today.
|
"""Post one demo artifact of every kind, tagged, dated today.
|
||||||
|
|
||||||
Posts through the real ingest API using the existing daybook_post.py helper,
|
Posts through the real ingest API using the existing agentfeed_post.py helper,
|
||||||
so seeded data goes through the same normalization as real agent output.
|
so seeded data goes through the same normalization as real agent output.
|
||||||
|
|
||||||
Usage (from project root, with the dev server running):
|
Usage (from project root, with the dev server running):
|
||||||
|
|
@ -13,7 +13,7 @@ import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
HELPER = Path(__file__).resolve().parent.parent / "skills" / "daybook-posting" / "scripts" / "daybook_post.py"
|
HELPER = Path(__file__).resolve().parent.parent / "skills" / "agentfeed-posting" / "scripts" / "agentfeed_post.py"
|
||||||
PERSONA = "seed-demo"
|
PERSONA = "seed-demo"
|
||||||
TAGS = "seed-demo,demo"
|
TAGS = "seed-demo,demo"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
---
|
---
|
||||||
name: daybook-posting
|
name: agentfeed-posting
|
||||||
description: Use when you have produced a finished deliverable such as a report, summary, brief, diagram, generated HTML, PDF, deck, or status update that a human should review later outside this chat. Publishes the artifact to a Daybook instance and returns a URL.
|
description: Use when you have produced a finished deliverable such as a report, summary, brief, diagram, generated HTML, PDF, deck, or status update that a human should review later outside this chat. Publishes the artifact to a AgentFeed instance and returns a URL.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Daybook Posting
|
# AgentFeed Posting
|
||||||
|
|
||||||
Daybook is a private artifact journal for completed agent outputs. It stores, indexes, and displays artifacts for human review. It does not execute commands, run workflows, or continue conversations.
|
AgentFeed is a private artifact journal for completed agent outputs. It stores, indexes, and displays artifacts for human review. It does not execute commands, run workflows, or continue conversations.
|
||||||
|
|
||||||
You post to a **running Daybook server**, which may be anywhere — a cloud VM, a container, or a workstation. You do **not** need a checkout of Daybook's source. The only things that tie you to a particular instance are two environment variables: `DAYBOOK_URL` (where the server is) and `DAYBOOK_TOKEN` (who you are). Set those and the rest of this skill works unchanged regardless of where you or the server run.
|
You post to a **running AgentFeed server**, which may be anywhere — a cloud VM, a container, or a workstation. You do **not** need a checkout of AgentFeed's source. The only things that tie you to a particular instance are two environment variables: `AGENTFEED_URL` (where the server is) and `AGENTFEED_TOKEN` (who you are). Set those and the rest of this skill works unchanged regardless of where you or the server run.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|
@ -17,20 +17,20 @@ config) — the helper reads `os.environ` directly. There is no `.env` file
|
||||||
support; a `.env` file in the working directory is not read automatically.
|
support; a `.env` file in the working directory is not read automatically.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DAYBOOK_URL=https://your-daybook-instance.example.com # your server's base URL
|
AGENTFEED_URL=https://your-agentfeed-instance.example.com # your server's base URL
|
||||||
DAYBOOK_TOKEN=your-personal-api-key # from Settings -> API Keys
|
AGENTFEED_TOKEN=your-personal-api-key # from Settings -> API Keys
|
||||||
DAYBOOK_SOURCE=agent # a stable label for what's posting
|
AGENTFEED_SOURCE=agent # a stable label for what's posting
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`DAYBOOK_URL`** — the base URL of the Daybook instance you're posting to.
|
- **`AGENTFEED_URL`** — the base URL of the AgentFeed instance you're posting to.
|
||||||
Set it to your server. If it's unset, the helper falls back to
|
Set it to your server. If it's unset, the helper falls back to
|
||||||
`http://127.0.0.1:8001`, which is only correct when Daybook is running
|
`http://127.0.0.1:8001`, which is only correct when AgentFeed is running
|
||||||
locally on the same machine (its development default). For any remote or
|
locally on the same machine (its development default). For any remote or
|
||||||
deployed instance you **must** set this.
|
deployed instance you **must** set this.
|
||||||
- **`DAYBOOK_TOKEN`** — a personal API key, created in the app under Settings →
|
- **`AGENTFEED_TOKEN`** — a personal API key, created in the app under Settings →
|
||||||
API Keys (name one per agent so you can revoke them individually). Not a key
|
API Keys (name one per agent so you can revoke them individually). Not a key
|
||||||
shared by everyone; artifacts posted with it land only in your feed.
|
shared by everyone; artifacts posted with it land only in your feed.
|
||||||
- **`DAYBOOK_SOURCE`** — a free-text label for whatever is posting
|
- **`AGENTFEED_SOURCE`** — a free-text label for whatever is posting
|
||||||
(agent-profile, script, or person name). Optional; can also be passed per
|
(agent-profile, script, or person name). Optional; can also be passed per
|
||||||
call with `--source`.
|
call with `--source`.
|
||||||
|
|
||||||
|
|
@ -41,29 +41,29 @@ PowerShell multipart, or inline shell content (a `curl` fallback for
|
||||||
environments without the helper is documented at the end — the script is
|
environments without the helper is documented at the end — the script is
|
||||||
still preferred everywhere it's available).
|
still preferred everywhere it's available).
|
||||||
|
|
||||||
The helper lives at **`scripts/daybook_post.py` inside this skill's own
|
The helper lives at **`scripts/agentfeed_post.py` inside this skill's own
|
||||||
directory** — not in any project you happen to be working in. Invoke it by
|
directory** — not in any project you happen to be working in. Invoke it by
|
||||||
its absolute path. For brevity, the examples below assume you've captured
|
its absolute path. For brevity, the examples below assume you've captured
|
||||||
that path once:
|
that path once:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Point this at the helper inside THIS skill directory (absolute path).
|
# Point this at the helper inside THIS skill directory (absolute path).
|
||||||
DAYBOOK_POST=/abs/path/to/this/skill/scripts/daybook_post.py
|
AGENTFEED_POST=/abs/path/to/this/skill/scripts/agentfeed_post.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Then:
|
Then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python "$DAYBOOK_POST" --check
|
python "$AGENTFEED_POST" --check
|
||||||
python "$DAYBOOK_POST" --kind markdown --title "Build Report" --content-file report.md
|
python "$AGENTFEED_POST" --kind markdown --title "Build Report" --content-file report.md
|
||||||
cat report.md | python "$DAYBOOK_POST" --kind markdown --title "Build Report"
|
cat report.md | python "$AGENTFEED_POST" --kind markdown --title "Build Report"
|
||||||
python "$DAYBOOK_POST" --file diagram.svg --title "Architecture Diagram"
|
python "$AGENTFEED_POST" --file diagram.svg --title "Architecture Diagram"
|
||||||
```
|
```
|
||||||
|
|
||||||
The artifact URL is printed as the final stdout line.
|
The artifact URL is printed as the final stdout line.
|
||||||
|
|
||||||
(If you *are* working inside a Daybook source checkout, a compatibility
|
(If you *are* working inside a AgentFeed source checkout, a compatibility
|
||||||
wrapper also exists at `scripts/daybook_post.py` in the repo root, but the
|
wrapper also exists at `scripts/agentfeed_post.py` in the repo root, but the
|
||||||
canonical helper is the one bundled in this skill.)
|
canonical helper is the one bundled in this skill.)
|
||||||
|
|
||||||
## Kind Cheat Sheet
|
## Kind Cheat Sheet
|
||||||
|
|
@ -100,7 +100,7 @@ with no API equivalent at all yet.
|
||||||
|
|
||||||
## CDN Snippets For `html_full`
|
## CDN Snippets For `html_full`
|
||||||
|
|
||||||
`html_full` is Daybook's escape hatch for custom pages — nothing is
|
`html_full` is AgentFeed's escape hatch for custom pages — nothing is
|
||||||
injected into it, so a poster wanting Tailwind or a charting library has to
|
injected into it, so a poster wanting Tailwind or a charting library has to
|
||||||
supply its own `<link>`/`<script>` tags. Known-good ones:
|
supply its own `<link>`/`<script>` tags. Known-good ones:
|
||||||
|
|
||||||
|
|
@ -113,13 +113,13 @@ supply its own `<link>`/`<script>` tags. Known-good ones:
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
**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
|
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
|
Tailwind's in-browser compiler) without internet access. If the deployment
|
||||||
target is offline, don't reach for these — use `markdown`/`html_fragment`
|
target is offline, don't reach for these — use `markdown`/`html_fragment`
|
||||||
instead, which are styled entirely server-side with no external requests.
|
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`
|
vendors Grid.js and renderjson locally (used to render `csv`/`json`
|
||||||
artifacts), and they're reachable as plain static files from any artifact,
|
artifacts), and they're reachable as plain static files from any artifact,
|
||||||
including `html_full`:
|
including `html_full`:
|
||||||
|
|
@ -138,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.
|
CDN snippets above are still the only option for those.
|
||||||
|
|
||||||
Only use this for `html_full` specifically — never add CDN references to
|
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
|
poster, owns styling; injecting a default CDN habit into them would
|
||||||
undermine that.
|
undermine that.
|
||||||
|
|
||||||
|
|
@ -149,12 +149,12 @@ artifact), upload it first and reference the returned URL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Upload -> prints the URL as the last line
|
# 1. Upload -> prints the URL as the last line
|
||||||
python "$DAYBOOK_POST" --upload-image chart.png
|
python "$AGENTFEED_POST" --upload-image chart.png
|
||||||
|
|
||||||
# 2. Reference that URL in your markdown, then post as usual.
|
# 2. Reference that URL in your markdown, then post as usual.
|
||||||
# The printed URL is absolute against your DAYBOOK_URL, e.g.
|
# The printed URL is absolute against your AGENTFEED_URL, e.g.
|
||||||
# 
|
# 
|
||||||
python "$DAYBOOK_POST" report.md
|
python "$AGENTFEED_POST" report.md
|
||||||
```
|
```
|
||||||
|
|
||||||
The image is linked to the artifact that references it and is deleted along with it.
|
The image is linked to the artifact that references it and is deleted along with it.
|
||||||
|
|
@ -167,15 +167,15 @@ just post the file positionally; auto-mode detects `.csv`/`.json` and picks
|
||||||
the right kind automatically:
|
the right kind automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python "$DAYBOOK_POST" results.csv
|
python "$AGENTFEED_POST" results.csv
|
||||||
python "$DAYBOOK_POST" --kind json --title "Run Summary" --content-file summary.json
|
python "$AGENTFEED_POST" --kind json --title "Run Summary" --content-file summary.json
|
||||||
```
|
```
|
||||||
|
|
||||||
`.xlsx` spreadsheets work too, with no special flag needed — just `--file`
|
`.xlsx` spreadsheets work too, with no special flag needed — just `--file`
|
||||||
(or post it positionally, same as any other file):
|
(or post it positionally, same as any other file):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python "$DAYBOOK_POST" balance_sheet.xlsx
|
python "$AGENTFEED_POST" balance_sheet.xlsx
|
||||||
```
|
```
|
||||||
|
|
||||||
The server converts it to a `csv` artifact automatically. Known
|
The server converts it to a `csv` artifact automatically. Known
|
||||||
|
|
@ -196,7 +196,7 @@ as a binary, use `--file path --as-markdown` to wrap it in a Markdown
|
||||||
fenced code block (still gets syntax highlighting):
|
fenced code block (still gets syntax highlighting):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python "$DAYBOOK_POST" --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.
|
Without `--as-markdown`, it uploads as a downloadable binary file instead.
|
||||||
|
|
@ -208,7 +208,7 @@ All optional, all default to something reasonable if omitted:
|
||||||
| Flag | Default | Notes |
|
| Flag | Default | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `--source-type` | `agent` | One of `agent`, `person`, `system`. |
|
| `--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-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. |
|
| `--source-prompt` | Empty string | The prompt/instruction that produced this artifact — useful for later tracing why something was posted. |
|
||||||
|
|
||||||
|
|
@ -219,9 +219,9 @@ context like `run_id` or `model` — these end up in the artifact's
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Run `--check` first if unsure Daybook is reachable.
|
- Run `--check` first if unsure AgentFeed is reachable.
|
||||||
- Make sure `DAYBOOK_URL` points at the right instance and `DAYBOOK_TOKEN` is set before posting anything real.
|
- Make sure `AGENTFEED_URL` points at the right instance and `AGENTFEED_TOKEN` is set before posting anything real.
|
||||||
- Use a stable `DAYBOOK_SOURCE` or pass `--source` (the agent-profile, script, or person name that produced this artifact).
|
- 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 `--tags a,b,c` for filtering.
|
||||||
- Use `--meta run_id=... --meta model=...` for machine-readable context.
|
- Use `--meta run_id=... --meta model=...` for machine-readable context.
|
||||||
- Never inline multiline content into the shell command; use a file or stdin.
|
- Never inline multiline content into the shell command; use a file or stdin.
|
||||||
|
|
@ -236,8 +236,8 @@ with no Python). Text artifacts post as JSON to `POST /api/artifacts` with
|
||||||
bearer-token auth:
|
bearer-token auth:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sS -X POST "$DAYBOOK_URL/api/artifacts" \
|
curl -sS -X POST "$AGENTFEED_URL/api/artifacts" \
|
||||||
-H "Authorization: Bearer $DAYBOOK_TOKEN" \
|
-H "Authorization: Bearer $AGENTFEED_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"kind":"markdown","title":"Build Report","source":"agent","content":"# Hello\n\nBody text."}'
|
-d '{"kind":"markdown","title":"Build Report","source":"agent","content":"# Hello\n\nBody text."}'
|
||||||
```
|
```
|
||||||
|
|
@ -245,12 +245,12 @@ curl -sS -X POST "$DAYBOOK_URL/api/artifacts" \
|
||||||
File/image artifacts use multipart instead:
|
File/image artifacts use multipart instead:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sS -X POST "$DAYBOOK_URL/api/artifacts" \
|
curl -sS -X POST "$AGENTFEED_URL/api/artifacts" \
|
||||||
-H "Authorization: Bearer $DAYBOOK_TOKEN" \
|
-H "Authorization: Bearer $AGENTFEED_TOKEN" \
|
||||||
-F "kind=image" -F "title=Architecture Diagram" -F "source=agent" \
|
-F "kind=image" -F "title=Architecture Diagram" -F "source=agent" \
|
||||||
-F "file=@diagram.png"
|
-F "file=@diagram.png"
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET $DAYBOOK_URL/api/capabilities` (no auth needed beyond a login/token per
|
`GET $AGENTFEED_URL/api/capabilities` (no auth needed beyond a login/token per
|
||||||
that instance's config) lists supported kinds, methods, and the server
|
that instance's config) lists supported kinds, methods, and the server
|
||||||
version if you need to confirm the contract before posting.
|
version if you need to confirm the contract before posting.
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env python3
|
#!/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
|
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.
|
need to inline multiline JSON/HTML/Markdown in shell commands.
|
||||||
|
|
@ -26,11 +26,11 @@ DEFAULT_SOURCE = "agent"
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
base_url = os.environ.get("DAYBOOK_URL", DEFAULT_URL).rstrip("/")
|
base_url = os.environ.get("AGENTFEED_URL", DEFAULT_URL).rstrip("/")
|
||||||
# DAYBOOK_TOKEN is your personal per-user token (Settings -> Account in the app);
|
# 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.
|
# there is no shared fallback token, so posting without it cannot work.
|
||||||
token = os.environ.get("DAYBOOK_TOKEN", "")
|
token = os.environ.get("AGENTFEED_TOKEN", "")
|
||||||
source = args.source or os.environ.get("DAYBOOK_SOURCE", DEFAULT_SOURCE)
|
source = args.source or os.environ.get("AGENTFEED_SOURCE", DEFAULT_SOURCE)
|
||||||
|
|
||||||
if args.check:
|
if args.check:
|
||||||
try:
|
try:
|
||||||
|
|
@ -43,8 +43,8 @@ def main():
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
print(
|
print(
|
||||||
"error: DAYBOOK_TOKEN is not set. Get your personal API token from "
|
"error: AGENTFEED_TOKEN is not set. Get your personal API key from "
|
||||||
f"{base_url}/settings/account/ and set DAYBOOK_TOKEN.",
|
f"{base_url}/settings/api-keys/ and set AGENTFEED_TOKEN.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
|
|
@ -84,11 +84,11 @@ def main():
|
||||||
|
|
||||||
|
|
||||||
def parse_args():
|
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("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("--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("--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("--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("--file", help="Upload this file as image/pdf/binary, or render as markdown with --as-markdown.")
|
||||||
parser.add_argument("--tags", help="Comma-separated tags.")
|
parser.add_argument("--tags", help="Comma-separated tags.")
|
||||||
|
|
@ -352,7 +352,7 @@ def post_payload(base_url, token, payload, args):
|
||||||
|
|
||||||
|
|
||||||
def multipart_body(payload):
|
def multipart_body(payload):
|
||||||
boundary = f"----daybook-{uuid.uuid4().hex}"
|
boundary = f"----agentfeed-{uuid.uuid4().hex}"
|
||||||
chunks = []
|
chunks = []
|
||||||
upload_path = payload["upload_path"]
|
upload_path = payload["upload_path"]
|
||||||
for key, value in payload.items():
|
for key, value in payload.items():
|
||||||
|
|
@ -394,11 +394,11 @@ def content_type_for(path):
|
||||||
|
|
||||||
|
|
||||||
def upload_asset(base_url, token, 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():
|
if not path.is_file():
|
||||||
raise ValueError(f"image file does not exist: {path}")
|
raise ValueError(f"image file does not exist: {path}")
|
||||||
|
|
||||||
boundary = f"----daybook-{uuid.uuid4().hex}"
|
boundary = f"----agentfeed-{uuid.uuid4().hex}"
|
||||||
chunks = [
|
chunks = [
|
||||||
f"--{boundary}\r\n".encode("utf-8"),
|
f"--{boundary}\r\n".encode("utf-8"),
|
||||||
f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'.encode("utf-8"),
|
f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'.encode("utf-8"),
|
||||||
|
|
@ -128,6 +128,42 @@ body {
|
||||||
border-color: var(--control-border);
|
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 {
|
.user-menu-dropdown {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--control-border);
|
border: 1px solid var(--control-border);
|
||||||
|
|
@ -324,6 +360,7 @@ h1 {
|
||||||
|
|
||||||
/* Source sidebar: scrollbar hidden until hover (short list, rarely scrolls). */
|
/* Source sidebar: scrollbar hidden until hover (short list, rarely scrolls). */
|
||||||
.source-list {
|
.source-list {
|
||||||
|
flex: 1 1 auto; /* fill available height so system folders pin to the bottom */
|
||||||
scrollbar-color: transparent transparent;
|
scrollbar-color: transparent transparent;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
@ -477,6 +514,18 @@ h1 {
|
||||||
font-size: 0.7rem;
|
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,
|
.pane-heading span,
|
||||||
.artifact-time {
|
.artifact-time {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|
@ -968,6 +1017,21 @@ kbd {
|
||||||
padding-bottom: 0.7rem !important;
|
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 {
|
.settings-panel {
|
||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
padding: 1.6rem 2rem;
|
padding: 1.6rem 2rem;
|
||||||
|
|
@ -994,6 +1058,173 @@ kbd {
|
||||||
color: var(--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 {
|
.api-key-create {
|
||||||
align-items: end;
|
align-items: end;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1009,6 +1240,32 @@ kbd {
|
||||||
gap: 0.3rem;
|
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 {
|
.api-key-table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
42
templates/artifacts/_artifact_rows.html
Normal file
42
templates/artifacts/_artifact_rows.html
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{% for artifact in artifacts %}
|
||||||
|
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %} {% if artifact.is_kept %}kept{% endif %} {% if artifact.is_shared %}shared{% endif %}"
|
||||||
|
href="?{% if selected_source %}source={{ selected_source|urlencode }}&{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}artifact={{ artifact.id }}&view={{ selected_view }}"
|
||||||
|
data-artifact-id="{{ artifact.id }}"
|
||||||
|
data-title="{{ artifact.title }}"
|
||||||
|
data-kind="{{ artifact.kind }}"
|
||||||
|
data-source="{{ artifact.source }}"
|
||||||
|
data-source-type="{{ artifact.source_type }}"
|
||||||
|
data-source-name="{{ artifact.source_name }}"
|
||||||
|
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
|
||||||
|
data-size="{{ artifact.size_bytes|filesizeformat }}"
|
||||||
|
data-kept="{{ artifact.is_kept|yesno:'1,0' }}"
|
||||||
|
data-shared="{{ artifact.is_shared|yesno:'1,0' }}"
|
||||||
|
data-share-url="{% if artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' artifact.share_token %}{% endif %}"
|
||||||
|
data-tags="{{ artifact.tags|join:', ' }}"
|
||||||
|
data-rendered-url="{% url 'artifact' artifact.id %}"
|
||||||
|
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
|
||||||
|
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
|
||||||
|
<span class="artifact-row-main">
|
||||||
|
<span class="artifact-title">{{ artifact.title }}</span>
|
||||||
|
<span class="artifact-kind-text">{% 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 %}</span>
|
||||||
|
<span class="kept-badge" data-kept-badge title="Kept — never auto-deleted"{% if not artifact.is_kept %} hidden{% endif %}>K</span>
|
||||||
|
<span class="shared-badge" data-shared-badge title="Shared — anyone with the link can view"{% if not artifact.is_shared %} hidden{% endif %}>🔗</span>
|
||||||
|
{% if artifact.label_chips %}
|
||||||
|
<span class="tag-chips">
|
||||||
|
{% for label in artifact.label_chips %}
|
||||||
|
<span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="artifact-source">{{ artifact.source }}</span>
|
||||||
|
<span class="row-actions">
|
||||||
|
{% if in_trash %}
|
||||||
|
<span class="row-action row-undelete" role="button" tabindex="0" data-undelete="{{ artifact.id }}" title="Restore from Trash" aria-label="Restore {{ artifact.title }}">↩</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="row-action row-delete" role="button" tabindex="0" data-delete="{{ artifact.id }}" title="Delete" aria-label="Delete {{ artifact.title }}">🗑</span>
|
||||||
|
<span class="row-action row-menu-trigger" role="button" tabindex="0" data-menu-trigger title="More actions" aria-label="More actions for {{ artifact.title }}">⋯</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
@ -1,43 +1,10 @@
|
||||||
{% for group in day_groups %}
|
{% for group in day_groups %}
|
||||||
<div class="day-group">
|
<div class="day-group"{% if group.day == today %} data-today="1"{% endif %}>
|
||||||
<div class="day-separator">
|
<div class="day-separator">
|
||||||
<span>{{ group.day|date:"D, M j Y" }}{% if group.day == today %} <em class="today-badge">Today</em>{% endif %}</span>
|
<span>{{ group.day|date:"D, M j Y" }}{% if group.day == today %} <em class="today-badge">Today</em>{% endif %}</span>
|
||||||
<span class="day-separator-count">{{ group.artifacts|length }}</span>
|
<span class="day-separator-count">{{ group.artifacts|length }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% for artifact in group.artifacts %}
|
{% include "artifacts/_artifact_rows.html" with artifacts=group.artifacts %}
|
||||||
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %} {% if artifact.is_kept %}kept{% endif %} {% if artifact.is_shared %}shared{% endif %}"
|
|
||||||
href="?{% if selected_source %}source={{ selected_source|urlencode }}&{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}artifact={{ artifact.id }}&view={{ selected_view }}"
|
|
||||||
data-artifact-id="{{ artifact.id }}"
|
|
||||||
data-title="{{ artifact.title }}"
|
|
||||||
data-kind="{{ artifact.kind }}"
|
|
||||||
data-source="{{ artifact.source }}"
|
|
||||||
data-source-type="{{ artifact.source_type }}"
|
|
||||||
data-source-name="{{ artifact.source_name }}"
|
|
||||||
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
|
|
||||||
data-size="{{ artifact.size_bytes|filesizeformat }}"
|
|
||||||
data-kept="{{ artifact.is_kept|yesno:'1,0' }}"
|
|
||||||
data-shared="{{ artifact.is_shared|yesno:'1,0' }}"
|
|
||||||
data-share-url="{% if artifact.is_shared %}{{ request.scheme }}://{{ request.get_host }}{% url 'artifact-shared' artifact.share_token %}{% endif %}"
|
|
||||||
data-tags="{{ artifact.tags|join:', ' }}"
|
|
||||||
data-rendered-url="{% url 'artifact' artifact.id %}"
|
|
||||||
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
|
|
||||||
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
|
|
||||||
<span class="artifact-row-main">
|
|
||||||
<span class="artifact-title">{{ artifact.title }}</span>
|
|
||||||
<span class="artifact-kind-text">{% 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 %}</span>
|
|
||||||
<span class="kept-badge" data-kept-badge title="Kept — never auto-deleted"{% if not artifact.is_kept %} hidden{% endif %}>K</span>
|
|
||||||
<span class="shared-badge" data-shared-badge title="Shared — anyone with the link can view"{% if not artifact.is_shared %} hidden{% endif %}>🔗</span>
|
|
||||||
{% if artifact.label_chips %}
|
|
||||||
<span class="tag-chips">
|
|
||||||
{% for label in artifact.label_chips %}
|
|
||||||
<span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span class="artifact-source">{{ artifact.source }}</span>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if has_more %}
|
{% if has_more %}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
{% load agentfeed_extras %}
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brandline">
|
<div class="brandline">
|
||||||
<a class="brand-link" href="{% url 'feed' %}"><img class="brand-logo" src="/static/logo.png" alt=""><h1>AgentFeed</h1></a>
|
<a class="brand-link" href="{% url 'feed' %}"><img class="brand-logo" src="/static/logo.png" alt=""><h1>AgentFeed</h1></a>
|
||||||
|
|
@ -6,12 +7,12 @@
|
||||||
{% if topbar_toolbar %}{% include topbar_toolbar %}{% endif %}
|
{% if topbar_toolbar %}{% include topbar_toolbar %}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<button class="icon-button" type="button" data-user-menu-toggle aria-haspopup="true" aria-expanded="false" title="{{ request.user.username }}">
|
<button class="avatar-button" type="button" data-user-menu-toggle aria-haspopup="true" aria-expanded="false" title="{{ request.user.username }}" aria-label="{{ request.user.username }} menu">
|
||||||
⚙ {{ request.user.username }}
|
<span class="avatar" style="background: {{ request.user.username|avatar_color }};">{{ request.user.username|avatar_initial }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="user-menu-dropdown" hidden data-user-menu>
|
<div class="user-menu-dropdown" hidden data-user-menu>
|
||||||
|
<div class="user-menu-name">{{ request.user.username }}</div>
|
||||||
<a href="{% url 'settings-account' %}">Settings</a>
|
<a href="{% url 'settings-account' %}">Settings</a>
|
||||||
<a href="{% url 'settings-demo-data' %}">Demo Data</a>
|
|
||||||
<a href="{% url 'settings-about' %}">About</a>
|
<a href="{% url 'settings-about' %}">About</a>
|
||||||
<button type="button" data-theme-toggle>Dark mode</button>
|
<button type="button" data-theme-toggle>Dark mode</button>
|
||||||
<form method="post" action="{% url 'logout' %}">{% csrf_token %}<button type="submit">Log out</button></form>
|
<form method="post" action="{% url 'logout' %}">{% csrf_token %}<button type="submit">Log out</button></form>
|
||||||
|
|
@ -47,10 +48,10 @@
|
||||||
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
|
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
document.documentElement.removeAttribute("data-theme");
|
document.documentElement.removeAttribute("data-theme");
|
||||||
try { localStorage.setItem("daybook.theme", "light"); } catch (e) {}
|
try { localStorage.setItem("agentfeed.theme", "light"); } catch (e) {}
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
try { localStorage.setItem("daybook.theme", "dark"); } catch (e) {}
|
try { localStorage.setItem("agentfeed.theme", "dark"); } catch (e) {}
|
||||||
}
|
}
|
||||||
syncLabel();
|
syncLabel();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
<title>AgentFeed</title>
|
<title>AgentFeed</title>
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" href="/static/daybook.css">
|
<link rel="stylesheet" href="/static/agentfeed.css">
|
||||||
<script>
|
<script>
|
||||||
// Set the theme before first paint so dark-mode users never see a light flash.
|
// Set the theme before first paint so dark-mode users never see a light flash.
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
if (localStorage.getItem("daybook.theme") === "dark") {
|
if (localStorage.getItem("agentfeed.theme") === "dark") {
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
data-selected-kind="{{ selected_kind }}"
|
data-selected-kind="{{ selected_kind }}"
|
||||||
data-title-query="{{ title_query }}"
|
data-title-query="{{ title_query }}"
|
||||||
data-selected-view="{{ selected_view }}"
|
data-selected-view="{{ selected_view }}"
|
||||||
|
data-folder="{{ selected_folder }}"
|
||||||
data-window-start="{{ window_start|date:'Y-m-d' }}"
|
data-window-start="{{ window_start|date:'Y-m-d' }}"
|
||||||
data-older-count="{{ older_count }}">
|
data-older-count="{{ older_count }}">
|
||||||
<aside class="source-sidebar" aria-label="Sources">
|
<aside class="source-sidebar" aria-label="Sources">
|
||||||
|
|
@ -34,33 +35,48 @@
|
||||||
<h2>Sources</h2>
|
<h2>Sources</h2>
|
||||||
</div>
|
</div>
|
||||||
<nav class="source-list">
|
<nav class="source-list">
|
||||||
<a class="source-link {% if not selected_source %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&preview=none">
|
<a class="source-link all-sources-link {% if not selected_source and not selected_folder %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&preview=none">
|
||||||
<span class="source-name">All sources</span>
|
<span class="source-name">All sources</span>
|
||||||
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
|
{% if total_count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ total_count }}</span>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% for entry in source_counts %}
|
{% for entry in source_counts %}
|
||||||
<a class="source-link {% if entry.source == selected_source %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none">
|
<a class="source-link {% if entry.source == selected_source and not selected_folder %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none" data-source="{{ entry.source }}">
|
||||||
<span class="source-name">{{ entry.source }}</span>
|
<span class="source-name">{{ entry.source }}</span>
|
||||||
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
|
{% if entry.count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ entry.count }}</span>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<nav class="system-folders" aria-label="Folders">
|
||||||
|
<a class="source-link folder-link {% if selected_folder == 'keep' %}selected{% endif %}" href="?folder=keep&view={{ selected_view }}&preview=none" data-folder-link="keep">
|
||||||
|
<span class="source-name">★ Keep</span>
|
||||||
|
{% if keep_count %}<span class="source-count">{{ keep_count }}</span>{% endif %}
|
||||||
|
</a>
|
||||||
|
<a class="source-link folder-link {% if selected_folder == 'deleted' %}selected{% endif %}" href="?folder=deleted&view={{ selected_view }}&preview=none" data-folder-link="deleted">
|
||||||
|
<span class="source-name">🗑 Deleted</span>
|
||||||
|
{% if trash_count %}<span class="source-count">{{ trash_count }}</span>{% endif %}
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div>
|
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div>
|
||||||
|
|
||||||
<aside class="artifact-sidebar" aria-label="Artifact feed">
|
<aside class="artifact-sidebar" aria-label="Artifact feed">
|
||||||
<div class="pane-bar pane-heading">
|
<div class="pane-bar pane-heading">
|
||||||
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
<h2>{% if selected_folder == 'deleted' %}Deleted{% elif selected_folder == 'keep' %}Keep{% elif selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
|
||||||
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
{% if selected_folder == 'deleted' %}
|
||||||
|
<button type="button" class="empty-trash-button" data-empty-trash>Empty Trash</button>
|
||||||
|
{% else %}
|
||||||
|
<span>{{ artifacts|length }} message{{ artifacts|length|pluralize }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list>
|
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list data-since="{{ feed_since }}">
|
||||||
{% if day_groups %}
|
{% if day_groups %}
|
||||||
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
|
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty compact">
|
<div class="empty compact">
|
||||||
<h3>No artifacts</h3>
|
<h3>No messages</h3>
|
||||||
<p>Nothing in the last {{ 7 }} days for these filters.</p>
|
<p>Nothing in the last {{ 7 }} days for these filters.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -72,7 +88,7 @@
|
||||||
<section class="preview-pane">
|
<section class="preview-pane">
|
||||||
<header class="pane-bar preview-actions" data-actions>
|
<header class="pane-bar preview-actions" data-actions>
|
||||||
<div class="preview-title" data-preview-title {% if selected_artifact %}title="{{ selected_artifact.title }}"{% endif %}>
|
<div class="preview-title" data-preview-title {% if selected_artifact %}title="{{ selected_artifact.title }}"{% endif %}>
|
||||||
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No artifact loaded{% endif %}
|
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No message loaded{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<nav class="view-toggle" aria-label="Preview mode">
|
<nav class="view-toggle" aria-label="Preview mode">
|
||||||
<a class="{% if selected_view == 'rendered' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ rendered_url }}{% else %}#{% endif %}" data-view="rendered" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Rendered</a>
|
<a class="{% if selected_view == 'rendered' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ rendered_url }}{% else %}#{% endif %}" data-view="rendered" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Rendered</a>
|
||||||
|
|
@ -102,8 +118,8 @@
|
||||||
<img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}">
|
<img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}">
|
||||||
{% elif selected_artifact.kind == "binary" %}
|
{% elif selected_artifact.kind == "binary" %}
|
||||||
<div class="empty preview-empty">
|
<div class="empty preview-empty">
|
||||||
<h2>Binary Artifact</h2>
|
<h2>Binary Message</h2>
|
||||||
<p>This artifact type cannot be previewed inline yet.</p>
|
<p>This message type cannot be previewed inline yet.</p>
|
||||||
<p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p>
|
<p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -113,10 +129,10 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty preview-empty">
|
<div class="empty preview-empty">
|
||||||
{% if artifacts %}
|
{% if artifacts %}
|
||||||
<h2>No artifact loaded</h2>
|
<h2>No message loaded</h2>
|
||||||
<p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p>
|
<p>Press <kbd>Right Arrow</kbd> or select a message from the middle column to preview it.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h2>No artifacts for this day</h2>
|
<h2>No messages for this day</h2>
|
||||||
<p>This day is ready for new agent output.</p>
|
<p>This day is ready for new agent output.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -125,6 +141,25 @@
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div class="row-menu" hidden data-row-menu>
|
||||||
|
<button type="button" data-menu-action="keep">Keep</button>
|
||||||
|
<button type="button" data-menu-action="open">Open</button>
|
||||||
|
<button type="button" data-menu-action="copy">Copy link</button>
|
||||||
|
<button type="button" data-menu-action="delete" class="danger">Delete</button>
|
||||||
|
{% if label_palette %}
|
||||||
|
<div class="row-menu-divider" role="separator"></div>
|
||||||
|
<div class="row-menu-labels">
|
||||||
|
{% for label in label_palette %}
|
||||||
|
<button type="button" class="row-menu-label" data-label-name="{{ label.name }}" data-label-color="{{ label.color }}">
|
||||||
|
<span class="row-menu-dot" style="background:{{ label.color }}"></span>
|
||||||
|
<span class="row-menu-label-name">{{ label.name }}</span>
|
||||||
|
<span class="row-menu-check" aria-hidden="true">✓</span>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const feed = document.querySelector(".feed");
|
const feed = document.querySelector(".feed");
|
||||||
|
|
@ -141,8 +176,8 @@
|
||||||
const sourceSelected = Boolean(feed.dataset.selectedSource);
|
const sourceSelected = Boolean(feed.dataset.selectedSource);
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
days: Number(localStorage.getItem("daybook.daysWidth")) || 144,
|
days: Number(localStorage.getItem("agentfeed.daysWidth")) || 144,
|
||||||
artifacts: Number(localStorage.getItem("daybook.artifactsWidth")) || 280,
|
artifacts: Number(localStorage.getItem("agentfeed.artifactsWidth")) || 280,
|
||||||
};
|
};
|
||||||
|
|
||||||
const applySizes = () => {
|
const applySizes = () => {
|
||||||
|
|
@ -177,7 +212,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const up = () => {
|
const up = () => {
|
||||||
localStorage.setItem(`daybook.${target}Width`, String(sizes[target]));
|
localStorage.setItem(`agentfeed.${target}Width`, String(sizes[target]));
|
||||||
document.body.classList.remove("is-resizing");
|
document.body.classList.remove("is-resizing");
|
||||||
splitter.removeEventListener("pointermove", move);
|
splitter.removeEventListener("pointermove", move);
|
||||||
splitter.removeEventListener("pointerup", up);
|
splitter.removeEventListener("pointerup", up);
|
||||||
|
|
@ -195,7 +230,8 @@
|
||||||
const artifactList = document.querySelector("[data-artifact-list]");
|
const artifactList = document.querySelector("[data-artifact-list]");
|
||||||
// Rows grow as older pages load, so always read them live rather than caching an array.
|
// Rows grow as older pages load, so always read them live rather than caching an array.
|
||||||
const allRows = () => Array.from(document.querySelectorAll(".artifact-row"));
|
const allRows = () => Array.from(document.querySelectorAll(".artifact-row"));
|
||||||
const sourceLinks = Array.from(document.querySelectorAll(".source-link"));
|
// Live-queried so sources added by live updates are keyboard-navigable too.
|
||||||
|
const sourceLinks = () => Array.from(document.querySelectorAll(".source-link"));
|
||||||
const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
|
const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
|
||||||
|
|
||||||
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
|
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
|
||||||
|
|
@ -209,7 +245,7 @@
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(absoluteUrl);
|
await navigator.clipboard.writeText(absoluteUrl);
|
||||||
} catch {
|
} catch {
|
||||||
window.prompt("Copy artifact link", absoluteUrl);
|
window.prompt("Copy message link", absoluteUrl);
|
||||||
}
|
}
|
||||||
const original = button.textContent;
|
const original = button.textContent;
|
||||||
button.textContent = "Copied";
|
button.textContent = "Copied";
|
||||||
|
|
@ -318,7 +354,7 @@
|
||||||
if (!actions) return;
|
if (!actions) return;
|
||||||
const previewTitle = actions.querySelector("[data-preview-title]");
|
const previewTitle = actions.querySelector("[data-preview-title]");
|
||||||
if (previewTitle) {
|
if (previewTitle) {
|
||||||
previewTitle.textContent = "No artifact loaded";
|
previewTitle.textContent = "No message loaded";
|
||||||
previewTitle.removeAttribute("title");
|
previewTitle.removeAttribute("title");
|
||||||
}
|
}
|
||||||
actions.querySelectorAll("[data-view]").forEach((link) => {
|
actions.querySelectorAll("[data-view]").forEach((link) => {
|
||||||
|
|
@ -383,6 +419,335 @@
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findSourceLink = (name) =>
|
||||||
|
sourceLinks().find((link) => link.querySelector(".source-name")?.textContent.trim() === name);
|
||||||
|
|
||||||
|
const bumpSourceBadge = (link, delta) => {
|
||||||
|
let badge = link.querySelector(".source-count");
|
||||||
|
const next = (badge ? Number(badge.textContent) || 0 : 0) + delta;
|
||||||
|
if (next <= 0) {
|
||||||
|
if (badge) badge.remove(); // empty folder/source shows no count badge
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!badge) {
|
||||||
|
badge = document.createElement("span");
|
||||||
|
badge.className = "source-count";
|
||||||
|
link.appendChild(badge);
|
||||||
|
}
|
||||||
|
badge.textContent = String(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSourceLink = (name, count) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("source", name);
|
||||||
|
if (feed.dataset.selectedLabel) params.set("label", feed.dataset.selectedLabel);
|
||||||
|
if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind);
|
||||||
|
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
|
||||||
|
params.set("view", feed.dataset.selectedView || "rendered");
|
||||||
|
params.set("preview", "none");
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.className = "source-link";
|
||||||
|
link.href = `?${params.toString()}`;
|
||||||
|
link.innerHTML =
|
||||||
|
`<span class="source-name">${escapeHtml(name)}</span>` +
|
||||||
|
`<span class="source-count">${count}</span>`;
|
||||||
|
|
||||||
|
// Newest activity goes to the top of the per-source list (just under
|
||||||
|
// the pinned "All sources"), matching the recency ordering.
|
||||||
|
const list = document.querySelector(".source-list");
|
||||||
|
const firstSource = list.querySelector(".source-link:not(.all-sources-link)");
|
||||||
|
list.insertBefore(link, firstSource);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveSourceToTop = (link) => {
|
||||||
|
const list = document.querySelector(".source-list");
|
||||||
|
const firstSource = list.querySelector(".source-link:not(.all-sources-link)");
|
||||||
|
if (firstSource !== link) list.insertBefore(link, firstSource);
|
||||||
|
};
|
||||||
|
|
||||||
|
// From the newly-arrived rows: bump each source's unread count (adding a
|
||||||
|
// sidebar entry for a source seen for the first time) plus the total.
|
||||||
|
const registerNewSources = (rows) => {
|
||||||
|
const counts = {};
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const source = row.dataset.source;
|
||||||
|
if (source) counts[source] = (counts[source] || 0) + 1;
|
||||||
|
});
|
||||||
|
let total = 0;
|
||||||
|
Object.entries(counts).forEach(([source, n]) => {
|
||||||
|
total += n;
|
||||||
|
const existing = findSourceLink(source);
|
||||||
|
if (existing) {
|
||||||
|
bumpSourceBadge(existing, n);
|
||||||
|
moveSourceToTop(existing); // most-recent activity -> top (recency order)
|
||||||
|
} else {
|
||||||
|
addSourceLink(source, n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const allLink = findSourceLink("All sources");
|
||||||
|
if (allLink && total) bumpSourceBadge(allLink, total);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Delete / undelete / empty trash ---
|
||||||
|
const updateArtifactCount = (delta) => {
|
||||||
|
const header = document.querySelector(".artifact-sidebar .pane-heading span");
|
||||||
|
if (!header) return;
|
||||||
|
const total = Math.max(0, (parseInt(header.textContent, 10) || 0) + delta);
|
||||||
|
header.textContent = `${total} message${total === 1 ? "" : "s"}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bumpFolderCount = (folder, delta) => {
|
||||||
|
const link = document.querySelector(`[data-folder-link="${folder}"]`);
|
||||||
|
if (link) bumpSourceBadge(link, delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRowFromList = (row) => {
|
||||||
|
const group = row.closest(".day-group");
|
||||||
|
row.remove();
|
||||||
|
if (group) {
|
||||||
|
const remaining = group.querySelectorAll(".artifact-row").length;
|
||||||
|
const count = group.querySelector(".day-separator-count");
|
||||||
|
if (count) count.textContent = String(remaining);
|
||||||
|
if (remaining === 0) group.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const softDeleteRow = async (id) => {
|
||||||
|
const row = artifactList.querySelector(`.artifact-row[data-artifact-id="${id}"]`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/a/${id}/delete`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
||||||
|
if (!response.ok) return;
|
||||||
|
} catch { return; }
|
||||||
|
if (!row) return;
|
||||||
|
if (row.classList.contains("selected")) clearPreview();
|
||||||
|
if (row.classList.contains("unread")) decrementSourceCount(row.dataset.source);
|
||||||
|
removeRowFromList(row);
|
||||||
|
updateArtifactCount(-1);
|
||||||
|
bumpFolderCount("deleted", 1); // best-effort; the folder appears on next nav if it's the first delete
|
||||||
|
};
|
||||||
|
|
||||||
|
const undeleteRow = async (id) => {
|
||||||
|
const row = artifactList.querySelector(`.artifact-row[data-artifact-id="${id}"]`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/a/${id}/undelete`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
||||||
|
if (!response.ok) return;
|
||||||
|
} catch { return; }
|
||||||
|
if (!row) return;
|
||||||
|
removeRowFromList(row);
|
||||||
|
bumpFolderCount("deleted", -1);
|
||||||
|
// The restored item reappears in the feed on the next navigation (full render).
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyTrashButton = document.querySelector("[data-empty-trash]");
|
||||||
|
if (emptyTrashButton) {
|
||||||
|
emptyTrashButton.addEventListener("click", async () => {
|
||||||
|
if (!window.confirm("Permanently delete everything in Trash? This can't be undone.")) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch("{% url 'feed-empty-trash' %}", {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
||||||
|
if (!response.ok) return;
|
||||||
|
} catch { return; }
|
||||||
|
window.location.href = window.location.pathname; // trash empty; folder gone
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-row actions menu (⋯ trigger + right-click) ---
|
||||||
|
const rowMenu = document.querySelector("[data-row-menu]");
|
||||||
|
let menuRowId = null;
|
||||||
|
|
||||||
|
const currentMenuRow = () =>
|
||||||
|
menuRowId ? artifactList.querySelector(`.artifact-row[data-artifact-id="${menuRowId}"]`) : null;
|
||||||
|
|
||||||
|
const closeRowMenu = () => {
|
||||||
|
if (rowMenu) rowMenu.hidden = true;
|
||||||
|
menuRowId = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseRowTags = (row) =>
|
||||||
|
(row.dataset.tags || "").split(",").map((t) => t.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const refreshMenuLabelState = (row) => {
|
||||||
|
const applied = new Set(parseRowTags(row));
|
||||||
|
rowMenu.querySelectorAll(".row-menu-label").forEach((btn) => {
|
||||||
|
btn.classList.toggle("applied", applied.has(btn.dataset.labelName));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRowMenu = (row, x, y) => {
|
||||||
|
if (!rowMenu || !row) return;
|
||||||
|
menuRowId = row.dataset.artifactId;
|
||||||
|
const keepBtn = rowMenu.querySelector('[data-menu-action="keep"]');
|
||||||
|
if (keepBtn) keepBtn.textContent = row.dataset.kept === "1" ? "Remove from Keep" : "Keep";
|
||||||
|
refreshMenuLabelState(row);
|
||||||
|
rowMenu.hidden = false;
|
||||||
|
const rect = rowMenu.getBoundingClientRect();
|
||||||
|
const left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8));
|
||||||
|
const top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8));
|
||||||
|
rowMenu.style.left = `${left}px`;
|
||||||
|
rowMenu.style.top = `${top}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyText = async (text) => {
|
||||||
|
try { await navigator.clipboard.writeText(text); } catch (e) { /* clipboard unavailable */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const keepFromMenu = async (row, id) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/a/${id}/keep`, {method: "POST", headers: {"X-CSRFToken": csrfToken}});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = await response.json();
|
||||||
|
row.dataset.kept = data.is_kept ? "1" : "0";
|
||||||
|
row.classList.toggle("kept", data.is_kept);
|
||||||
|
const badge = row.querySelector("[data-kept-badge]");
|
||||||
|
if (badge) badge.hidden = !data.is_kept;
|
||||||
|
bumpFolderCount("keep", data.is_kept ? 1 : -1);
|
||||||
|
if (feed.dataset.folder === "keep" && !data.is_kept) removeRowFromList(row);
|
||||||
|
const keepButton = document.querySelector("[data-keep-button]");
|
||||||
|
if (keepButton && keepButton.dataset.artifactId === id) {
|
||||||
|
keepButton.classList.toggle("kept", data.is_kept);
|
||||||
|
keepButton.textContent = data.is_kept ? "✓ Kept" : "Keep";
|
||||||
|
}
|
||||||
|
} catch (e) { /* leave as-is */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRowChip = (row, name, color) => {
|
||||||
|
const main = row.querySelector(".artifact-row-main");
|
||||||
|
if (!main) return;
|
||||||
|
let chips = main.querySelector(".tag-chips");
|
||||||
|
if (!chips) {
|
||||||
|
chips = document.createElement("span");
|
||||||
|
chips.className = "tag-chips";
|
||||||
|
main.appendChild(chips);
|
||||||
|
}
|
||||||
|
if ([...chips.querySelectorAll(".tag-chip")].some((c) => c.textContent === name)) return;
|
||||||
|
const chip = document.createElement("span");
|
||||||
|
chip.className = "tag-chip";
|
||||||
|
chip.style.background = color;
|
||||||
|
chip.textContent = name;
|
||||||
|
chips.appendChild(chip);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRowChip = (row, name) => {
|
||||||
|
const chips = row.querySelector(".tag-chips");
|
||||||
|
if (!chips) return;
|
||||||
|
chips.querySelectorAll(".tag-chip").forEach((c) => { if (c.textContent === name) c.remove(); });
|
||||||
|
if (!chips.querySelector(".tag-chip")) chips.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle a palette label on the current menu row. Leaves the menu open so
|
||||||
|
// several labels can be applied in one go.
|
||||||
|
const toggleLabelFromMenu = async (btn) => {
|
||||||
|
const row = currentMenuRow();
|
||||||
|
const id = menuRowId;
|
||||||
|
if (!row || !id) return;
|
||||||
|
const name = btn.dataset.labelName;
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set("name", name);
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/a/${id}/label`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"X-CSRFToken": csrfToken, "Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
data = await response.json();
|
||||||
|
} catch (e) { return; }
|
||||||
|
row.dataset.tags = data.tags.join(", ");
|
||||||
|
btn.classList.toggle("applied", data.applied);
|
||||||
|
if (data.applied) addRowChip(row, data.name, data.color);
|
||||||
|
else removeRowChip(row, data.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rowMenu) {
|
||||||
|
rowMenu.addEventListener("click", (event) => {
|
||||||
|
const labelBtn = event.target.closest(".row-menu-label");
|
||||||
|
if (labelBtn) {
|
||||||
|
toggleLabelFromMenu(labelBtn);
|
||||||
|
return; // keep the menu open for applying multiple labels
|
||||||
|
}
|
||||||
|
const btn = event.target.closest("[data-menu-action]");
|
||||||
|
if (!btn) return;
|
||||||
|
const row = currentMenuRow();
|
||||||
|
const id = menuRowId;
|
||||||
|
closeRowMenu();
|
||||||
|
if (!row || !id) return;
|
||||||
|
const action = btn.dataset.menuAction;
|
||||||
|
if (action === "delete") softDeleteRow(id);
|
||||||
|
else if (action === "keep") keepFromMenu(row, id);
|
||||||
|
else if (action === "open") window.open(row.dataset.renderedUrl, "_blank", "noopener");
|
||||||
|
else if (action === "copy") copyText(window.location.origin + row.dataset.renderedUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
if (rowMenu && !rowMenu.hidden && !rowMenu.contains(event.target)) closeRowMenu();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") closeRowMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Right-click a feed row opens the same menu at the cursor.
|
||||||
|
artifactList.addEventListener("contextmenu", (event) => {
|
||||||
|
const row = event.target.closest(".artifact-row");
|
||||||
|
if (!row || feed.dataset.folder) return; // normal feed only
|
||||||
|
event.preventDefault();
|
||||||
|
openRowMenu(row, event.clientX, event.clientY);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click a source's unread badge to mark all of that source's messages read.
|
||||||
|
const markSourceRead = async (badge) => {
|
||||||
|
const link = badge.closest(".source-link");
|
||||||
|
if (!link) return;
|
||||||
|
const isAll = link.classList.contains("all-sources-link");
|
||||||
|
const source = link.dataset.source || "";
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
if (!isAll && source) body.set("source", source);
|
||||||
|
try {
|
||||||
|
const response = await fetch("{% url 'feed-mark-read' %}", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"X-CSRFToken": csrfToken, "Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
} catch (e) { return; }
|
||||||
|
if (isAll) {
|
||||||
|
document.querySelectorAll(".source-list .source-count").forEach((b) => b.remove());
|
||||||
|
artifactList.querySelectorAll(".artifact-row.unread").forEach((r) => r.classList.remove("unread"));
|
||||||
|
} else {
|
||||||
|
const n = parseInt(badge.textContent, 10) || 0;
|
||||||
|
badge.remove();
|
||||||
|
const allBadge = document.querySelector(".all-sources-link .source-count");
|
||||||
|
if (allBadge) {
|
||||||
|
const t = (parseInt(allBadge.textContent, 10) || 0) - n;
|
||||||
|
if (t > 0) allBadge.textContent = String(t);
|
||||||
|
else allBadge.remove();
|
||||||
|
}
|
||||||
|
artifactList
|
||||||
|
.querySelectorAll(`.artifact-row.unread[data-source="${CSS.escape(source)}"]`)
|
||||||
|
.forEach((r) => r.classList.remove("unread"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceListEl = document.querySelector(".source-list");
|
||||||
|
if (sourceListEl) {
|
||||||
|
const badgeFromEvent = (event) => event.target.closest("[data-mark-read]");
|
||||||
|
sourceListEl.addEventListener("click", (event) => {
|
||||||
|
const badge = badgeFromEvent(event);
|
||||||
|
if (!badge) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
markSourceRead(badge);
|
||||||
|
});
|
||||||
|
sourceListEl.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
const badge = badgeFromEvent(event);
|
||||||
|
if (!badge) return;
|
||||||
|
event.preventDefault();
|
||||||
|
markSourceRead(badge);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const renderPreview = (row, pushState = true) => {
|
const renderPreview = (row, pushState = true) => {
|
||||||
navigationMode = "artifacts";
|
navigationMode = "artifacts";
|
||||||
markRead(row);
|
markRead(row);
|
||||||
|
|
@ -395,7 +760,7 @@
|
||||||
: kind === "image"
|
: kind === "image"
|
||||||
? `<img class="preview-image" src="${source}" alt="${title}">`
|
? `<img class="preview-image" src="${source}" alt="${title}">`
|
||||||
: kind === "binary"
|
: kind === "binary"
|
||||||
? `<div class="empty preview-empty"><h2>Binary Artifact</h2><p>This artifact type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
|
? `<div class="empty preview-empty"><h2>Binary Message</h2><p>This message type cannot be previewed inline yet.</p><p><a href="${source}">Open or download it</a>.</p></div>`
|
||||||
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
|
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
|
||||||
|
|
||||||
previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`;
|
previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`;
|
||||||
|
|
@ -410,7 +775,7 @@
|
||||||
}
|
}
|
||||||
allRows().forEach((candidate) => candidate.classList.remove("selected"));
|
allRows().forEach((candidate) => candidate.classList.remove("selected"));
|
||||||
disableActions();
|
disableActions();
|
||||||
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No artifact loaded</h2><p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p></div>`;
|
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No message loaded</h2><p>Press <kbd>Right Arrow</kbd> or select a message from the middle column to preview it.</p></div>`;
|
||||||
window.history.pushState({}, "", noPreviewUrl());
|
window.history.pushState({}, "", noPreviewUrl());
|
||||||
const currentSource = document.querySelector(".source-link.selected");
|
const currentSource = document.querySelector(".source-link.selected");
|
||||||
if (currentSource) currentSource.focus({preventScroll: true});
|
if (currentSource) currentSource.focus({preventScroll: true});
|
||||||
|
|
@ -418,6 +783,21 @@
|
||||||
|
|
||||||
// Delegated so rows appended by infinite scroll work without rebinding.
|
// Delegated so rows appended by infinite scroll work without rebinding.
|
||||||
artifactList.addEventListener("click", (event) => {
|
artifactList.addEventListener("click", (event) => {
|
||||||
|
// Row action icons (delete/undelete/menu) intercept before selection.
|
||||||
|
const action = event.target.closest("[data-delete], [data-undelete], [data-menu-trigger]");
|
||||||
|
if (action) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (action.hasAttribute("data-menu-trigger")) {
|
||||||
|
const rect = action.getBoundingClientRect();
|
||||||
|
openRowMenu(action.closest(".artifact-row"), rect.right, rect.bottom);
|
||||||
|
} else if (action.hasAttribute("data-delete")) {
|
||||||
|
softDeleteRow(action.getAttribute("data-delete"));
|
||||||
|
} else {
|
||||||
|
undeleteRow(action.getAttribute("data-undelete"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const row = event.target.closest(".artifact-row");
|
const row = event.target.closest(".artifact-row");
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -438,12 +818,13 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveSource = (direction) => {
|
const moveSource = (direction) => {
|
||||||
if (!sourceLinks.length) return;
|
const links = sourceLinks();
|
||||||
|
if (!links.length) return;
|
||||||
navigationMode = "sources";
|
navigationMode = "sources";
|
||||||
const current = document.querySelector(".source-link.selected");
|
const current = document.querySelector(".source-link.selected");
|
||||||
const currentIndex = current ? sourceLinks.indexOf(current) : 0;
|
const currentIndex = current ? links.indexOf(current) : 0;
|
||||||
const nextIndex = Math.max(0, Math.min(sourceLinks.length - 1, currentIndex + direction));
|
const nextIndex = Math.max(0, Math.min(links.length - 1, currentIndex + direction));
|
||||||
const nextSource = sourceLinks[nextIndex];
|
const nextSource = links[nextIndex];
|
||||||
if (nextSource && nextSource !== current) window.location.href = nextSource.href;
|
if (nextSource && nextSource !== current) window.location.href = nextSource.href;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -552,6 +933,98 @@
|
||||||
|
|
||||||
observeSentinel();
|
observeSentinel();
|
||||||
|
|
||||||
|
// --- Live updates: poll for new artifacts and prepend them at the top. ---
|
||||||
|
// Cheap: a small "anything since X?" fetch every few seconds, paused while
|
||||||
|
// the tab is hidden. New rows are inserted without touching the selected
|
||||||
|
// row or the preview, so what you're reading stays put. Respects the
|
||||||
|
// active source/label/kind/q filters (passed through below).
|
||||||
|
const LIVE_POLL_MS = 5000;
|
||||||
|
const NOW_TTL_MS = 3 * 60 * 1000; // show "now", then age into the real time
|
||||||
|
let liveSince = artifactList.dataset.since || null;
|
||||||
|
let livePolling = false;
|
||||||
|
|
||||||
|
const buildUpdatesUrl = () => {
|
||||||
|
const url = new URL("{% url 'feed-updates' %}", window.location.origin);
|
||||||
|
const p = url.searchParams;
|
||||||
|
if (liveSince) p.set("after", liveSince);
|
||||||
|
p.set("view", feed.dataset.selectedView || "rendered");
|
||||||
|
if (feed.dataset.selectedSource) p.set("source", feed.dataset.selectedSource);
|
||||||
|
if (feed.dataset.selectedLabel) p.set("label", feed.dataset.selectedLabel);
|
||||||
|
if (feed.dataset.selectedKind) p.set("kind", feed.dataset.selectedKind);
|
||||||
|
if (feed.dataset.titleQuery) p.set("q", feed.dataset.titleQuery);
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureTodayGroup = (todayLabel) => {
|
||||||
|
let group = artifactList.querySelector(".day-group[data-today]");
|
||||||
|
if (group) return group;
|
||||||
|
const empty = artifactList.querySelector(".empty");
|
||||||
|
if (empty) empty.remove();
|
||||||
|
group = document.createElement("div");
|
||||||
|
group.className = "day-group";
|
||||||
|
group.setAttribute("data-today", "1");
|
||||||
|
group.innerHTML =
|
||||||
|
`<div class="day-separator"><span>${escapeHtml(todayLabel)} <em class="today-badge">Today</em></span>` +
|
||||||
|
`<span class="day-separator-count">0</span></div>`;
|
||||||
|
artifactList.insertBefore(group, artifactList.firstChild);
|
||||||
|
return group;
|
||||||
|
};
|
||||||
|
|
||||||
|
const markArrived = (rows) => {
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const timeEl = row.querySelector(".artifact-time");
|
||||||
|
if (timeEl) {
|
||||||
|
timeEl.dataset.realtime = timeEl.textContent;
|
||||||
|
timeEl.textContent = "now";
|
||||||
|
setTimeout(() => {
|
||||||
|
if (timeEl.dataset.realtime) timeEl.textContent = timeEl.dataset.realtime;
|
||||||
|
}, NOW_TTL_MS);
|
||||||
|
}
|
||||||
|
row.classList.add("just-arrived");
|
||||||
|
setTimeout(() => row.classList.remove("just-arrived"), 1500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollUpdates = async () => {
|
||||||
|
// Don't poll while viewing a system folder (Deleted/Keep) — new live
|
||||||
|
// posts don't belong in those views.
|
||||||
|
if (livePolling || document.hidden || !liveSince || feed.dataset.folder) return;
|
||||||
|
livePolling = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(buildUpdatesUrl(), {headers: {"X-Requested-With": "fetch"}});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.count) return;
|
||||||
|
const group = ensureTodayGroup(data.today_label);
|
||||||
|
const separator = group.querySelector(".day-separator");
|
||||||
|
// Newest-first batch goes immediately below the separator, above prior rows.
|
||||||
|
separator.insertAdjacentHTML("afterend", data.html);
|
||||||
|
const inserted = [];
|
||||||
|
let node = separator.nextElementSibling;
|
||||||
|
while (node && inserted.length < data.count) {
|
||||||
|
if (node.classList.contains("artifact-row")) inserted.push(node);
|
||||||
|
node = node.nextElementSibling;
|
||||||
|
}
|
||||||
|
markArrived(inserted);
|
||||||
|
registerNewSources(inserted); // update sidebar: counts + any new source
|
||||||
|
liveSince = data.newest;
|
||||||
|
const dayCount = group.querySelector(".day-separator-count");
|
||||||
|
if (dayCount) dayCount.textContent = String((parseInt(dayCount.textContent, 10) || 0) + data.count);
|
||||||
|
const header = document.querySelector(".artifact-sidebar .pane-heading span");
|
||||||
|
if (header) {
|
||||||
|
const total = (parseInt(header.textContent, 10) || 0) + data.count;
|
||||||
|
header.textContent = `${total} message${total === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore; retry next tick
|
||||||
|
} finally {
|
||||||
|
livePolling = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setInterval(pollUpdates, LIVE_POLL_MS);
|
||||||
|
document.addEventListener("visibilitychange", () => { if (!document.hidden) pollUpdates(); });
|
||||||
|
|
||||||
// Populate the details panel for a server-preselected artifact so the first
|
// Populate the details panel for a server-preselected artifact so the first
|
||||||
// "Details" click shows content immediately.
|
// "Details" click shows content immediately.
|
||||||
const preselected = selectedRow();
|
const preselected = selectedRow();
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
<title>Log in — AgentFeed</title>
|
<title>Log in — AgentFeed</title>
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" href="/static/daybook.css">
|
<link rel="stylesheet" href="/static/agentfeed.css">
|
||||||
<script>
|
<script>
|
||||||
// Set the theme before first paint so dark-mode users never see a light flash.
|
// Set the theme before first paint so dark-mode users never see a light flash.
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
if (localStorage.getItem("daybook.theme") === "dark") {
|
if (localStorage.getItem("agentfeed.theme") === "dark") {
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,17 @@
|
||||||
<div class="about-head">
|
<div class="about-head">
|
||||||
<img class="about-logo" src="/static/logo.png" alt="">
|
<img class="about-logo" src="/static/logo.png" alt="">
|
||||||
<div>
|
<div>
|
||||||
<h2>Daybook</h2>
|
<h2>AgentFeed</h2>
|
||||||
<p class="about-version">Version {{ version }}</p>
|
<p class="about-version">Version {{ version }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>A self-hosted, multi-user journal where AI agents, scripts, or people
|
<p>A self-hosted, multi-user journal where AI agents, scripts, or people
|
||||||
publish artifacts — reports, HTML, decks, images, PDFs, JSON, CSV — and each
|
publish messages — reports, HTML, decks, images, PDFs, JSON, CSV — and each
|
||||||
logged-in user browses their own time-ordered, label-filterable feed. The
|
logged-in user browses their own time-ordered, label-filterable feed. The
|
||||||
server owns IDs, styling, and storage; agents publish content, not files.</p>
|
server owns IDs, styling, and storage; agents publish content, not files.</p>
|
||||||
|
|
||||||
<p class="storage-note">Post to it with a personal API token from
|
<p class="storage-note">Post to it with a personal API key from
|
||||||
Settings → Account; the current API capabilities (including this
|
Settings → API Keys; the current API capabilities (including
|
||||||
version) are available at <code>/api/capabilities</code>.</p>
|
this version) are available at <code>/api/capabilities</code>.</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,31 @@
|
||||||
|
|
||||||
<h3>API Keys</h3>
|
<h3>API Keys</h3>
|
||||||
<p>
|
<p>
|
||||||
Posting to Daybook uses named API keys — one per agent or script, each
|
Posting to AgentFeed uses named API keys — one per agent or script, each
|
||||||
revocable on its own. You have {{ key_count }} key{{ key_count|pluralize }}.
|
revocable on its own. You have {{ key_count }} key{{ key_count|pluralize }}.
|
||||||
</p>
|
</p>
|
||||||
<p><a href="{% url 'settings-api-keys' %}">Manage API keys →</a></p>
|
<p><a href="{% url 'settings-api-keys' %}">Manage API keys →</a></p>
|
||||||
|
|
||||||
|
<h3>Change password</h3>
|
||||||
|
<p>Enter your current password, then choose a new one.</p>
|
||||||
|
<form method="post" class="password-change">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if password_form.non_field_errors %}
|
||||||
|
<ul class="form-errors">
|
||||||
|
{% for error in password_form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% for field in password_form %}
|
||||||
|
<label class="form-field">
|
||||||
|
<span>{{ field.label }}</span>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.errors %}
|
||||||
|
<ul class="form-errors">
|
||||||
|
{% for error in field.errors %}<li>{{ error }}</li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit">Change password</button>
|
||||||
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<h2>API Keys</h2>
|
<h2>API Keys</h2>
|
||||||
<p>
|
<p>
|
||||||
One key per agent or script. Name each key so you know which agent it's
|
One key per agent or script. Name each key so you know which agent it's
|
||||||
for, copy it into that agent's <code>DAYBOOK_TOKEN</code>, and revoke keys
|
for, copy it into that agent's <code>AGENTFEED_TOKEN</code>, and revoke keys
|
||||||
individually here. Use a key as
|
individually here. Use a key as
|
||||||
<code>Authorization: Bearer <key></code> when posting.
|
<code>Authorization: Bearer <key></code> when posting.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
<title>Settings — AgentFeed</title>
|
<title>Settings — AgentFeed</title>
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" href="/static/daybook.css">
|
<link rel="stylesheet" href="/static/agentfeed.css">
|
||||||
<script>
|
<script>
|
||||||
// Set the theme before first paint so dark-mode users never see a light flash.
|
// Set the theme before first paint so dark-mode users never see a light flash.
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
if (localStorage.getItem("daybook.theme") === "dark") {
|
if (localStorage.getItem("agentfeed.theme") === "dark") {
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
@ -24,14 +24,26 @@
|
||||||
<main class="settings-layout">
|
<main class="settings-layout">
|
||||||
<nav class="settings-nav">
|
<nav class="settings-nav">
|
||||||
<a class="settings-nav-back" href="{% url 'feed' %}">← Back to Feed</a>
|
<a class="settings-nav-back" href="{% url 'feed' %}">← Back to Feed</a>
|
||||||
<a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</a>
|
|
||||||
<a class="{% if active_section == 'api-keys' %}selected{% endif %}" href="{% url 'settings-api-keys' %}">API Keys</a>
|
<div class="settings-nav-group">
|
||||||
<a class="{% if active_section == 'storage' %}selected{% endif %}" href="{% url 'settings-storage' %}">Storage</a>
|
<span class="settings-nav-heading">You</span>
|
||||||
<a class="{% if active_section == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a>
|
<a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</a>
|
||||||
|
<a class="{% if active_section == 'api-keys' %}selected{% endif %}" href="{% url 'settings-api-keys' %}">API Keys</a>
|
||||||
|
<a class="{% if active_section == 'labels' %}selected{% endif %}" href="{% url 'settings-labels' %}">Labels</a>
|
||||||
|
<a class="{% if active_section == 'storage' %}selected{% endif %}" href="{% url 'settings-storage' %}">Storage</a>
|
||||||
|
<a class="{% if active_section == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if request.user.is_superuser %}
|
{% if request.user.is_superuser %}
|
||||||
<a class="{% if active_section == 'users' %}selected{% endif %}" href="{% url 'settings-users' %}">Users</a>
|
<div class="settings-nav-group">
|
||||||
|
<span class="settings-nav-heading">Admin</span>
|
||||||
|
<a class="{% if active_section == 'users' %}selected{% endif %}" href="{% url 'settings-users' %}">Users</a>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="{% if active_section == 'about' %}selected{% endif %}" href="{% url 'settings-about' %}">About</a>
|
|
||||||
|
<div class="settings-nav-group">
|
||||||
|
<a class="{% if active_section == 'about' %}selected{% endif %}" href="{% url 'settings-about' %}">About</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<section class="settings-panel">
|
<section class="settings-panel">
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
{% block settings_content %}
|
{% block settings_content %}
|
||||||
<h2>Demo Data</h2>
|
<h2>Demo Data</h2>
|
||||||
<p>Generate one sample artifact of every supported kind (markdown, HTML fragment, full HTML, deck, image, PDF, binary), tagged "demo" and dated today.</p>
|
<p>Generate one sample message of every supported kind (markdown, HTML fragment, full HTML, deck, image, PDF, binary), tagged "demo" and dated today.</p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit">Generate Demo Data</button>
|
<button type="submit">Generate Demo Data</button>
|
||||||
|
|
|
||||||
54
templates/artifacts/settings_labels.html
Normal file
54
templates/artifacts/settings_labels.html
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
{% extends "artifacts/settings_base.html" %}
|
||||||
|
|
||||||
|
{% block settings_content %}
|
||||||
|
<h2>Labels</h2>
|
||||||
|
<p>
|
||||||
|
Build a palette of labels you can apply to any message from its ⋯ menu
|
||||||
|
in the feed. A label is just a coloured tag — applying one adds it to the
|
||||||
|
message and it shows as a chip next to the title. Deleting a label here only
|
||||||
|
removes it from this palette; messages that already carry it keep the chip.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Add a label</h3>
|
||||||
|
<form method="post" class="label-create">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="create">
|
||||||
|
<label>Name <input type="text" name="name" placeholder="e.g. follow-up" required autocomplete="off" maxlength="40"></label>
|
||||||
|
<label>Colour <input type="color" name="color" value="#4a6ea8"></label>
|
||||||
|
<button type="submit">Add label</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3>Your labels</h3>
|
||||||
|
{% if labels %}
|
||||||
|
<table class="label-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Label</th><th>Colour</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for label in labels %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" class="label-recolor">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="update">
|
||||||
|
<input type="hidden" name="label_id" value="{{ label.pk }}">
|
||||||
|
<input type="color" name="color" value="{{ label.color }}" onchange="this.form.submit()" title="Change colour">
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" onsubmit="return confirm('Remove "{{ label.name|escapejs }}" from your palette? Messages already labelled keep the chip.');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="label_id" value="{{ label.pk }}">
|
||||||
|
<button type="submit" class="label-delete">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No labels yet. Add one above, then pick it from a message's ⋯ menu.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -11,14 +11,14 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul class="storage-facts">
|
<ul class="storage-facts">
|
||||||
<li>{{ artifact_count }} artifact{{ artifact_count|pluralize }} total</li>
|
<li>{{ artifact_count }} message{{ artifact_count|pluralize }} total</li>
|
||||||
<li>{{ kept_count }} kept ({{ kept_bytes|filesizeformat }}) — never auto-deleted</li>
|
<li>{{ kept_count }} kept ({{ kept_bytes|filesizeformat }}) — never auto-deleted</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p class="storage-note">
|
<p class="storage-note">
|
||||||
When your quota fills, the oldest <strong>unkept</strong> artifacts are deleted
|
When your quota fills, the oldest <strong>unkept</strong> messages are deleted
|
||||||
automatically to make room for new ones. Click <strong>Keep</strong> on an artifact
|
automatically to make room for new ones. Click <strong>Keep</strong> on a message
|
||||||
to protect it. If the quota is full of kept artifacts, new posts are rejected until
|
to protect it. If the quota is full of kept messages, new posts are rejected until
|
||||||
you free space.
|
you free space.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
<h3>Admin</h3>
|
<h3>Admin</h3>
|
||||||
<ul class="storage-facts">
|
<ul class="storage-facts">
|
||||||
<li>Total quota allocated across all users: <strong>{{ total_allocated|filesizeformat }}</strong></li>
|
<li>Total quota allocated across all users: <strong>{{ total_allocated|filesizeformat }}</strong></li>
|
||||||
<li>Free space on the artifact disk: <strong>{{ disk_free|filesizeformat }}</strong></li>
|
<li>Free space on the storage disk: <strong>{{ disk_free|filesizeformat }}</strong></li>
|
||||||
</ul>
|
</ul>
|
||||||
{% if total_allocated > disk_free %}
|
{% if total_allocated > disk_free %}
|
||||||
<p class="storage-warning">
|
<p class="storage-warning">
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from artifacts.models import ApiKey
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
@override_settings(DAYBOOK_KEY_PROVISIONING_API=True)
|
@override_settings(AGENTFEED_KEY_PROVISIONING_API=True)
|
||||||
class ApiKeyProvisioningEnabledTestCase(APITestCase):
|
class ApiKeyProvisioningEnabledTestCase(APITestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="alice", password="pw")
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
|
|
@ -78,7 +78,7 @@ class ApiKeyProvisioningEnabledTestCase(APITestCase):
|
||||||
self.assertEqual(response.data["methods"]["/api/keys"], ["GET", "POST"])
|
self.assertEqual(response.data["methods"]["/api/keys"], ["GET", "POST"])
|
||||||
|
|
||||||
|
|
||||||
@override_settings(DAYBOOK_KEY_PROVISIONING_API=False)
|
@override_settings(AGENTFEED_KEY_PROVISIONING_API=False)
|
||||||
class ApiKeyProvisioningDisabledTestCase(APITestCase):
|
class ApiKeyProvisioningDisabledTestCase(APITestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="alice", password="pw")
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from artifacts.models import ApiKey, Artifact
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(AGENTFEED_ASYNC_PURGE=False) # DELETE triggers purge; run it inline in tests
|
||||||
class ArtifactAPITestCase(APITestCase):
|
class ArtifactAPITestCase(APITestCase):
|
||||||
"""Exercises the ingest/list/detail API against a real (temp) filesystem
|
"""Exercises the ingest/list/detail API against a real (temp) filesystem
|
||||||
and a real (test) database, the way an agent posting over HTTP would."""
|
and a real (test) database, the way an agent posting over HTTP would."""
|
||||||
|
|
@ -24,7 +25,7 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
super().setUpClass()
|
super().setUpClass()
|
||||||
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-artifacts-"))
|
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-artifacts-"))
|
||||||
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
||||||
cls.settings_override.enable()
|
cls.settings_override.enable()
|
||||||
|
|
||||||
|
|
@ -58,9 +59,10 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
|
|
||||||
# -- capabilities -------------------------------------------------
|
# -- capabilities -------------------------------------------------
|
||||||
|
|
||||||
def test_capabilities_requires_auth(self):
|
def test_capabilities_is_public(self):
|
||||||
|
# Capability/contract info only, no user data — public, no auth needed.
|
||||||
response = self.client.get(reverse("capabilities-api"))
|
response = self.client.get(reverse("capabilities-api"))
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_capabilities_with_token(self):
|
def test_capabilities_with_token(self):
|
||||||
response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token))
|
response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token))
|
||||||
|
|
@ -85,9 +87,10 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
|
|
||||||
# -- skill ----------------------------------------------------------
|
# -- skill ----------------------------------------------------------
|
||||||
|
|
||||||
def test_skill_requires_auth(self):
|
def test_skill_is_public(self):
|
||||||
|
# Bootstrapping instructions, no secrets — public, no auth needed.
|
||||||
response = self.client.get(reverse("skill-api"))
|
response = self.client.get(reverse("skill-api"))
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_skill_returns_markdown_with_origin_filled_in(self):
|
def test_skill_returns_markdown_with_origin_filled_in(self):
|
||||||
response = self.client.get(reverse("skill-api"), **self.auth_headers(self.token))
|
response = self.client.get(reverse("skill-api"), **self.auth_headers(self.token))
|
||||||
|
|
@ -96,9 +99,9 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
body = response.content.decode()
|
body = response.content.decode()
|
||||||
# Frontmatter is intact and the config placeholder is rewritten to this
|
# Frontmatter is intact and the config placeholder is rewritten to this
|
||||||
# instance's origin (http://testserver under the test client).
|
# instance's origin (http://testserver under the test client).
|
||||||
self.assertIn("name: daybook-posting", body)
|
self.assertIn("name: agentfeed-posting", body)
|
||||||
self.assertIn("DAYBOOK_URL=http://testserver", body)
|
self.assertIn("AGENTFEED_URL=http://testserver", body)
|
||||||
self.assertNotIn("your-daybook-instance.example.com", body)
|
self.assertNotIn("your-agentfeed-instance.example.com", body)
|
||||||
|
|
||||||
def test_skill_json_format(self):
|
def test_skill_json_format(self):
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
|
|
@ -107,7 +110,7 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data["version"], __version__)
|
self.assertEqual(response.data["version"], __version__)
|
||||||
self.assertEqual(response.data["url"], "http://testserver")
|
self.assertEqual(response.data["url"], "http://testserver")
|
||||||
self.assertIn("name: daybook-posting", response.data["skill"])
|
self.assertIn("name: agentfeed-posting", response.data["skill"])
|
||||||
|
|
||||||
def test_skill_with_session(self):
|
def test_skill_with_session(self):
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
@ -156,9 +159,10 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
|
|
||||||
# -- list (GET) -------------------------------------------------------
|
# -- list (GET) -------------------------------------------------------
|
||||||
|
|
||||||
def test_list_requires_session_login(self):
|
def test_list_requires_auth(self):
|
||||||
|
# No session and no token -> JSON 403, never a browser-login redirect.
|
||||||
response = self.client.get(reverse("artifact-api"))
|
response = self.client.get(reverse("artifact-api"))
|
||||||
self.assertEqual(response.status_code, 302) # redirected to login
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
def test_list_scoped_to_owner(self):
|
def test_list_scoped_to_owner(self):
|
||||||
self.create_artifact()
|
self.create_artifact()
|
||||||
|
|
@ -169,6 +173,15 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual([a["title"] for a in response.data], ["Hello World"])
|
self.assertEqual([a["title"] for a in response.data], ["Hello World"])
|
||||||
|
|
||||||
|
def test_list_readable_with_bearer_token(self):
|
||||||
|
# Agents read back with the same token they post with; owner-scoped.
|
||||||
|
self.create_artifact()
|
||||||
|
self.create_artifact(token=self.other_token, title="Not mine")
|
||||||
|
|
||||||
|
response = self.client.get(reverse("artifact-api"), **self.auth_headers(self.token))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual([a["title"] for a in response.data], ["Hello World"])
|
||||||
|
|
||||||
def test_list_filter_by_label(self):
|
def test_list_filter_by_label(self):
|
||||||
self.create_artifact(tags=["urgent"])
|
self.create_artifact(tags=["urgent"])
|
||||||
self.create_artifact(title="Second", tags=["later"])
|
self.create_artifact(title="Second", tags=["later"])
|
||||||
|
|
@ -220,6 +233,67 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
|
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_detail_readable_with_bearer_token(self):
|
||||||
|
# The api_url returned by POST is fetchable with the posting token.
|
||||||
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["id"], artifact_id)
|
||||||
|
|
||||||
|
# ...but only the owner's token; another user's token gets 404.
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.other_token)
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_detail_requires_auth(self):
|
||||||
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
# -- raw content (GET {id}/raw) ---------------------------------------
|
||||||
|
|
||||||
|
def _raw_body(self, response):
|
||||||
|
return b"".join(response.streaming_content).decode()
|
||||||
|
|
||||||
|
def test_raw_returns_exact_content_with_bearer(self):
|
||||||
|
create = self.create_artifact(content="# Exact\n\nverify me")
|
||||||
|
artifact_id = create.data["id"]
|
||||||
|
# metadata detail advertises the raw endpoint
|
||||||
|
self.assertTrue(create.data["api_raw_url"].endswith(f"/api/artifacts/{artifact_id}/raw"))
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
|
||||||
|
# Content round-trips faithfully; normalize newlines since the stored
|
||||||
|
# sidecar is written in the host's text mode (CRLF on Windows dev, LF on
|
||||||
|
# the Linux deploy).
|
||||||
|
self.assertEqual(self._raw_body(response).replace("\r\n", "\n"), "# Exact\n\nverify me")
|
||||||
|
|
||||||
|
def test_raw_requires_auth(self):
|
||||||
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
response = self.client.get(reverse("artifact-raw-api", args=[artifact_id]))
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_raw_owner_scoped(self):
|
||||||
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.other_token)
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_raw_not_available_for_render_only_kind(self):
|
||||||
|
artifact_id = self.create_artifact(kind="html_fragment", content="<p>hi</p>").data["id"]
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("artifact-raw-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
def test_patch_updates_title_and_tags(self):
|
def test_patch_updates_title_and_tags(self):
|
||||||
artifact_id = self.create_artifact().data["id"]
|
artifact_id = self.create_artifact().data["id"]
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
|
|
@ -261,7 +335,8 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
|
|
||||||
# -- delete (DELETE) --------------------------------------------------
|
# -- delete (DELETE) --------------------------------------------------
|
||||||
|
|
||||||
def test_delete_removes_artifact_and_files(self):
|
def test_delete_soft_deletes_to_trash(self):
|
||||||
|
# DELETE moves the artifact to Trash (recoverable), not a hard removal.
|
||||||
artifact_id = self.create_artifact().data["id"]
|
artifact_id = self.create_artifact().data["id"]
|
||||||
artifact = Artifact.objects.get(pk=artifact_id)
|
artifact = Artifact.objects.get(pk=artifact_id)
|
||||||
stored_path = self.artifacts_root / artifact.path
|
stored_path = self.artifacts_root / artifact.path
|
||||||
|
|
@ -271,9 +346,12 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 204)
|
self.assertEqual(response.status_code, 204)
|
||||||
self.assertFalse(Artifact.objects.filter(pk=artifact_id).exists())
|
# Row still exists, marked deleted; files kept on disk until purge.
|
||||||
self.assertFalse(stored_path.exists())
|
artifact.refresh_from_db()
|
||||||
self.assertFalse(stored_path.with_suffix(".md").exists())
|
self.assertIsNotNone(artifact.deleted_at)
|
||||||
|
self.assertTrue(stored_path.exists())
|
||||||
|
# But it's gone from the API's list/detail (hidden like the feed).
|
||||||
|
self.assertEqual(self.client.get(reverse("artifact-api"), **self.auth_headers(self.token)).data, [])
|
||||||
|
|
||||||
def test_delete_requires_token(self):
|
def test_delete_requires_token(self):
|
||||||
artifact_id = self.create_artifact().data["id"]
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
|
@ -289,7 +367,8 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists())
|
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists())
|
||||||
|
|
||||||
def test_delete_twice_second_call_404s(self):
|
def test_delete_is_idempotent(self):
|
||||||
|
# Soft-delete is idempotent: deleting an already-trashed artifact is a no-op 204.
|
||||||
artifact_id = self.create_artifact().data["id"]
|
artifact_id = self.create_artifact().data["id"]
|
||||||
first = self.client.delete(
|
first = self.client.delete(
|
||||||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||||
|
|
@ -299,7 +378,7 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
second = self.client.delete(
|
second = self.client.delete(
|
||||||
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||||
)
|
)
|
||||||
self.assertEqual(second.status_code, 404)
|
self.assertEqual(second.status_code, 204)
|
||||||
|
|
||||||
def test_deleted_artifact_is_gone_from_list_and_detail(self):
|
def test_deleted_artifact_is_gone_from_list_and_detail(self):
|
||||||
artifact_id = self.create_artifact().data["id"]
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
|
@ -442,7 +521,7 @@ class ArtifactAPITestCase(APITestCase):
|
||||||
self.assertEqual(stored_html.count("</script>"), 3)
|
self.assertEqual(stored_html.count("</script>"), 3)
|
||||||
|
|
||||||
# The escaped content must still round-trip to the exact original value.
|
# The escaped content must still round-trip to the exact original value.
|
||||||
start = stored_html.index('id="daybook-data">') + len('id="daybook-data">')
|
start = stored_html.index('id="agentfeed-data">') + len('id="agentfeed-data">')
|
||||||
end = stored_html.index("</script>", start)
|
end = stored_html.index("</script>", start)
|
||||||
embedded = stored_html[start:end]
|
embedded = stored_html[start:end]
|
||||||
self.assertEqual(json.loads(embedded)["value"], "</script><script>alert(1)</script>")
|
self.assertEqual(json.loads(embedded)["value"], "</script><script>alert(1)</script>")
|
||||||
|
|
|
||||||
|
|
@ -6,76 +6,76 @@ from pathlib import Path
|
||||||
SCRIPT_PATH = (
|
SCRIPT_PATH = (
|
||||||
Path(__file__).resolve().parents[2]
|
Path(__file__).resolve().parents[2]
|
||||||
/ "skills"
|
/ "skills"
|
||||||
/ "daybook-posting"
|
/ "agentfeed-posting"
|
||||||
/ "scripts"
|
/ "scripts"
|
||||||
/ "daybook_post.py"
|
/ "agentfeed_post.py"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load_module():
|
def _load_module():
|
||||||
spec = importlib.util.spec_from_file_location("daybook_post", SCRIPT_PATH)
|
spec = importlib.util.spec_from_file_location("agentfeed_post", SCRIPT_PATH)
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
return module
|
return module
|
||||||
|
|
||||||
|
|
||||||
daybook_post = _load_module()
|
agentfeed_post = _load_module()
|
||||||
|
|
||||||
|
|
||||||
class SniffTextKindTestCase(unittest.TestCase):
|
class SniffTextKindTestCase(unittest.TestCase):
|
||||||
def test_markdown_suffixes(self):
|
def test_markdown_suffixes(self):
|
||||||
for suffix in (".md", ".markdown", ".txt"):
|
for suffix in (".md", ".markdown", ".txt"):
|
||||||
self.assertEqual(daybook_post.sniff_text_kind(suffix, "anything"), "markdown")
|
self.assertEqual(agentfeed_post.sniff_text_kind(suffix, "anything"), "markdown")
|
||||||
|
|
||||||
def test_json_suffix(self):
|
def test_json_suffix(self):
|
||||||
self.assertEqual(daybook_post.sniff_text_kind(".json", '{"a": 1}'), "json")
|
self.assertEqual(agentfeed_post.sniff_text_kind(".json", '{"a": 1}'), "json")
|
||||||
|
|
||||||
def test_csv_suffix(self):
|
def test_csv_suffix(self):
|
||||||
self.assertEqual(daybook_post.sniff_text_kind(".csv", "a,b\n1,2"), "csv")
|
self.assertEqual(agentfeed_post.sniff_text_kind(".csv", "a,b\n1,2"), "csv")
|
||||||
|
|
||||||
def test_html_full_sniffed_from_content(self):
|
def test_html_full_sniffed_from_content(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
daybook_post.sniff_text_kind(".html", "<!doctype html><html></html>"), "html_full"
|
agentfeed_post.sniff_text_kind(".html", "<!doctype html><html></html>"), "html_full"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_deck_sniffed_from_content(self):
|
def test_deck_sniffed_from_content(self):
|
||||||
self.assertEqual(daybook_post.sniff_text_kind(".html", "<section>slide</section>"), "deck")
|
self.assertEqual(agentfeed_post.sniff_text_kind(".html", "<section>slide</section>"), "deck")
|
||||||
|
|
||||||
def test_html_fragment_is_the_fallback(self):
|
def test_html_fragment_is_the_fallback(self):
|
||||||
self.assertEqual(daybook_post.sniff_text_kind(".html", "<p>hi</p>"), "html_fragment")
|
self.assertEqual(agentfeed_post.sniff_text_kind(".html", "<p>hi</p>"), "html_fragment")
|
||||||
|
|
||||||
|
|
||||||
class ValidatePayloadTestCase(unittest.TestCase):
|
class ValidatePayloadTestCase(unittest.TestCase):
|
||||||
def test_valid_json_passes(self):
|
def test_valid_json_passes(self):
|
||||||
daybook_post.validate_payload({"kind": "json", "content": '{"a": 1}'}, None) # no raise
|
agentfeed_post.validate_payload({"kind": "json", "content": '{"a": 1}'}, None) # no raise
|
||||||
|
|
||||||
def test_invalid_json_raises(self):
|
def test_invalid_json_raises(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
daybook_post.validate_payload({"kind": "json", "content": "{not json"}, None)
|
agentfeed_post.validate_payload({"kind": "json", "content": "{not json"}, None)
|
||||||
|
|
||||||
def test_deck_without_section_raises(self):
|
def test_deck_without_section_raises(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
daybook_post.validate_payload({"kind": "deck", "content": "<h1>no slides</h1>"}, None)
|
agentfeed_post.validate_payload({"kind": "deck", "content": "<h1>no slides</h1>"}, None)
|
||||||
|
|
||||||
def test_html_fragment_with_nested_html_raises(self):
|
def test_html_fragment_with_nested_html_raises(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
daybook_post.validate_payload(
|
agentfeed_post.validate_payload(
|
||||||
{"kind": "html_fragment", "content": "<html><body>x</body></html>"}, None
|
{"kind": "html_fragment", "content": "<html><body>x</body></html>"}, None
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_html_full_without_html_tag_raises(self):
|
def test_html_full_without_html_tag_raises(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
daybook_post.validate_payload({"kind": "html_full", "content": "<p>x</p>"}, None)
|
agentfeed_post.validate_payload({"kind": "html_full", "content": "<p>x</p>"}, None)
|
||||||
|
|
||||||
def test_blank_markdown_raises(self):
|
def test_blank_markdown_raises(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
daybook_post.validate_payload({"kind": "markdown", "content": " "}, None)
|
agentfeed_post.validate_payload({"kind": "markdown", "content": " "}, None)
|
||||||
|
|
||||||
def test_csv_has_no_shape_check(self):
|
def test_csv_has_no_shape_check(self):
|
||||||
daybook_post.validate_payload({"kind": "csv", "content": "anything at all"}, None) # no raise
|
agentfeed_post.validate_payload({"kind": "csv", "content": "anything at all"}, None) # no raise
|
||||||
|
|
||||||
def test_upload_path_skips_all_checks(self):
|
def test_upload_path_skips_all_checks(self):
|
||||||
daybook_post.validate_payload({"upload_path": Path("whatever.png")}, None) # no raise
|
agentfeed_post.validate_payload({"upload_path": Path("whatever.png")}, None) # no raise
|
||||||
|
|
||||||
|
|
||||||
class ResolveAutoPathTestCase(unittest.TestCase):
|
class ResolveAutoPathTestCase(unittest.TestCase):
|
||||||
|
|
@ -93,7 +93,7 @@ class ResolveAutoPathTestCase(unittest.TestCase):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = Path(tmp) / "data.json"
|
path = Path(tmp) / "data.json"
|
||||||
path.write_text('{"a": 1}', encoding="utf-8")
|
path.write_text('{"a": 1}', encoding="utf-8")
|
||||||
args = daybook_post.resolve_auto_path(self._args(path))
|
args = agentfeed_post.resolve_auto_path(self._args(path))
|
||||||
self.assertEqual(args.kind, "json")
|
self.assertEqual(args.kind, "json")
|
||||||
self.assertEqual(args.content_file, str(path))
|
self.assertEqual(args.content_file, str(path))
|
||||||
|
|
||||||
|
|
@ -101,14 +101,14 @@ class ResolveAutoPathTestCase(unittest.TestCase):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = Path(tmp) / "data.csv"
|
path = Path(tmp) / "data.csv"
|
||||||
path.write_text("a,b\n1,2", encoding="utf-8")
|
path.write_text("a,b\n1,2", encoding="utf-8")
|
||||||
args = daybook_post.resolve_auto_path(self._args(path))
|
args = agentfeed_post.resolve_auto_path(self._args(path))
|
||||||
self.assertEqual(args.kind, "csv")
|
self.assertEqual(args.kind, "csv")
|
||||||
|
|
||||||
def test_image_file_becomes_multipart_upload(self):
|
def test_image_file_becomes_multipart_upload(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path = Path(tmp) / "chart.png"
|
path = Path(tmp) / "chart.png"
|
||||||
path.write_bytes(b"\x89PNG\r\n")
|
path.write_bytes(b"\x89PNG\r\n")
|
||||||
args = daybook_post.resolve_auto_path(self._args(path))
|
args = agentfeed_post.resolve_auto_path(self._args(path))
|
||||||
self.assertIsNone(args.kind)
|
self.assertIsNone(args.kind)
|
||||||
self.assertEqual(args.file, str(path))
|
self.assertEqual(args.file, str(path))
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@ class MakeRoomForTestCase(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
super().setUpClass()
|
super().setUpClass()
|
||||||
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-storage-"))
|
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-storage-"))
|
||||||
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
||||||
cls.settings_override.enable()
|
cls.settings_override.enable()
|
||||||
|
|
||||||
|
|
|
||||||
75
tests/views/test_feed_updates.py
Normal file
75
tests/views/test_feed_updates.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from artifacts.models import Artifact
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class FeedUpdatesViewTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
|
self.other = User.objects.create_user(username="bob", password="pw")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self._n = 0
|
||||||
|
|
||||||
|
def make(self, owner=None, title="Note", source="agent"):
|
||||||
|
self._n += 1
|
||||||
|
return Artifact.objects.create(
|
||||||
|
id=f"upd{self._n:05d}",
|
||||||
|
owner=owner or self.user,
|
||||||
|
source=source,
|
||||||
|
title=title,
|
||||||
|
kind=Artifact.MARKDOWN,
|
||||||
|
day=timezone.localdate(),
|
||||||
|
path=f"{source}/x.html",
|
||||||
|
original_kind=Artifact.MARKDOWN,
|
||||||
|
size_bytes=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def marker(self, seconds_ago=60):
|
||||||
|
return (timezone.now() - timedelta(seconds=seconds_ago)).isoformat()
|
||||||
|
|
||||||
|
def get(self, **params):
|
||||||
|
return self.client.get(reverse("feed-updates"), params)
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.get(after=self.marker())
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_returns_new_since_marker(self):
|
||||||
|
self.make(title="Fresh Post")
|
||||||
|
response = self.get(after=self.marker())
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(data["count"], 1)
|
||||||
|
self.assertIn("Fresh Post", data["html"])
|
||||||
|
self.assertIn("newest", data)
|
||||||
|
self.assertIn("today_label", data)
|
||||||
|
|
||||||
|
def test_nothing_newer_than_now(self):
|
||||||
|
self.make()
|
||||||
|
response = self.get(after=self.marker(seconds_ago=-60)) # 60s in the future
|
||||||
|
self.assertEqual(response.json()["count"], 0)
|
||||||
|
|
||||||
|
def test_missing_marker_returns_zero(self):
|
||||||
|
self.make()
|
||||||
|
self.assertEqual(self.get().json()["count"], 0)
|
||||||
|
|
||||||
|
def test_owner_scoped(self):
|
||||||
|
self.make(owner=self.other, title="Not mine")
|
||||||
|
data = self.get(after=self.marker()).json()
|
||||||
|
self.assertEqual(data["count"], 0)
|
||||||
|
|
||||||
|
def test_respects_source_filter(self):
|
||||||
|
self.make(title="Hermes post", source="hermes")
|
||||||
|
self.make(title="Janus post", source="janus")
|
||||||
|
data = self.get(after=self.marker(), source="hermes").json()
|
||||||
|
self.assertEqual(data["count"], 1)
|
||||||
|
self.assertIn("Hermes post", data["html"])
|
||||||
|
self.assertNotIn("Janus post", data["html"])
|
||||||
54
tests/views/test_settings_account.py
Normal file
54
tests/views/test_settings_account.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordViewTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="alice", password="oldpassword")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def _post(self, old, new1, new2):
|
||||||
|
return self.client.post(
|
||||||
|
reverse("settings-account"),
|
||||||
|
{"old_password": old, "new_password1": new1, "new_password2": new2},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_renders_form(self):
|
||||||
|
response = self.client.get(reverse("settings-account"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Change password")
|
||||||
|
self.assertContains(response, "old_password")
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(reverse("settings-account"))
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_valid_change_updates_password(self):
|
||||||
|
response = self._post("oldpassword", "brandnewpass", "brandnewpass")
|
||||||
|
self.assertRedirects(response, reverse("settings-account"))
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertTrue(self.user.check_password("brandnewpass"))
|
||||||
|
self.assertFalse(self.user.check_password("oldpassword"))
|
||||||
|
|
||||||
|
def test_valid_change_keeps_session(self):
|
||||||
|
self._post("oldpassword", "brandnewpass", "brandnewpass")
|
||||||
|
# Session must survive the password change (update_session_auth_hash),
|
||||||
|
# so a follow-up authenticated request still succeeds.
|
||||||
|
response = self.client.get(reverse("settings-account"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_wrong_current_password_rejected(self):
|
||||||
|
response = self._post("WRONG", "brandnewpass", "brandnewpass")
|
||||||
|
self.assertEqual(response.status_code, 200) # re-render with errors, no redirect
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertTrue(self.user.check_password("oldpassword"))
|
||||||
|
|
||||||
|
def test_mismatched_new_password_rejected(self):
|
||||||
|
response = self._post("oldpassword", "brandnewpass", "different")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertTrue(self.user.check_password("oldpassword"))
|
||||||
113
tests/views/test_settings_labels.py
Normal file
113
tests/views/test_settings_labels.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from artifacts.models import Artifact, Label, TagColor
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class LabelPaletteTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def url(self):
|
||||||
|
return reverse("settings-labels")
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
self.assertEqual(self.client.get(self.url()).status_code, 302)
|
||||||
|
|
||||||
|
def test_create_label_sets_palette_and_tagcolor(self):
|
||||||
|
self.client.post(self.url(), {"action": "create", "name": "follow-up", "color": "#112233"})
|
||||||
|
label = Label.objects.get(user=self.user, name="follow-up")
|
||||||
|
self.assertEqual(label.color, "#112233")
|
||||||
|
self.assertEqual(TagColor.objects.get(name="follow-up").color, "#112233")
|
||||||
|
|
||||||
|
def test_create_rejects_bad_color(self):
|
||||||
|
self.client.post(self.url(), {"action": "create", "name": "x", "color": "red"})
|
||||||
|
self.assertFalse(Label.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
def test_create_requires_name(self):
|
||||||
|
self.client.post(self.url(), {"action": "create", "name": " ", "color": "#112233"})
|
||||||
|
self.assertFalse(Label.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
def test_duplicate_name_same_user_rejected(self):
|
||||||
|
Label.objects.create(user=self.user, name="dup", color="#111111")
|
||||||
|
self.client.post(self.url(), {"action": "create", "name": "dup", "color": "#222222"})
|
||||||
|
self.assertEqual(Label.objects.filter(user=self.user, name="dup").count(), 1)
|
||||||
|
|
||||||
|
def test_same_name_different_user_allowed(self):
|
||||||
|
other = User.objects.create_user(username="bob", password="pw")
|
||||||
|
Label.objects.create(user=other, name="shared", color="#111111")
|
||||||
|
self.client.post(self.url(), {"action": "create", "name": "shared", "color": "#222222"})
|
||||||
|
self.assertTrue(Label.objects.filter(user=self.user, name="shared").exists())
|
||||||
|
|
||||||
|
def test_recolor_updates_tagcolor(self):
|
||||||
|
label = Label.objects.create(user=self.user, name="urgent", color="#111111")
|
||||||
|
self.client.post(self.url(), {"action": "update", "label_id": label.pk, "color": "#ff0000"})
|
||||||
|
label.refresh_from_db()
|
||||||
|
self.assertEqual(label.color, "#ff0000")
|
||||||
|
self.assertEqual(TagColor.objects.get(name="urgent").color, "#ff0000")
|
||||||
|
|
||||||
|
def test_delete_removes_from_palette_only(self):
|
||||||
|
label = Label.objects.create(user=self.user, name="temp", color="#111111")
|
||||||
|
art = Artifact.objects.create(
|
||||||
|
id="lbl00001", owner=self.user, source="agent", title="T", kind=Artifact.MARKDOWN,
|
||||||
|
day=timezone.localdate(), path="agent/lbl00001.html", original_kind=Artifact.MARKDOWN,
|
||||||
|
tags=["temp"],
|
||||||
|
)
|
||||||
|
self.client.post(self.url(), {"action": "delete", "label_id": label.pk})
|
||||||
|
self.assertFalse(Label.objects.filter(pk=label.pk).exists())
|
||||||
|
art.refresh_from_db()
|
||||||
|
self.assertEqual(art.tags, ["temp"]) # chip stays on the message
|
||||||
|
|
||||||
|
|
||||||
|
class ToggleLabelTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
|
self.other = User.objects.create_user(username="bob", password="pw")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.label = Label.objects.create(user=self.user, name="star", color="#abcdef")
|
||||||
|
self.art = Artifact.objects.create(
|
||||||
|
id="tog00001", owner=self.user, source="agent", title="T", kind=Artifact.MARKDOWN,
|
||||||
|
day=timezone.localdate(), path="agent/tog00001.html", original_kind=Artifact.MARKDOWN,
|
||||||
|
)
|
||||||
|
|
||||||
|
def toggle(self, art_id, name):
|
||||||
|
return self.client.post(reverse("artifact-toggle-label", args=[art_id]), {"name": name})
|
||||||
|
|
||||||
|
def test_toggle_on_then_off(self):
|
||||||
|
r = self.toggle(self.art.id, "star")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertTrue(r.json()["applied"])
|
||||||
|
self.art.refresh_from_db()
|
||||||
|
self.assertIn("star", self.art.tags)
|
||||||
|
|
||||||
|
r = self.toggle(self.art.id, "star")
|
||||||
|
self.assertFalse(r.json()["applied"])
|
||||||
|
self.art.refresh_from_db()
|
||||||
|
self.assertNotIn("star", self.art.tags)
|
||||||
|
|
||||||
|
def test_toggle_syncs_tagcolor(self):
|
||||||
|
self.toggle(self.art.id, "star")
|
||||||
|
self.assertEqual(TagColor.objects.get(name="star").color, "#abcdef")
|
||||||
|
|
||||||
|
def test_name_not_in_palette_rejected(self):
|
||||||
|
r = self.toggle(self.art.id, "nope")
|
||||||
|
self.assertEqual(r.status_code, 400)
|
||||||
|
self.art.refresh_from_db()
|
||||||
|
self.assertEqual(self.art.tags, [])
|
||||||
|
|
||||||
|
def test_owner_scoped(self):
|
||||||
|
theirs = Artifact.objects.create(
|
||||||
|
id="tog00002", owner=self.other, source="agent", title="T", kind=Artifact.MARKDOWN,
|
||||||
|
day=timezone.localdate(), path="agent/tog00002.html", original_kind=Artifact.MARKDOWN,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.toggle(theirs.id, "star").status_code, 404)
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
self.assertEqual(self.toggle(self.art.id, "star").status_code, 302)
|
||||||
|
|
@ -16,7 +16,7 @@ class SharingTestCase(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
super().setUpClass()
|
super().setUpClass()
|
||||||
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-sharing-"))
|
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-sharing-"))
|
||||||
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
||||||
cls.settings_override.enable()
|
cls.settings_override.enable()
|
||||||
|
|
||||||
|
|
|
||||||
158
tests/views/test_trash.py
Normal file
158
tests/views/test_trash.py
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from artifacts.models import Artifact
|
||||||
|
from artifacts.storage import purge_expired_trash, user_usage_bytes
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
# Run the purge inline (no background thread) so its effects are deterministic.
|
||||||
|
@override_settings(AGENTFEED_ASYNC_PURGE=False)
|
||||||
|
class TrashKeepViewTestCase(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-trash-"))
|
||||||
|
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
||||||
|
cls.settings_override.enable()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.settings_override.disable()
|
||||||
|
shutil.rmtree(cls.artifacts_root, ignore_errors=True)
|
||||||
|
super().tearDownClass()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
|
self.other = User.objects.create_user(username="bob", password="pw")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self._n = 0
|
||||||
|
|
||||||
|
def make(self, owner=None, title="Note", source="agent", is_kept=False, size_bytes=1):
|
||||||
|
self._n += 1
|
||||||
|
return Artifact.objects.create(
|
||||||
|
id=f"trs{self._n:05d}",
|
||||||
|
owner=owner or self.user,
|
||||||
|
source=source,
|
||||||
|
title=title,
|
||||||
|
kind=Artifact.MARKDOWN,
|
||||||
|
day=timezone.localdate(),
|
||||||
|
path=f"{source}/trs{self._n:05d}.html",
|
||||||
|
original_kind=Artifact.MARKDOWN,
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
is_kept=is_kept,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delete_soft_deletes(self):
|
||||||
|
a = self.make()
|
||||||
|
r = self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
a.refresh_from_db()
|
||||||
|
self.assertIsNotNone(a.deleted_at)
|
||||||
|
|
||||||
|
def test_delete_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
a = self.make()
|
||||||
|
r = self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||||
|
self.assertEqual(r.status_code, 302)
|
||||||
|
|
||||||
|
def test_delete_scoped_to_owner(self):
|
||||||
|
a = self.make(owner=self.other)
|
||||||
|
r = self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
a.refresh_from_db()
|
||||||
|
self.assertIsNone(a.deleted_at)
|
||||||
|
|
||||||
|
def test_deleted_hidden_from_feed_shown_in_trash(self):
|
||||||
|
a = self.make(title="ThrowawayThing")
|
||||||
|
self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||||
|
self.assertNotIn("ThrowawayThing", self.client.get("/").content.decode())
|
||||||
|
self.assertIn("ThrowawayThing", self.client.get("/?folder=deleted").content.decode())
|
||||||
|
|
||||||
|
def test_undelete_restores(self):
|
||||||
|
a = self.make(title="ComeBack")
|
||||||
|
self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||||
|
r = self.client.post(reverse("artifact-undelete", args=[a.id]))
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
a.refresh_from_db()
|
||||||
|
self.assertIsNone(a.deleted_at)
|
||||||
|
self.assertIn("ComeBack", self.client.get("/").content.decode())
|
||||||
|
|
||||||
|
def test_empty_trash_purges(self):
|
||||||
|
a = self.make()
|
||||||
|
b = self.make()
|
||||||
|
self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||||
|
self.client.post(reverse("artifact-delete", args=[b.id]))
|
||||||
|
r = self.client.post(reverse("feed-empty-trash"))
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.json()["purged"], 2)
|
||||||
|
self.assertFalse(Artifact.objects.filter(pk__in=[a.id, b.id]).exists())
|
||||||
|
|
||||||
|
def test_deleted_is_off_quota(self):
|
||||||
|
a = self.make(size_bytes=1000)
|
||||||
|
self.assertEqual(user_usage_bytes(self.user), 1000)
|
||||||
|
self.client.post(reverse("artifact-delete", args=[a.id]))
|
||||||
|
self.assertEqual(user_usage_bytes(self.user), 0)
|
||||||
|
|
||||||
|
def test_keep_folder_shows_only_kept(self):
|
||||||
|
self.make(title="KeptOne", is_kept=True)
|
||||||
|
self.make(title="NormalOne")
|
||||||
|
page = self.client.get("/?folder=keep").content.decode()
|
||||||
|
self.assertIn("KeptOne", page)
|
||||||
|
self.assertNotIn("NormalOne", page)
|
||||||
|
|
||||||
|
def test_purge_expired_trash_removes_old_only(self):
|
||||||
|
old = self.make()
|
||||||
|
recent = self.make()
|
||||||
|
Artifact.objects.filter(pk=old.id).update(deleted_at=timezone.now() - timedelta(days=4))
|
||||||
|
Artifact.objects.filter(pk=recent.id).update(deleted_at=timezone.now())
|
||||||
|
purged = purge_expired_trash()
|
||||||
|
self.assertEqual(purged, 1)
|
||||||
|
self.assertFalse(Artifact.objects.filter(pk=old.id).exists())
|
||||||
|
self.assertTrue(Artifact.objects.filter(pk=recent.id).exists())
|
||||||
|
|
||||||
|
def test_delete_triggers_expired_purge(self):
|
||||||
|
old = self.make()
|
||||||
|
Artifact.objects.filter(pk=old.id).update(deleted_at=timezone.now() - timedelta(days=4))
|
||||||
|
fresh = self.make()
|
||||||
|
# Deleting fresh runs the purge (inline here) -> the expired one goes,
|
||||||
|
# the freshly-trashed one stays.
|
||||||
|
self.client.post(reverse("artifact-delete", args=[fresh.id]))
|
||||||
|
self.assertFalse(Artifact.objects.filter(pk=old.id).exists())
|
||||||
|
self.assertTrue(Artifact.objects.filter(pk=fresh.id, deleted_at__isnull=False).exists())
|
||||||
|
|
||||||
|
def test_mark_all_read_across_sources(self):
|
||||||
|
self.make(source="a")
|
||||||
|
self.make(source="b")
|
||||||
|
r = self.client.post(reverse("feed-mark-read"))
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.json()["marked"], 2)
|
||||||
|
self.assertFalse(Artifact.objects.filter(owner=self.user, read_at__isnull=True).exists())
|
||||||
|
|
||||||
|
def test_mark_all_read_scoped_to_source(self):
|
||||||
|
a = self.make(source="hermes")
|
||||||
|
b = self.make(source="janus")
|
||||||
|
self.assertEqual(self.client.post(reverse("feed-mark-read"), {"source": "hermes"}).json()["marked"], 1)
|
||||||
|
a.refresh_from_db()
|
||||||
|
b.refresh_from_db()
|
||||||
|
self.assertIsNotNone(a.read_at)
|
||||||
|
self.assertIsNone(b.read_at)
|
||||||
|
|
||||||
|
def test_mark_all_read_owner_scoped(self):
|
||||||
|
theirs = self.make(owner=self.other)
|
||||||
|
self.client.post(reverse("feed-mark-read"))
|
||||||
|
theirs.refresh_from_db()
|
||||||
|
self.assertIsNone(theirs.read_at)
|
||||||
|
|
||||||
|
def test_mark_all_read_excludes_trashed(self):
|
||||||
|
a = self.make()
|
||||||
|
Artifact.objects.filter(pk=a.id).update(deleted_at=timezone.now())
|
||||||
|
self.assertEqual(self.client.post(reverse("feed-mark-read")).json()["marked"], 0)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue