Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

68 changed files with 1173 additions and 3344 deletions

View file

@ -8,6 +8,6 @@ Run the demo seeding script to populate today with one tagged sample artifact pe
python scripts/seed_demo_artifacts.py
```
Requires the AgentFeed dev server to be running on the project's fixed local port: `python manage.py runserver 127.0.0.1:8001`. The script defaults to `AGENTFEED_URL=http://127.0.0.1:8001` / `AGENTFEED_TOKEN=dev-token`; no overrides needed unless the user has changed these.
Requires the Daybook dev server to be running on the project's fixed local port: `python manage.py runserver 127.0.0.1:8001`. The script defaults to `DAYBOOK_URL=http://127.0.0.1:8001` / `DAYBOOK_TOKEN=dev-token`; no overrides needed unless the user has changed these.
After running, report each posted artifact's kind and URL, and remind the user to reload the feed for today.

View file

@ -1,280 +0,0 @@
# Changelog
All notable changes to AgentFeed are recorded here. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/); versions are SemVer and match
the git tags (`vX.Y.Z`) and `__version__`. (Entries before 0.3.0 use the app's
former name, "Daybook".)
## [Unreleased]
_Nothing yet._
## [0.7.1] — 2026-09-08
### Added
- **Custom labels.** Curate a personal palette of coloured labels in
**Settings → Labels**, then apply them to any message from its ⋯ menu (or
right-click) — the menu stays open so you can apply several at once, with a
checkmark showing which are applied. Labels are unified with tags: applying
one adds it to the message's tags and renders as a chip. Backed by
`POST /a/{id}/label` (session-auth, owner-scoped, palette-validated); chip
colours sync to the global `TagColor`.
- **Change your password in-app** from **Settings → Account**: enter the current
password, then a new one. Uses Django's `PasswordChangeForm` and keeps the
session logged in after the change.
### Changed
- Sidebar: the **Keep** and **Deleted** folders are now always shown (even when
empty); the count badge only appears when the folder is non-empty.
## [0.7.0] — 2026-09-07
### Added
- **Mark all as read.** Click a source's unread badge in the sidebar to mark
all of that source's messages read (clears the badge); click the **All
sources** total to mark everything read. Owner-scoped, excludes trashed; any
visible feed rows update too. Backed by `POST /feed/mark-read`.
## [0.6.3] — 2026-09-07
### Changed
- **User-facing wording: "artifact(s)" → "messages"** throughout the UI (feed
count, empty states, preview, About, Storage). The model, API
(`/api/artifacts`), and posting skill keep "artifact" as the internal term —
no contract change for agents.
- Sidebar: the Keep/Deleted folders now have spacing between them and share the
"All sources" bold+blue-tint styling (they're special folders).
### Fixed
- About page now points to Settings → API Keys (was the stale "Settings →
Account / personal API token").
## [0.6.2] — 2026-09-07
### Changed
- Source sidebar is now ordered by **most recent message** (Telegram-style)
instead of alphabetically; "All sources" stays pinned at the top. Live
updates move a source to the top when it receives a new message.
## [0.6.1] — 2026-09-07
### Changed
- Sidebar: the **Keep / Deleted folders are pinned to the bottom** — only the
source list scrolls, so the folders stay reachable no matter how many sources
you have. **"All sources"** is now visually distinct (bold + subtle blue tint)
as the primary entry. (Internal: `.source-folders` class renamed to
`.system-folders`.)
## [0.6.0] — 2026-09-07
### Added
- **Per-artifact actions menu.** A **⋯** button appears on row hover, and
**right-clicking** a feed row opens the same floating menu — **Keep /
Open / Copy link / Delete**. Dismisses on click-away or Escape, edge-flips
to stay on-screen. The standalone one-click Delete icon stays; the Trash
view keeps its dedicated Undelete. (Share and Add-label will join the menu
in later slices.)
## [0.5.1] — 2026-09-07
### Added
- **Automatic Trash purge.** Trashed artifacts older than a ~3-day retention
window are now permanently removed automatically. It's cron-less: a delete
triggers a sweep that runs on a **background thread** (off the request, so
the click never blocks), tolerant of concurrent runs. Manual "Empty Trash"
still works for immediate reclaim. Controlled by `AGENTFEED_ASYNC_PURGE`
(on by default; tests run it inline).
## [0.5.0] — 2026-09-07
### Added
- **Trash (soft-delete).** Deleting an artifact now moves it to a recoverable
**Trash** instead of removing it. Feed rows get a hover **Delete** icon; a
**"Deleted"** folder at the bottom of the sidebar lists trashed items with a
hover **Undelete**, plus an **Empty Trash** button to purge permanently.
Trashed items are **off-quota** (deleting frees quota immediately) and
eviction-protected. `DELETE /api/artifacts/{id}` is now a soft-delete too
(idempotent); trashed items are hidden from all API reads.
- **Keep folder.** A sidebar folder listing kept artifacts (a view over the
existing `is_kept` flag), mirroring Trash. Both hide when empty.
- Session endpoints `POST /a/{id}/delete`, `/a/{id}/undelete`,
`/feed/empty-trash`; feed `?folder=deleted` / `?folder=keep` views.
### Changed
- `DELETE /api/artifacts/{id}` no longer hard-deletes — see above.
### Note
- Permanent purge is manual (**Empty Trash**) in this release; automatic
purge-on-delete (background, ~3-day retention) is the planned fast-follow.
### Changed
- Live feed updates now also keep the **source sidebar** current: as new posts
arrive, per-source and total unread counts bump, and a source posting for the
first time is added to the list (alphabetically) — all derived client-side
from the polled rows, no extra request. Sources added live stay
keyboard-navigable. Caveat: while filtered to a single source, *other* new
sources still only appear on reload (the poll is scoped to the selected view).
## [0.4.0] — 2026-09-04
### Added
- **Live feed updates.** New artifacts now appear at the top of the feed
automatically, without a manual refresh — the browser polls a lightweight
`GET /feed/updates` every 5s (paused while the tab is hidden, one immediate
poll on refocus). Owner-scoped and **respects the active source/label/kind/q
filters**, so a new post that doesn't match your current view won't show. The
currently selected/previewed artifact stays put and visibly selected. Fresh
rows display "now" and age into their real time after a few minutes.
(Chosen over SSE/WebSockets deliberately: zero new infrastructure on the
sync-WSGI + SQLite stack; see `planning/next_steps/` for the SSE upgrade path.)
### Changed
- Extracted the feed row markup into a reusable `_artifact_rows.html` partial
(shared by the day-group render and the live-updates endpoint).
## [0.3.2] — 2026-09-04
### Added
- `GET /api/artifacts/{id}/raw` — the artifact's full content, separate from the
metadata detail endpoint. Bearer-token (or session) auth, owner-scoped, so an
agent can read back and verify exactly what it posted: exact source text for
`markdown`/`html_full`/`json`/`csv`, file bytes for `image`/`pdf`/`binary`,
`404` for render-only `html_fragment`/`deck`. The metadata response now
includes `api_raw_url`, and `/api/capabilities` advertises the route.
## [0.3.1] — 2026-09-04
### Changed
- Artifact **read** endpoints (`GET /api/artifacts`, `GET /api/artifacts/{id}`)
now accept a bearer token as well as a browser session, owner-scoped — an
agent can read back what it posted with the same token. Unauthenticated API
requests return a JSON `403` instead of a 302 redirect to the browser login.
- `GET /api/skill` and `GET /api/capabilities` are now **public** (no auth) —
they expose no user data, so an agent can fetch the contract and posting
instructions before it has a key.
## [0.3.0] — 2026-09-04
### Changed
- **Renamed the app to AgentFeed.** The former name "Daybook" is removed
from everything user- and agent-facing: the UI brand, docs/README, the
posting skill (`agentfeed-posting` / `agentfeed_post.py`), CSS
(`agentfeed.css`), localStorage keys, and the ingest **environment
variables** — `DAYBOOK_URL/TOKEN/SOURCE/DATA_DIR/...` are now
`AGENTFEED_*`. Agents must update their env-var names (the token *values*
are unchanged). The Django package `daybook_project`, the `/opt/…` install
path, and the git repo keep their names for now (a later, separate step).
- Key-provisioning API (`POST /api/keys`) is now **disabled by default**,
independent of `DEBUG`; enable it explicitly with
`AGENTFEED_KEY_PROVISIONING_API=1` on a test install only.
### Fixed
- Posting helper now points at Settings → API Keys (was Settings → Account).
## [0.2.1] — 2026-09-04
### Added
- Top-bar **avatar**: a round initials badge (deterministic per-user color)
replaces the gear/username button; lays the groundwork for profile photos.
- Django admin now registers `ApiKey` and `TagColor`, so `/admin/` is complete
as an operator break-glass tool (key value shown read-only).
### Changed
- Settings nav regrouped into **You** (Account, API Keys, Storage, Demo Data)
and a superuser-only **Admin** (Users) section, with About on its own — the
personal/admin split now mirrors who each setting affects.
## [0.2.0] — 2026-09-04
### Added
- **Multiple named API keys per user.** Each user can now hold several keys —
one per agent or script — named and revocable individually, managed from a
new **Settings → API Keys** page (a table with name, key, created, last-used,
and per-row revoke). Keys are viewable on demand (`dbk_`-prefixed).
- **Gated key-provisioning API** for automated test installations:
`GET/POST /api/keys` and `DELETE /api/keys/{id}`, controlled by
`DAYBOOK_KEY_PROVISIONING_API` (unset → follows `DJANGO_DEBUG`, so **off in
deployment**). Advertised as `key_provisioning_api` in `/api/capabilities`;
returns 403 when disabled.
- API keys track `last_used_at` (throttled to one write/minute) so stale keys
are visible before you revoke them.
### Changed
- Auth now resolves bearer tokens against the new `ApiKey` model instead of the
single DRF `authtoken.Token`. Existing tokens are migrated 1:1 into named
("default") keys with the same value — no client reconfiguration needed.
- Settings → Account no longer manages a single token; it links to API Keys.
Creating a user (Settings → Users) issues an initial named key.
## [0.1.1] — 2026-09-03
### Fixed
- Deployment crash-loop: the vendored `gridjs.umd.js` referenced a
`gridjs.umd.js.map` sourcemap that isn't in the repo, so `collectstatic`
(strict manifest storage) failed on container start and v0.1.0 never came
up. Removed the dangling sourcemap trailer (debug-only, no runtime effect).
### Added
- `GET /api/skill` serves the bundled agent-posting skill as Markdown (or
`?format=json`), with `DAYBOOK_URL` pre-filled to the instance's own
origin so the instructions are ready to hand to an AI. Advertised as
`skill_url` in `/api/capabilities`.
### Changed
- Rewrote the posting skill (`skills/daybook-posting/SKILL.md`) to be
location-agnostic: no longer assumes a local source checkout, reframes
`DAYBOOK_URL` as set-your-instance, and adds a raw `curl` fallback.
## [0.1.0] — 2026-09-03
First tagged release.
### Artifacts & rendering
- Artifact kinds: `markdown`, `html_fragment`, `html_full`, `deck`, `json`,
`csv`, `image`, `pdf`, `binary`.
- `.xlsx` uploads convert server-side to a `csv` artifact (active sheet,
saved formula results).
- Markdown code blocks get syntax highlighting; tables (markdown &
html_fragment) render styled — monospace cells, sans-serif headers.
- `json`/`csv` render as an interactive collapsible tree / sortable table
(Grid.js + renderjson, vendored locally).
- Unified off-white document background; system sans-serif body text.
### API
- `POST/GET/PATCH/DELETE /api/artifacts`, list filters (source/label/day/
kind/q), `/api/assets` image upload, `/api/capabilities` (reports
`version`). Per-user bearer-token ingest; session-authed reads.
- Server-side validation: per-kind content shape, field length limits,
string-typed source fields, valid-JSON for `json`.
### Sharing, quotas, security
- Per-artifact public share links (`/s/{token}`), owner-toggled, revocable.
- Per-user disk quotas with oldest-unkept auto-eviction and a Keep flag.
- Allowlist HTML sanitization (nh3) + `script-src 'none'` CSP for the themed
kinds; deep-JSON parse errors return 400 (not 500).
### UI
- Multi-user auth; feed with source sidebar + label filtering; settings area
(Account, Storage, Demo Data, Users, About); light/dark toggle; app icon.
### Deployment
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
runbook (`deploy/cloud_agent_deploy.md`) and health check.
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.7.0...HEAD
[0.7.0]: https://git.cygnus.systems/peter/daybook/compare/v0.6.3...v0.7.0
[0.6.3]: https://git.cygnus.systems/peter/daybook/compare/v0.6.2...v0.6.3
[0.6.2]: https://git.cygnus.systems/peter/daybook/compare/v0.6.1...v0.6.2
[0.6.1]: https://git.cygnus.systems/peter/daybook/compare/v0.6.0...v0.6.1
[0.6.0]: https://git.cygnus.systems/peter/daybook/compare/v0.5.1...v0.6.0
[0.5.1]: https://git.cygnus.systems/peter/daybook/compare/v0.5.0...v0.5.1
[0.5.0]: https://git.cygnus.systems/peter/daybook/compare/v0.4.1...v0.5.0
[0.4.1]: https://git.cygnus.systems/peter/daybook/compare/v0.4.0...v0.4.1
[0.4.0]: https://git.cygnus.systems/peter/daybook/compare/v0.3.2...v0.4.0
[0.3.2]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...v0.3.2
[0.3.1]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...v0.3.1
[0.3.0]: https://git.cygnus.systems/peter/daybook/compare/v0.2.1...v0.3.0
[0.2.1]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...v0.2.1
[0.2.0]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...v0.2.0
[0.1.1]: https://git.cygnus.systems/peter/daybook/compare/v0.1.0...v0.1.1
[0.1.0]: https://git.cygnus.systems/peter/daybook/releases/tag/v0.1.0

View file

@ -2,7 +2,7 @@
## Project
AgentFeed is a self-hosted, multi-user Django + Django REST Framework web app where AI agents, scripts, or people publish artifacts and each logged-in user browses their own time-ordered, label-filterable feed.
Daybook is a self-hosted, multi-user Django + Django REST Framework web app where AI agents, scripts, or people publish artifacts and each logged-in user browses their own time-ordered, label-filterable feed.
The core product principle is: agents publish content, not files. The server owns IDs, styling, storage layout, dates, and normalization into browser-native HTML, image, or PDF outputs.
@ -11,11 +11,10 @@ The core product principle is: agents publish content, not files. The server own
- Each user has a personal ingest API token (DRF `Token` model), viewable/regenerable from Settings → Account.
- Require `source` in every artifact payload — a free-text label identifying what posted it (agent-profile, script, or person name). Ownership of the resulting artifact comes from whichever user's token authenticated the POST, not from the `source` value itself.
- `source` and `tags` together form one unified **label** system: every artifact shows all its labels next to the title, and a single `label` filter/query param matches against either.
- Require login (Django session auth) for the browser feed/UI. The JSON API accepts a per-user bearer token for both ingest (POST/PATCH/DELETE) and artifact reads (GET list/detail), owner-scoped — an agent reads back with the same token it posts with; API requests never redirect to a browser login (JSON 403 instead). `GET /api/capabilities` and `GET /api/skill` are public (no user data), so agents can bootstrap before they have a key.
- Require login (Django session auth) for the whole app, including the feed and API reads. Ingest (POST/PATCH) stays per-user bearer-token authenticated, since agents don't have browser sessions.
- Exception to the above: an artifact can be explicitly opted into public sharing (per-artifact, owner-toggled from the feed UI) via a high-entropy `share_token`, served unauthenticated at `GET /s/{share_token}`. Not exposed via the ingest API yet — UI-only. Turning sharing off immediately invalidates the old link. Images embedded in a shared artifact are also served unauthenticated while it's shared, so inline content doesn't break for a link recipient.
- User accounts can be created from Settings → Users (superuser-only in-app page) or via Django admin. Regular users are not staff and cannot access `/admin/`. Creating a user via Settings → Users also issues their API token immediately, shown once.
- `TagColor` (label color overrides) stays global/shared across all users.
- Deleting an artifact is a **soft-delete to Trash** (`deleted_at`), not a hard removal — recoverable via the "Deleted" sidebar folder (undelete), off-quota and eviction-protected while trashed, and purged permanently via "Empty Trash". `DELETE /api/artifacts/{id}` is soft too (idempotent); trashed artifacts are hidden from all API reads. A **Keep** sidebar folder is a view over `is_kept`. Both the Keep and Deleted folders are always shown in the sidebar (even when empty); the count badge only appears when the folder is non-empty. Trash auto-purges after a ~3-day retention window via a cron-less background thread triggered by deletes (`AGENTFEED_ASYNC_PURGE`, on by default); "Empty Trash" purges immediately.
- Use random 8-character artifact IDs.
- Start as a plain Django project; add Docker after core ingest and viewer work.
- Preserve original markdown as `.md` alongside rendered `.html`.
@ -45,7 +44,7 @@ The core product principle is: agents publish content, not files. The server own
- Use the local Django commands for normal iteration: `python manage.py check`, `python manage.py migrate`, and `python manage.py runserver`.
- Run the local development server on `127.0.0.1:8001`. Port `8000` may be reserved or blocked on this workstation.
- Always use this same fixed port for the dev server. Never start a second instance on a different port; if `8001` is already serving, reuse it rather than starting another.
- All artifact-posting tools (`skills/agentfeed-posting/scripts/agentfeed_post.py`, `scripts/seed_demo_artifacts.py`, and any future posting skill) must default `AGENTFEED_URL` to `http://127.0.0.1:8001` to match.
- All artifact-posting tools (`skills/daybook-posting/scripts/daybook_post.py`, `scripts/seed_demo_artifacts.py`, and any future posting skill) must default `DAYBOOK_URL` to `http://127.0.0.1:8001` to match.
- A regular (non-superuser) user `test` / password `testpwd` must always exist in the local dev database — it's the permanent account a sibling project (`AGENT_DAYBOOK__LLM_TEST`) uses for API testing. If the local `db.sqlite3` is ever reset, recreate this exact user (e.g. via Settings → Users) before assuming that project is broken.
- Do not deploy to the Proxmox NUC during routine code iteration.
- Treat the NUC container as the local live/staging target. Deploy there only at explicit stable checkpoints, such as an end-of-day build.

View file

@ -1,56 +0,0 @@
# AgentFeed
AgentFeed is a self-hosted, multi-user **artifact journal**. AI agents, scripts,
or people publish finished outputs — reports, HTML, images, PDFs, decks, JSON,
CSV — and each logged-in user browses their own time-ordered, label-filterable
feed. The core principle: **agents publish content, not files.** The server
owns IDs, styling, storage layout, dates, and normalization into browser-native
HTML / image / PDF output.
Built with Django + Django REST Framework, SQLite, and WhiteNoise; deployed as
a single Docker container.
## Quick start (local development)
```bash
python -m venv .venv
.venv/Scripts/python -m pip install -r requirements.txt # Windows; use .venv/bin on POSIX
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver 127.0.0.1:8001
```
Then open <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`.

View file

@ -1,6 +1,6 @@
from django.contrib import admin
from .models import ApiKey, Artifact, Asset, TagColor, UserProfile
from .models import Artifact, Asset, UserProfile
@admin.register(Artifact)
@ -21,18 +21,3 @@ class AssetAdmin(admin.ModelAdmin):
list_display = ("id", "owner", "artifact", "content_type", "size_bytes", "created_at")
list_filter = ("owner", "content_type")
search_fields = ("id",)
@admin.register(ApiKey)
class ApiKeyAdmin(admin.ModelAdmin):
list_display = ("name", "user", "key", "created_at", "last_used_at")
list_filter = ("user",)
search_fields = ("name", "key", "user__username")
# The key value and timestamps are system-managed; show but don't hand-edit.
readonly_fields = ("key", "created_at", "last_used_at")
@admin.register(TagColor)
class TagColorAdmin(admin.ModelAdmin):
list_display = ("name", "color")
search_fields = ("name",)

View file

@ -2,7 +2,7 @@ from rest_framework.response import Response
from rest_framework.views import exception_handler
def agentfeed_exception_handler(exc, context):
def daybook_exception_handler(exc, context):
"""DRF exception handler that additionally maps pathological-input crashes
to a clean 400 instead of an unhandled 500.

View file

@ -2,5 +2,5 @@ from django import forms
from django.contrib.auth.forms import AuthenticationForm
class AgentFeedLoginForm(AuthenticationForm):
class DaybookLoginForm(AuthenticationForm):
remember_me = forms.BooleanField(required=False, initial=True)

View file

@ -1,31 +0,0 @@
# Generated by Django 5.2.15 on 2026-09-04 09:35
import artifacts.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0013_artifact_share_token'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ApiKey',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('key', models.CharField(db_index=True, default=artifacts.models.generate_api_key, max_length=64, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['created_at'],
},
),
]

View file

@ -1,32 +0,0 @@
from django.db import migrations
def copy_tokens_to_apikeys(apps, schema_editor):
"""Carry every existing DRF authtoken over to a named ApiKey so keys issued
before the multi-key feature keep authenticating (incl. the permanent test
user and any deployed agents). Same key string -> no client reconfiguration."""
Token = apps.get_model("authtoken", "Token")
ApiKey = apps.get_model("artifacts", "ApiKey")
for token in Token.objects.all():
if ApiKey.objects.filter(key=token.key).exists():
continue
api_key = ApiKey.objects.create(user_id=token.user_id, name="default", key=token.key)
# created_at is auto_now_add, so set it after the fact to preserve the
# original issue date (queryset.update bypasses auto_now_add).
ApiKey.objects.filter(pk=api_key.pk).update(created_at=token.created)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("artifacts", "0014_apikey"),
("authtoken", "0004_alter_tokenproxy_options"),
]
operations = [
migrations.RunPython(copy_tokens_to_apikeys, noop),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 5.2.15 on 2026-09-07 11:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0015_migrate_tokens_to_apikeys'),
]
operations = [
migrations.AddField(
model_name='artifact',
name='deleted_at',
field=models.DateTimeField(blank=True, db_index=True, null=True),
),
]

View file

@ -1,29 +0,0 @@
# Generated by Django 5.2.15 on 2026-09-08 13:46
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0016_artifact_deleted_at'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Label',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=40)),
('color', models.CharField(max_length=20)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
'unique_together': {('user', 'name')},
},
),
]

View file

@ -1,14 +1,7 @@
import secrets
from django.conf import settings
from django.db import models
def generate_api_key():
"""`dbk_` prefix (recognizable as a AgentFeed key) + 40 hex chars."""
return "dbk_" + secrets.token_hex(20)
class Artifact(models.Model):
HTML_FRAGMENT = "html_fragment"
HTML_FULL = "html_full"
@ -60,9 +53,6 @@ class Artifact(models.Model):
metadata = models.JSONField(default=dict, blank=True)
read_at = models.DateTimeField(null=True, blank=True)
is_kept = models.BooleanField(default=False)
# Soft-delete: non-null = in Trash (hidden from feed/sources, off-quota,
# recoverable via undelete, purged by Empty Trash). NULL = live.
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True)
size_bytes = models.BigIntegerField(default=0)
# NULL = not shared. Many rows share NULL (SQL treats each NULL as
# distinct, so `unique=True` still holds); a real value must be unique
@ -77,10 +67,6 @@ class Artifact(models.Model):
def is_shared(self):
return bool(self.share_token)
@property
def is_deleted(self):
return self.deleted_at is not None
class Meta:
ordering = ["-created_at"]
@ -96,23 +82,6 @@ class TagColor(models.Model):
return f"{self.name} ({self.color})"
class Label(models.Model):
"""A user's reusable label in their personal palette. Applying one from the
feed UI just adds its `name` to an artifact's `tags`; the chip colour lives
in the global TagColor table (kept in sync when the palette entry is saved)."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="labels")
name = models.CharField(max_length=40)
color = models.CharField(max_length=20) # #RRGGBB
class Meta:
unique_together = ("user", "name")
ordering = ["name"]
def __str__(self):
return f"{self.name} ({self.color})"
class UserProfile(models.Model):
DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB
@ -123,28 +92,6 @@ class UserProfile(models.Model):
return f"{self.user} quota={self.quota_bytes}"
class ApiKey(models.Model):
"""A named, per-user API key for ingest. A user can hold several — one per
agent or script — and revoke them individually. Stored and shown in
plaintext: this is a self-hosted, single-operator app and keys are
viewable on demand by design.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_keys"
)
name = models.CharField(max_length=100)
key = models.CharField(max_length=64, unique=True, default=generate_api_key, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["created_at"]
def __str__(self):
return f"{self.name} ({self.user})"
class Asset(models.Model):
"""An image uploaded for embedding inside an artifact's content.

View file

@ -7,11 +7,9 @@ SUM rather than a stat of every file.
"""
import shutil
import threading
from datetime import timedelta
from django.conf import settings
from django.db import connection
from django.db.models import F, Sum
from django.utils import timezone
@ -72,13 +70,8 @@ def artifact_total_bytes(artifact):
def user_usage_bytes(user):
# Trashed artifacts are off-quota (deleting relieves quota immediately);
# their files linger on disk until Empty Trash / purge, but don't count.
artifact_bytes = (
Artifact.objects.filter(owner=user, deleted_at__isnull=True)
.aggregate(total=Sum("size_bytes"))
.get("total")
or 0
Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
)
asset_bytes = (
Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
@ -120,10 +113,8 @@ def make_room_for(user, incoming_bytes):
if used + incoming_bytes <= quota:
return []
# Trashed artifacts are eviction-protected (recovery window is guaranteed)
# and already off-quota, so they're never eviction candidates.
unkept = list(
Artifact.objects.filter(owner=user, is_kept=False, deleted_at__isnull=True).order_by("created_at")
Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at")
)
# Check whether eviction can possibly succeed before deleting anything -
# otherwise a doomed request would still destroy every unkept artifact on
@ -146,55 +137,6 @@ def make_room_for(user, incoming_bytes):
return evicted
def purge_trashed(user):
"""Permanently remove all of a user's trashed artifacts (files + rows).
Backs the "Empty Trash" action."""
trashed = list(Artifact.objects.filter(owner=user, deleted_at__isnull=False))
for artifact in trashed:
delete_artifact_files(artifact)
artifact.delete()
return len(trashed)
TRASH_RETENTION = timedelta(days=3)
def purge_expired_trash(now=None):
"""Permanently remove trashed artifacts older than the retention window
(all users). Tolerant of already-gone rows so concurrent purges are safe.
Returns the number purged."""
now = now or timezone.now()
cutoff = now - TRASH_RETENTION
expired = list(Artifact.objects.filter(deleted_at__isnull=False, deleted_at__lt=cutoff))
purged = 0
for artifact in expired:
try:
delete_artifact_files(artifact)
artifact.delete()
purged += 1
except Exception:
# Another concurrent purge got there first, or a file's already
# gone — a purge error must never surface to anyone.
pass
return purged
def spawn_trash_purge():
"""Run purge_expired_trash off the request thread (cron-less: triggered by
deletes). Falls back to inline when async is disabled (tests)."""
if not getattr(settings, "AGENTFEED_ASYNC_PURGE", True):
purge_expired_trash()
return
def _run():
try:
purge_expired_trash()
finally:
connection.close() # threads get their own connection; don't leak it
threading.Thread(target=_run, daemon=True).start()
ASSET_ORPHAN_GRACE = timedelta(hours=24)

View file

@ -1,30 +0,0 @@
from django import template
register = template.Library()
# A small fixed palette. A user's avatar color is picked deterministically from
# their username, so it stays stable across sessions and page loads.
_AVATAR_COLORS = [
"#4a6ea8",
"#3f8f7b",
"#a3712a",
"#8a4f9e",
"#a3312a",
"#2f7d95",
"#6b7280",
"#7b6a3f",
]
@register.filter
def avatar_color(value):
text = str(value or "")
if not text:
return _AVATAR_COLORS[0]
return _AVATAR_COLORS[sum(ord(c) for c in text) % len(_AVATAR_COLORS)]
@register.filter
def avatar_initial(value):
text = str(value or "").strip()
return text[0].upper() if text else "?"

View file

@ -6,30 +6,18 @@ from . import views
urlpatterns = [
path("", views.feed, name="feed"),
path("feed/older", views.feed_older, name="feed-older"),
path("feed/updates", views.feed_updates, name="feed-updates"),
path("a/<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>/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("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
path("assets/<str:asset_id>", views.asset_view, name="asset"),
path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"),
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
path("api/skill", views.SkillView.as_view(), name="skill-api"),
path("api/keys", views.ApiKeyListCreateView.as_view(), name="api-keys"),
path("api/keys/<int:key_id>", views.ApiKeyDetailView.as_view(), name="api-key-detail"),
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>/raw", views.ArtifactRawView.as_view(), name="artifact-raw-api"),
path("settings/", views.settings_home, name="settings-home"),
path("settings/account/", views.settings_account, name="settings-account"),
path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"),
path("settings/labels/", views.settings_labels, name="settings-labels"),
path("settings/users/", views.settings_users, name="settings-users"),
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
path("settings/storage/", views.settings_storage, name="settings-storage"),

View file

@ -15,29 +15,28 @@ import nh3
import openpyxl
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash, views as auth_views
from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.models import User
from django.db import OperationalError, transaction
from django.db.models import Count, Max, Q, Sum
from django.db.models import Count, Q, Sum
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.defaultfilters import date as date_filter, slugify
from django.template.loader import render_to_string
from django.template.defaultfilters import slugify
from django.urls import reverse
from django.utils.dateparse import parse_date, parse_datetime
from django.utils.dateparse import parse_date
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.http import require_POST
from rest_framework.authtoken.models import Token
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from .demo_data import create_demo_artifacts
from .forms import AgentFeedLoginForm
from .models import ApiKey, Artifact, Asset, Label, TagColor
from .forms import DaybookLoginForm
from .models import Artifact, Asset, TagColor
from .storage import (
QuotaExceeded,
SIDECAR_SUFFIXES,
@ -45,8 +44,6 @@ from .storage import (
delete_artifact_files,
disk_free_bytes,
make_room_for,
purge_trashed,
spawn_trash_purge,
sweep_orphan_assets,
total_allocated_bytes,
user_quota_bytes,
@ -70,7 +67,7 @@ REMEMBER_ME_SESSION_AGE = 60 * 60 * 24 * 30 # 30 days
MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB per uploaded image
# Matches the asset id in a AgentFeed asset URL, e.g. /assets/ab12cd34ef56
# Matches the asset id in a Daybook asset URL, e.g. /assets/ab12cd34ef56
ASSET_URL_RE = re.compile(r"/assets/([0-9a-f]{12})")
@ -86,14 +83,12 @@ DEFAULT_THEMES = {
}
@method_decorator(login_required, name="get")
class ArtifactListCreateView(APIView):
parser_classes = [JSONParser, FormParser, MultiPartParser]
def get(self, request):
user = resolve_request_user(request)
if user is None:
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
artifacts = Artifact.objects.filter(owner=user, deleted_at__isnull=True)
artifacts = Artifact.objects.filter(owner=request.user)
if source := request.query_params.get("source"):
artifacts = artifacts.filter(source=source)
if label := request.query_params.get("label"):
@ -229,12 +224,10 @@ def asset_view(request, asset_id):
return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream")
@method_decorator(login_required, name="get")
class ArtifactDetailView(APIView):
def get(self, request, artifact_id):
user = resolve_request_user(request)
if user is None:
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
return Response(serialize_artifact(artifact, request))
def patch(self, request, artifact_id):
@ -242,7 +235,7 @@ class ArtifactDetailView(APIView):
if user is None:
return HttpResponseForbidden("Invalid or missing API token")
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
data = request.data
not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys())
if not_editable:
@ -301,67 +294,21 @@ class ArtifactDetailView(APIView):
if user is None:
return HttpResponseForbidden("Invalid or missing API token")
# Soft-delete: move to Trash (recoverable, off-quota) rather than
# permanently removing. Purged later via Empty Trash. Idempotent.
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
if artifact.deleted_at is None:
artifact.deleted_at = timezone.now()
artifact.save(update_fields=["deleted_at"])
spawn_trash_purge() # sweep expired trash off the request thread
delete_artifact_files(artifact)
artifact.delete()
return Response(status=204)
class ArtifactRawView(APIView):
"""The artifact's full content, for read-back / verification. Same auth as
the detail endpoint (bearer token or session), owner-scoped.
GET /api/artifacts/{id}/raw
- markdown / html_full / json / csv -> exact posted source (text/plain)
- image / pdf / binary -> the stored file bytes
- html_fragment / deck -> 404 (render-only; no raw kept)
"""
def get(self, request, artifact_id):
user = resolve_request_user(request)
if user is None:
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user, deleted_at__isnull=True)
raw_path = raw_artifact_path(artifact)
if raw_path and raw_path.exists():
return FileResponse(raw_path.open("rb"), content_type="text/plain; charset=utf-8")
if artifact.kind in {Artifact.IMAGE, Artifact.PDF, Artifact.BINARY}:
path = safe_artifact_path(artifact.path)
if path.exists():
content_type, _ = mimetypes.guess_type(path.name)
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
return Response(
{"detail": "No raw content available for this kind (render-only)."}, status=404
)
class CapabilitiesView(APIView):
"""Static capability info. Reachable by a logged-in session or a valid API token,
so agents can check connectivity before posting. Exposes no user data."""
def get(self, request):
# Public: capability/contract info only, no user data.
if not request.user.is_authenticated and resolve_token_user(request) is None:
return HttpResponseForbidden("Login or a valid API token is required")
from daybook_project import __version__
methods = {
"/api/artifacts": ["GET", "POST"],
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
"/api/artifacts/{id}/raw": ["GET"],
"/api/assets": ["POST"],
"/api/capabilities": ["GET"],
"/api/skill": ["GET"],
}
if settings.AGENTFEED_KEY_PROVISIONING_API:
methods["/api/keys"] = ["GET", "POST"]
methods["/api/keys/{id}"] = ["DELETE"]
return Response(
{
"version": __version__,
@ -371,121 +318,15 @@ class CapabilitiesView(APIView):
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL, Artifact.JSON, Artifact.CSV],
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
"auth": "bearer_token",
"methods": methods,
"skill_url": request.build_absolute_uri(reverse("skill-api")),
"key_provisioning_api": settings.AGENTFEED_KEY_PROVISIONING_API,
"methods": {
"/api/artifacts": ["GET", "POST"],
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
"/api/assets": ["POST"],
},
}
)
SKILL_MD_PATH = settings.BASE_DIR / "skills" / "agentfeed-posting" / "SKILL.md"
def render_posting_skill(origin):
"""Return the bundled posting SKILL.md with its AGENTFEED_URL placeholder
rewritten to this instance's origin, so text handed to an agent already
points at the right server. Only the config-block assignment is touched;
the surrounding prose (which explains AGENTFEED_URL) is left alone."""
text = SKILL_MD_PATH.read_text(encoding="utf-8")
return re.sub(
r"(?m)^(AGENTFEED_URL=)\S+",
lambda m: m.group(1) + origin,
text,
)
class SkillView(APIView):
"""Serve the bundled agent-posting skill (skills/agentfeed-posting/SKILL.md),
with AGENTFEED_URL pre-filled to this instance so a recipient posts to the
right server. Same auth as capabilities: a logged-in user can copy it, or
an agent can fetch it with its API token.
GET /api/skill -> raw Markdown (text/markdown)
GET /api/skill?format=json -> {"version", "url", "skill"}
"""
def get(self, request):
# Public: the skill is bootstrapping instructions (how to authenticate
# and post) and contains no secrets — an agent shouldn't need a key to
# learn how to use one.
origin = request.build_absolute_uri("/").rstrip("/")
try:
skill = render_posting_skill(origin)
except FileNotFoundError:
raise Http404("Posting skill is not bundled with this deployment")
if request.GET.get("format") == "json":
from daybook_project import __version__
return Response({"version": __version__, "url": origin, "skill": skill})
return HttpResponse(skill, content_type="text/markdown; charset=utf-8")
def serialize_api_key(api_key):
return {
"id": api_key.pk,
"name": api_key.name,
"key": api_key.key,
"created_at": api_key.created_at.isoformat(),
"last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None,
}
class ApiKeyListCreateView(APIView):
"""Programmatic API-key management, for automated test installations that
provision their own agents. Gated by settings.AGENTFEED_KEY_PROVISIONING_API,
which is OFF in deployment (it hands out key material over the API). When
off, every method returns 403. Authenticated by session or an existing
bearer token, and only ever operates on that user's own keys.
GET /api/keys -> list this user's keys (incl. key values)
POST /api/keys -> create {"name": ...} -> the new key
"""
def _resolve_user(self, request):
if request.user.is_authenticated:
return request.user
return resolve_token_user(request)
def get(self, request):
if not settings.AGENTFEED_KEY_PROVISIONING_API:
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
user = self._resolve_user(request)
if user is None:
return HttpResponseForbidden("Login or a valid API token is required")
return Response([serialize_api_key(k) for k in user.api_keys.all()])
def post(self, request):
if not settings.AGENTFEED_KEY_PROVISIONING_API:
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
user = self._resolve_user(request)
if user is None:
return HttpResponseForbidden("Login or a valid API token is required")
name = (request.data.get("name") or "").strip()
if not name:
return Response({"detail": "A non-empty 'name' is required."}, status=400)
api_key = ApiKey.objects.create(user=user, name=name)
return Response(serialize_api_key(api_key), status=201)
class ApiKeyDetailView(APIView):
"""Revoke a single key by id, same gating and auth as the list/create view.
DELETE /api/keys/{id}
"""
def delete(self, request, key_id):
if not settings.AGENTFEED_KEY_PROVISIONING_API:
return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403)
user = request.user if request.user.is_authenticated else resolve_token_user(request)
if user is None:
return HttpResponseForbidden("Login or a valid API token is required")
deleted, _ = ApiKey.objects.filter(pk=key_id, user=user).delete()
if not deleted:
raise Http404("No such key")
return Response(status=204)
FEED_WINDOW_DAYS = 7
@ -523,40 +364,21 @@ def feed(request):
selected_artifact_id = request.GET.get("artifact", "")
preview_none = request.GET.get("preview") == "none"
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
selected_folder = request.GET.get("folder", "")
if selected_folder not in ("deleted", "keep"):
selected_folder = ""
# Live (non-trashed) artifacts drive the normal feed and the source sidebar.
live = Artifact.objects.filter(owner=request.user, deleted_at__isnull=True)
if selected_folder == "deleted":
# System folder: all trashed items, not source-scoped.
selected_source = ""
base = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False)
elif selected_folder == "keep":
selected_source = ""
base = live.filter(is_kept=True)
else:
base = live
all_artifacts = Artifact.objects.filter(owner=request.user)
artifacts = apply_feed_filters(
base, selected_source, selected_label, selected_kind, title_query
all_artifacts, selected_source, selected_label, selected_kind, title_query
)
if selected_folder:
# System folders show everything (no 7-day window / infinite scroll).
window_start = today
window_artifacts = list(artifacts.order_by("-created_at"))
older_count = 0
else:
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
if not artifacts.filter(day__gte=window_start).exists():
# Nothing recent for these filters: fall back to the most recent days
# that do match, so selecting a source is never met with a blank list.
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
if recent_days:
window_start = recent_days[-1]
window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at"))
older_count = artifacts.filter(day__lt=window_start).count()
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
if not artifacts.filter(day__gte=window_start).exists():
# Nothing recent for these filters: fall back to the most recent days that
# do match, so selecting a source is never met with a blank list.
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
if recent_days:
window_start = recent_days[-1]
window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at"))
older_count = artifacts.filter(day__lt=window_start).count()
overrides = tag_color_overrides()
day_groups = group_artifacts_by_day(window_artifacts, overrides)
@ -566,8 +388,6 @@ def feed(request):
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
toggle_query = {"view": "rendered"}
if selected_folder:
toggle_query["folder"] = selected_folder
if selected_source:
toggle_query["source"] = selected_source
if selected_label:
@ -582,22 +402,18 @@ def feed(request):
toggle_query["view"] = "raw"
raw_url = f"?{urlencode(toggle_query)}"
# Source counts + labels always come from live artifacts, so the sidebar is
# the same whichever folder you're viewing.
# Source counts come from the other filters only, so the sidebar still shows
# every source available under the current label/type/name filters.
source_scoped = apply_feed_filters(
live, "", selected_label, selected_kind, title_query
all_artifacts, "", selected_label, selected_kind, title_query
)
# Sources ordered by most recent message first (Telegram-style); "All
# sources" is rendered separately and stays pinned at the top.
source_counts = (
source_scoped.values("source")
.annotate(count=Count("id", filter=Q(read_at__isnull=True)), latest=Max("created_at"))
.order_by("-latest")
.annotate(count=Count("id", filter=Q(read_at__isnull=True)))
.order_by("source")
)
keep_count = live.filter(is_kept=True).count()
trash_count = Artifact.objects.filter(owner=request.user, deleted_at__isnull=False).count()
all_labels = sorted({tag for tags in live.values_list("tags", flat=True) for tag in tags})
all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags})
return render(
request,
"artifacts/feed.html",
@ -606,14 +422,8 @@ def feed(request):
"day_groups": day_groups,
"older_count": older_count,
"window_start": window_start,
# Marker for live polling: newest artifact shown, else now.
"feed_since": (window_artifacts[0].created_at if window_artifacts else timezone.now()).isoformat(),
"source_counts": source_counts,
"total_count": source_scoped.filter(read_at__isnull=True).count(),
"keep_count": keep_count,
"trash_count": trash_count,
"selected_folder": selected_folder,
"in_trash": selected_folder == "deleted",
"all_labels": labels_with_colors_list(all_labels, overrides),
"raw_url": raw_url,
"rendered_url": rendered_url,
@ -626,7 +436,6 @@ def feed(request):
"today": today,
"title_query": title_query,
"kind_choices": Artifact.KIND_CHOICES,
"label_palette": request.user.labels.all(),
},
)
@ -641,31 +450,6 @@ def mark_artifact_read(request, artifact_id):
return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()})
@login_required
@require_POST
def toggle_artifact_label(request, artifact_id):
"""Toggle a palette label on/off for one of the user's own messages. The
label name is written into/removed from the artifact's `tags` list (labels
are unified with tags). Only names in the user's palette are accepted."""
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user, deleted_at__isnull=True)
name = (request.POST.get("name") or "").strip()
label = Label.objects.filter(user=request.user, name=name).first()
if label is None:
return JsonResponse({"error": "No such label in your palette."}, status=400)
tags = list(artifact.tags)
if name in tags:
tags.remove(name)
applied = False
else:
tags.append(name)
applied = True
artifact.tags = tags
artifact.save(update_fields=["tags"])
# Keep the chip colour in sync with the palette (TagColor is global).
TagColor.objects.update_or_create(name=name, defaults={"color": label.color})
return JsonResponse({"applied": applied, "name": name, "color": label.color, "tags": tags})
@login_required
@require_POST
def toggle_artifact_keep(request, artifact_id):
@ -692,58 +476,12 @@ def toggle_artifact_share(request, artifact_id):
return JsonResponse({"id": artifact.id, "is_shared": artifact.is_shared, "share_url": share_url})
@login_required
@require_POST
def delete_artifact(request, artifact_id):
"""Soft-delete: move to Trash (recoverable, off-quota). Idempotent."""
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
if artifact.deleted_at is None:
artifact.deleted_at = timezone.now()
artifact.save(update_fields=["deleted_at"])
spawn_trash_purge() # sweep expired trash off the request thread
return JsonResponse({"id": artifact.id, "deleted": True})
@login_required
@require_POST
def undelete_artifact(request, artifact_id):
"""Restore a trashed artifact back to the live feed."""
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
if artifact.deleted_at is not None:
artifact.deleted_at = None
artifact.save(update_fields=["deleted_at"])
return JsonResponse({"id": artifact.id, "deleted": False})
@login_required
@require_POST
def empty_trash(request):
"""Permanently purge all of the user's trashed artifacts."""
count = purge_trashed(request.user)
return JsonResponse({"purged": count})
@login_required
@require_POST
def mark_all_read(request):
"""Mark all of the user's unread (non-trashed) messages as read, optionally
scoped to one source. Backs the sidebar 'Mark all as read' action."""
unread = Artifact.objects.filter(
owner=request.user, deleted_at__isnull=True, read_at__isnull=True
)
source = request.POST.get("source", "")
if source:
unread = unread.filter(source=source)
marked = unread.update(read_at=timezone.now())
return JsonResponse({"marked": marked, "source": source})
@login_required
def feed_older(request):
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
before = parse_date(request.GET.get("before", "")) or timezone.localdate()
artifacts = apply_feed_filters(
Artifact.objects.filter(owner=request.user, deleted_at__isnull=True),
Artifact.objects.filter(owner=request.user),
request.GET.get("source", ""),
request.GET.get("label", ""),
request.GET.get("kind", ""),
@ -777,58 +515,6 @@ def feed_older(request):
)
@login_required
def feed_updates(request):
"""New artifacts since a timestamp, as row HTML for the feed to prepend live.
Owner-scoped and filter-aware — respects the source/label/kind/q the browser
is currently viewing, so a new post that doesn't match won't appear."""
after = parse_datetime(request.GET.get("after", "") or "")
if after is None:
return JsonResponse({"count": 0})
selected_source = request.GET.get("source", "")
selected_label = request.GET.get("label", "")
selected_kind = request.GET.get("kind", "")
title_query = request.GET.get("q", "")
artifacts = apply_feed_filters(
Artifact.objects.filter(owner=request.user, deleted_at__isnull=True),
selected_source,
selected_label,
selected_kind,
title_query,
).filter(created_at__gt=after).order_by("-created_at")
new = list(artifacts[:100])
if not new:
return JsonResponse({"count": 0})
overrides = tag_color_overrides()
for artifact in new:
artifact.label_chips = tags_with_colors(artifact.tags, overrides)
html = render_to_string(
"artifacts/_artifact_rows.html",
{
"artifacts": new,
"selected_artifact": None,
"selected_source": selected_source,
"selected_label": selected_label,
"selected_kind": selected_kind,
"title_query": title_query,
"selected_view": "raw" if request.GET.get("view") == "raw" else "rendered",
},
request=request,
)
return JsonResponse(
{
"count": len(new),
"newest": new[0].created_at.isoformat(),
"today_label": date_filter(timezone.localdate(), "D, M j Y"),
"html": html,
}
)
@login_required
@xframe_options_sameorigin
def artifact_view(request, artifact_id):
@ -1056,7 +742,7 @@ def create_binary_artifact(data, request, user):
# Allowlist for sanitizing the "themed" text kinds (markdown, deck,
# html_fragment) — kinds where AgentFeed, not the poster, owns presentation, so
# html_fragment) — kinds where Daybook, not the poster, owns presentation, so
# they must never be a script-execution vector (issue #014). html_full is the
# deliberate exception (verbatim, poster owns everything) and is NOT run
# through this. The list keeps ordinary formatting + tables + code-highlight
@ -1161,14 +847,14 @@ def wrap_json_viewer(content, title, request):
</style>
</head>
<body class="artifact">
<script type="application/json" id="agentfeed-data">{safe_json}</script>
<div id="agentfeed-json-view"></div>
<script type="application/json" id="daybook-data">{safe_json}</script>
<div id="daybook-json-view"></div>
<script>
(function () {{
var raw = document.getElementById("agentfeed-data").textContent;
var raw = document.getElementById("daybook-data").textContent;
var data = JSON.parse(raw);
renderjson.set_show_to_level(2);
document.getElementById("agentfeed-json-view").appendChild(renderjson(data));
document.getElementById("daybook-json-view").appendChild(renderjson(data));
}})();
</script>
</body>
@ -1205,18 +891,18 @@ def wrap_csv_viewer(content, title, request):
</style>
</head>
<body class="artifact">
<script type="application/json" id="agentfeed-data">{table_json}</script>
<div id="agentfeed-csv-view"></div>
<script type="application/json" id="daybook-data">{table_json}</script>
<div id="daybook-csv-view"></div>
<script>
(function () {{
var table = JSON.parse(document.getElementById("agentfeed-data").textContent);
var table = JSON.parse(document.getElementById("daybook-data").textContent);
new gridjs.Grid({{
columns: table.columns,
data: table.data,
search: true,
sort: true,
pagination: {{ limit: 20 }},
}}).render(document.getElementById("agentfeed-csv-view"));
}}).render(document.getElementById("daybook-csv-view"));
}})();
</script>
</body>
@ -1260,24 +946,9 @@ def resolve_token_user(request):
if not key:
return None
try:
api_key = ApiKey.objects.select_related("user").get(key=key)
except ApiKey.DoesNotExist:
return Token.objects.select_related("user").get(key=key).user
except Token.DoesNotExist:
return None
# Stamp last-used so stale keys are visible in the UI, but throttle the
# write to at most once a minute to avoid a DB write on every request.
now = timezone.now()
if api_key.last_used_at is None or (now - api_key.last_used_at).total_seconds() > 60:
ApiKey.objects.filter(pk=api_key.pk).update(last_used_at=now)
return api_key.user
def resolve_request_user(request):
"""The acting user from either a browser session or a bearer API key.
Used by the JSON read endpoints so agents can read back with the same token
they post with (owner-scoped). None if neither authenticates."""
if request.user.is_authenticated:
return request.user
return resolve_token_user(request)
def normalize_tags(tags):
@ -1469,7 +1140,6 @@ def serialize_artifact(artifact, request):
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
"api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])),
"raw_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"),
"api_raw_url": request.build_absolute_uri(reverse("artifact-raw-api", args=[artifact.id])),
"raw_available": raw_artifact_path(artifact) is not None,
"source": artifact.source,
"title": artifact.title,
@ -1514,9 +1184,9 @@ def raw_artifact_path(artifact):
return None
class AgentFeedLoginView(auth_views.LoginView):
class DaybookLoginView(auth_views.LoginView):
template_name = "artifacts/login.html"
authentication_form = AgentFeedLoginForm
authentication_form = DaybookLoginForm
def form_valid(self, form):
response = super().form_valid(form)
@ -1534,94 +1204,16 @@ def settings_home(request):
@login_required
def settings_account(request):
if request.method == "POST":
password_form = PasswordChangeForm(request.user, request.POST)
if password_form.is_valid():
user = password_form.save()
# Keep the current session valid — changing the password rotates the
# session auth hash, which would otherwise log the user out.
update_session_auth_hash(request, user)
messages.success(request, "Password changed.")
return redirect("settings-account")
else:
password_form = PasswordChangeForm(request.user)
token, _ = Token.objects.get_or_create(user=request.user)
if request.method == "POST" and request.POST.get("action") == "regenerate":
token.delete()
token = Token.objects.create(user=request.user)
messages.success(request, "API token regenerated.")
return redirect("settings-account")
return render(
request,
"artifacts/settings_account.html",
{
"active_section": "account",
"key_count": request.user.api_keys.count(),
"password_form": password_form,
},
)
@login_required
def settings_api_keys(request):
if request.method == "POST":
action = request.POST.get("action")
if action == "create":
name = (request.POST.get("name") or "").strip()
if not name:
messages.error(request, "Give the key a name so you know which agent it's for.")
else:
ApiKey.objects.create(user=request.user, name=name)
messages.success(request, f"Created API key '{name}'.")
elif action == "revoke":
key_id = request.POST.get("key_id") or ""
deleted, _ = ApiKey.objects.filter(pk=key_id, user=request.user).delete()
if deleted:
messages.success(request, "API key revoked.")
else:
messages.error(request, "No such key.")
return redirect("settings-api-keys")
return render(
request,
"artifacts/settings_api_keys.html",
{"active_section": "api-keys", "api_keys": request.user.api_keys.all()},
)
LABEL_HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
@login_required
def settings_labels(request):
if request.method == "POST":
action = request.POST.get("action")
if action == "create":
name = (request.POST.get("name") or "").strip()
color = (request.POST.get("color") or "").strip()
if not name:
messages.error(request, "Give the label a name.")
elif not LABEL_HEX_RE.match(color):
messages.error(request, "Pick a colour (#RRGGBB).")
elif Label.objects.filter(user=request.user, name=name).exists():
messages.error(request, f"You already have a label called '{name}'.")
else:
Label.objects.create(user=request.user, name=name, color=color)
TagColor.objects.update_or_create(name=name, defaults={"color": color})
messages.success(request, f"Created label '{name}'.")
elif action == "update":
color = (request.POST.get("color") or "").strip()
label = Label.objects.filter(pk=request.POST.get("label_id") or "", user=request.user).first()
if label is None:
messages.error(request, "No such label.")
elif not LABEL_HEX_RE.match(color):
messages.error(request, "Pick a colour (#RRGGBB).")
else:
label.color = color
label.save(update_fields=["color"])
TagColor.objects.update_or_create(name=label.name, defaults={"color": color})
messages.success(request, f"Updated '{label.name}'.")
elif action == "delete":
deleted, _ = Label.objects.filter(pk=request.POST.get("label_id") or "", user=request.user).delete()
messages.success(request, "Label removed from your palette.") if deleted else messages.error(request, "No such label.")
return redirect("settings-labels")
return render(
request,
"artifacts/settings_labels.html",
{"active_section": "labels", "labels": request.user.labels.all()},
{"token": token, "active_section": "account"},
)
@ -1631,7 +1223,7 @@ def settings_users(request):
return HttpResponseForbidden("Admin access required")
created_user = None
created_key = None
created_token = None
if request.method == "POST":
username = request.POST.get("username", "").strip()
password = request.POST.get("password", "")
@ -1645,7 +1237,7 @@ def settings_users(request):
if grant_admin:
created_user.is_superuser = True
created_user.save(update_fields=["is_superuser"])
created_key = ApiKey.objects.create(user=created_user, name="default")
created_token = Token.objects.create(user=created_user)
messages.success(request, f"Created user '{username}'.")
return render(
@ -1655,7 +1247,7 @@ def settings_users(request):
"active_section": "users",
"users": User.objects.order_by("username"),
"created_user": created_user,
"created_key": created_key,
"created_token": created_token,
},
)
@ -1725,7 +1317,7 @@ def raw_unavailable_html(artifact):
<body>
<main>
<h1>Raw source unavailable</h1>
<p>AgentFeed does not have preserved raw source for <strong>{html.escape(artifact.title)}</strong>.</p>
<p>Daybook does not have preserved raw source for <strong>{html.escape(artifact.title)}</strong>.</p>
</main>
</body>
</html>

View file

@ -1,4 +1,4 @@
# Single source of truth for the app version. Bump this, commit, then tag the
# commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in
# GET /api/capabilities ("version").
__version__ = "0.7.1"
__version__ = "0.1.0"

View file

@ -3,26 +3,12 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = Path(os.environ.get("AGENTFEED_DATA_DIR", BASE_DIR)).resolve()
DATA_DIR = Path(os.environ.get("DAYBOOK_DATA_DIR", BASE_DIR)).resolve()
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me")
DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"}
ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()]
# Allow creating/listing/revoking API keys over the ingest API (POST /api/keys).
# Meant for automated test installations that provision their own agents. It
# exposes key material over the API, so it is DISABLED BY DEFAULT and must be
# turned on explicitly — set AGENTFEED_KEY_PROVISIONING_API=1 only on a throwaway
# test install. Never enable it in a real deployment. (Independent of DEBUG, so
# a misconfigured DEBUG=1 production box still won't expose it.)
AGENTFEED_KEY_PROVISIONING_API = os.environ.get(
"AGENTFEED_KEY_PROVISIONING_API", ""
).lower() in {"1", "true", "yes", "on"}
# Purge expired Trash off the request thread (triggered by deletes, no cron).
# Tests override this to run inline so purge behavior is deterministic.
AGENTFEED_ASYNC_PURGE = True
# Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.):
# Django otherwise thinks every request is plain HTTP, which breaks CSRF checks
# on login/POST. The proxy must forward X-Forwarded-Proto: https.
@ -74,7 +60,7 @@ WSGI_APPLICATION = "daybook_project.wsgi.application"
REST_FRAMEWORK = {
# Maps deeply-nested-JSON RecursionErrors (raised in DRF's parser, before
# view validation) to a 400 instead of an unhandled 500. See issue #015.
"EXCEPTION_HANDLER": "artifacts.exception_handlers.agentfeed_exception_handler",
"EXCEPTION_HANDLER": "artifacts.exception_handlers.daybook_exception_handler",
}
DATABASES = {

View file

@ -2,12 +2,12 @@ from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path
from artifacts.views import AgentFeedLoginView
from artifacts.views import DaybookLoginView
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/login/", AgentFeedLoginView.as_view(), name="login"),
path("accounts/login/", DaybookLoginView.as_view(), name="login"),
path("accounts/logout/", auth_views.LogoutView.as_view(next_page="login"), name="logout"),
path("", include("artifacts.urls")),
]

View file

@ -1,7 +1,7 @@
# Cloud Deployment Runbook For A Dedicated Deploy Agent
This is written for a capable (Opus-class) agent deploying and operating
AgentFeed on a cloud VM. Treat it as a guide and a source of AgentFeed-specific
Daybook on a cloud VM. Treat it as a guide and a source of Daybook-specific
facts, **not a rigid script** — you're expected to use judgment, adapt to
the actual VM, and read the authoritative sources rather than follow steps
blindly. The steps below are a sensible default path; deviate where the host
@ -25,7 +25,7 @@ disagree):
Where a decision needs info you don't have (VM IP, domain, whether it's
internet-facing, whether a reverse proxy/secondary volume exists), ask the
operator once, then proceed autonomously for routine redeploys. The rest of
this file is the recommended path plus the non-obvious, AgentFeed-specific
this file is the recommended path plus the non-obvious, Daybook-specific
gotchas (SSH port, CSRF-behind-proxy, bind address, data volume, healthcheck)
that are easy to get wrong.
@ -37,9 +37,9 @@ a token tied to a full user account.
1. Generate an SSH key pair on the VM (or ask the operator for one to install):
```bash
ssh-keygen -t ed25519 -C "agentfeed-deploy-agent" -f ~/.ssh/agentfeed_deploy -N ""
ssh-keygen -t ed25519 -C "daybook-deploy-agent" -f ~/.ssh/daybook_deploy -N ""
```
2. Ask the operator to add the **public** key (`~/.ssh/agentfeed_deploy.pub`) as
2. Ask the operator to add the **public** key (`~/.ssh/daybook_deploy.pub`) as
a Deploy Key on the repo in Forgejo (repo → Settings → Deploy Keys →
read-only is sufficient), or hand it to them directly — never ask them to
give you their personal account credentials.
@ -52,7 +52,7 @@ a token tied to a full user account.
cat >> ~/.ssh/config <<'EOF'
Host git.cygnus.systems
Port 2222
IdentityFile ~/.ssh/agentfeed_deploy
IdentityFile ~/.ssh/daybook_deploy
IdentitiesOnly yes
EOF
```
@ -112,12 +112,12 @@ Edit `deploy/.env`:
DJANGO_DEBUG=0
DJANGO_SECRET_KEY=<generated-secret>
DJANGO_ALLOWED_HOSTS=<vm-public-ip>,<domain-if-any>,localhost,127.0.0.1
AGENTFEED_DATA_DIR=/data
DAYBOOK_DATA_DIR=/data
DATABASE_PATH=/data/db.sqlite3
ARTIFACTS_ROOT=/data/artifacts_store
```
There is no shared API token to set — AgentFeed issues a personal token per
There is no shared API token to set — Daybook issues a personal token per
user from Settings → Account once a user exists (step 5). Never commit
`deploy/.env`; it is already gitignored.
@ -131,17 +131,17 @@ if unsure which applies here.
## 4. Network Access
**If this VM is reachable from the public internet, a reverse proxy with TLS
in front of AgentFeed is not optional — it's the only path.** Bearer tokens are
in front of Daybook is not optional — it's the only path.** Bearer tokens are
this app's entire write-auth model, and they'd otherwise go over plaintext
HTTP straight to a port on the public IP. Direct port exposure (below) is
only for private/VPN-only networks with no public exposure.
- **Internet-facing (the common case):** set `AGENTFEED_BIND_ADDR=127.0.0.1` in
- **Internet-facing (the common case):** set `DAYBOOK_BIND_ADDR=127.0.0.1` in
`deploy/.env` so the container port is not reachable from outside the VM at
all, then open `80`/`443` in the VM's firewall/security group for the
reverse proxy (see §8). Do not open `8000` externally.
- **Private/VPN-only network:** open TCP `8000` to the addresses that should
reach it (never `0.0.0.0/0`), and leave `AGENTFEED_BIND_ADDR` unset.
reach it (never `0.0.0.0/0`), and leave `DAYBOOK_BIND_ADDR` unset.
Before opening any port, check whether one is already open and in use —
`ss -tlnp | grep -E ':80|:443|:8000'` — rather than assuming a clean slate.
@ -153,8 +153,8 @@ don't have API access to it yourself.
## 5. Build, Start, Create A User
If the target has a separate, larger data volume than its main disk (common
on space-constrained VMs), point AgentFeed's persistent data at it before
starting: set `AGENTFEED_HOST_DATA_DIR=/path/on/that/volume` in `deploy/.env`.
on space-constrained VMs), point Daybook's persistent data at it before
starting: set `DAYBOOK_HOST_DATA_DIR=/path/on/that/volume` in `deploy/.env`.
Ask the operator whether such a volume exists and where before defaulting to
the main disk — filling the main disk on a host running other services is a
bigger problem than it would be on a dedicated box. Create that same path
@ -162,9 +162,9 @@ bigger problem than it would be on a dedicated box. Create that same path
unset) before starting the stack:
```bash
mkdir -p deploy/data # or the AGENTFEED_HOST_DATA_DIR path if you set one above
mkdir -p deploy/data # or the DAYBOOK_HOST_DATA_DIR path if you set one above
docker compose -f deploy/docker-compose.yml up -d --build
docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py createsuperuser
docker compose -f deploy/docker-compose.yml exec daybook python manage.py createsuperuser
```
The entrypoint runs migrations and collects static files automatically on
@ -183,7 +183,7 @@ response when unauthenticated — it confirms the app is up. (Don't use
container's own healthcheck hits the login page instead, which is 200
without auth.) This `curl` runs
against `127.0.0.1` from on the VM itself, so it works regardless of whether
`AGENTFEED_BIND_ADDR` is set. Log in from a browser at whichever address is
`DAYBOOK_BIND_ADDR` is set. Log in from a browser at whichever address is
actually externally reachable at this point in the runbook — `http://<vm-ip>:8000/`
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
@ -202,52 +202,30 @@ Confirm the response includes `id`, `url`, and `api_url`, and that opening
## 7. Redeploy Loop (routine updates)
Releases are tagged `vX.Y.Z` (SemVer). Deploy a specific tag rather than a
moving branch, so what's live is always a known, named version — and note
the currently-deployed one before switching, for a clean rollback target:
This is the command you run every time you're asked to ship a new version:
```bash
cd /opt/daybook
PREV_TAG=$(git describe --tags --exact-match 2>/dev/null || git rev-parse HEAD) # what's live now
git fetch --tags
git checkout v0.1.0 # the tag you were told to deploy (latest: git tag -l | sort -V | tail -1)
PREV_SHA=$(git rev-parse HEAD)
git pull
docker compose -f deploy/docker-compose.yml up -d --build
```
Then repeat the verification in step 6 (and confirm `/api/capabilities`
reports the `version` you expect). If it fails, roll back to the previous
version and report the failure (build log / migration error / failed health
check) rather than retrying blindly:
Then repeat the verification in step 6 (capabilities check at minimum). If it
fails:
```bash
git checkout "$PREV_TAG"
git checkout "$PREV_SHA"
docker compose -f deploy/docker-compose.yml up -d --build
```
Caveat: rollback reverts code, not data — a release that already applied a
DB migration may not cleanly downgrade against the migrated `db.sqlite3`.
For a routine forward release that's fine; if a rollback is needed after a
migration, restore the pre-deploy `deploy/data` backup (see §9) alongside
the code checkout.
to roll back, then report the failure (build log / migration error / failed
health check) back to the operator rather than retrying blindly.
`deploy/.env` and the data directory (wherever `AGENTFEED_HOST_DATA_DIR` points,
`deploy/.env` and the data directory (wherever `DAYBOOK_HOST_DATA_DIR` points,
or `deploy/data` by default) are untracked, so `git pull` never touches them —
no need to back them up before a routine redeploy.
**If the container crash-loops right after a build** (`docker compose ps`
shows `restarting`, health never goes green), the most likely cause is the
entrypoint's `collectstatic` step failing — check `docker logs agentfeed`
before assuming an env/network problem. A `whitenoise ... MissingFileError`
there means a static asset references a file that isn't present (this is what
bit v0.1.0). No data or schema impact; fix the asset in the repo, tag, and
redeploy.
**If `git checkout <tag>` refuses because of local working-tree changes** to
tracked files (e.g. a previous on-server hotfix), reset those tracked files
first — `git checkout -- <file>` for a specific one, or `git stash` /
`git reset --hard` to clear all tracked drift. This never touches `deploy/.env`
or the data directory, which are untracked and therefore safe.
## 8. Hardening For Internet-Facing Deployments
If this VM is reachable from the public internet (not just a private
@ -277,14 +255,14 @@ instead. Apache example:
```apache
<VirtualHost *:443>
ServerName agentfeed.example.com
ServerName daybook.example.com
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
ProxyPass / http://127.0.0.1:8000/
ProxyPassReverse / http://127.0.0.1:8000/
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/agentfeed.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/agentfeed.example.com/privkey.pem
SSLCertificateFile /etc/letsencrypt/live/daybook.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/daybook.example.com/privkey.pem
</VirtualHost>
```
@ -296,7 +274,7 @@ Only reach for a standalone proxy like Caddy if the target genuinely has
nothing on 80/443 yet — it still needs the same header:
```text
agentfeed.example.com {
daybook.example.com {
reverse_proxy 127.0.0.1:8000 {
header_up X-Forwarded-Proto https
}
@ -305,15 +283,15 @@ agentfeed.example.com {
## 9. Backup
If you set `AGENTFEED_HOST_DATA_DIR` in step 5, the real data lives there, not
If you set `DAYBOOK_HOST_DATA_DIR` in step 5, the real data lives there, not
at `deploy/data` — back up whichever path is actually configured, read
straight from `deploy/.env` rather than assumed, so this keeps working
whether or not that override is set:
```bash
cd /opt/daybook
DATA_DIR="$(grep -oP '(?<=^AGENTFEED_HOST_DATA_DIR=).*' deploy/.env || echo deploy/data)"
tar -czf "/tmp/agentfeed-data-$(date +%Y%m%d-%H%M%S).tar.gz" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")"
DATA_DIR="$(grep -oP '(?<=^DAYBOOK_HOST_DATA_DIR=).*' deploy/.env || echo deploy/data)"
tar -czf "/tmp/daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")"
```
Copy the resulting archive off the VM (to object storage, the operator's
@ -331,12 +309,12 @@ Before reporting the deployment complete, confirm all of these:
public HTTPS origin, and the proxy forwards `X-Forwarded-Proto: https`.
Verify by actually logging in through the proxy, not just curling
`/api/capabilities` — CSRF only bites on POST (login, artifact writes).
- [ ] `docker compose -f deploy/docker-compose.yml ps` shows `agentfeed` healthy.
- [ ] `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy.
- [ ] `/api/capabilities` returns `403` unauthenticated, `200` with a token.
- [ ] At least one superuser exists with a working personal API token.
- [ ] A smoke-test artifact posts and renders successfully.
- [ ] If internet-facing: TLS is in front of the app, not raw HTTP on `8000`.
- [ ] A backup of the data directory (`deploy/data`, or `AGENTFEED_HOST_DATA_DIR`
- [ ] A backup of the data directory (`deploy/data`, or `DAYBOOK_HOST_DATA_DIR`
if you set it) has been taken and copied off the VM.
- [ ] If the target host has its own service-inventory, monitoring, or
changelog conventions for new services, they've been followed — ask

View file

@ -1,4 +1,4 @@
# ${VAR} substitution below (AGENTFEED_BIND_ADDR, AGENTFEED_HOST_DATA_DIR) reads
# ${VAR} substitution below (DAYBOOK_BIND_ADDR, DAYBOOK_HOST_DATA_DIR) reads
# from deploy/.env because Compose resolves .env relative to *this file's*
# directory, not the caller's cwd. That relies on being invoked as
# `docker compose -f deploy/docker-compose.yml` from the repo root, per
@ -7,20 +7,20 @@
# the defaults (0.0.0.0 bind, ./data) would be a security regression, not
# just a broken build.
services:
agentfeed:
daybook:
build:
context: ..
dockerfile: deploy/Dockerfile
container_name: agentfeed
container_name: daybook
restart: unless-stopped
env_file:
- .env
mem_limit: 384m
mem_reservation: 128m
ports:
- "${AGENTFEED_BIND_ADDR:-0.0.0.0}:8000:8000"
- "${DAYBOOK_BIND_ADDR:-0.0.0.0}:8000:8000"
volumes:
- ${AGENTFEED_HOST_DATA_DIR:-./data}:/data
- ${DAYBOOK_HOST_DATA_DIR:-./data}:/data
healthcheck:
# Hit the login page, not /api/capabilities: capabilities returns 403
# when unauthenticated, and `curl --fail` treats 403 as an error, which

View file

@ -1,7 +1,7 @@
#!/usr/bin/env sh
set -eu
mkdir -p "${AGENTFEED_DATA_DIR:-/data}" "${ARTIFACTS_ROOT:-/data/artifacts_store}"
mkdir -p "${DAYBOOK_DATA_DIR:-/data}" "${ARTIFACTS_ROOT:-/data/artifacts_store}"
python manage.py migrate --noinput
python manage.py collectstatic --noinput

View file

@ -1,26 +1,21 @@
DJANGO_DEBUG=0
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-vm-ip,your-domain.example
# Required if AgentFeed sits behind a TLS-terminating reverse proxy (Apache,
# Required if Daybook sits behind a TLS-terminating reverse proxy (Apache,
# Nginx, Traefik, a cloud load balancer). Without it, login/POST fails with
# "CSRF verification failed" because Django can't tell the original request
# was HTTPS. Set to the public HTTPS origin(s) users actually visit:
DJANGO_CSRF_TRUSTED_ORIGINS=https://your-domain.example
# Disabled by default. When on, POST /api/keys can create/list/revoke API keys
# over the network (it returns key material), which is only appropriate for
# automated test installations. Leave unset in deployment; set to 1 only on a
# throwaway test box.
# AGENTFEED_KEY_PROVISIONING_API=0
AGENTFEED_DATA_DIR=/data
DAYBOOK_DATA_DIR=/data
DATABASE_PATH=/data/db.sqlite3
ARTIFACTS_ROOT=/data/artifacts_store
# Optional, read by deploy/docker-compose.yml directly (not by the app):
# AGENTFEED_BIND_ADDR=127.0.0.1
# DAYBOOK_BIND_ADDR=127.0.0.1
# Uncomment and set to 127.0.0.1 when a reverse proxy on the same host will
# front this service — this stops the container port from being reachable
# directly from the network. Leave unset (binds all interfaces) only for
# private/VPN-only deployments with no proxy in front.
# AGENTFEED_HOST_DATA_DIR=/mnt/some-larger-volume/agentfeed-data
# DAYBOOK_HOST_DATA_DIR=/mnt/some-larger-volume/daybook-data
# Uncomment and point at a path on a separate/larger volume if the host's
# main disk is space-constrained. Defaults to ./data next to this file.

View file

@ -1,12 +1,11 @@
# Agent Quickstart
This is the shortest path for an agent to publish and retrieve artifacts from AgentFeed.
This is the shortest path for an agent to publish and retrieve artifacts from Daybook.
## 0. Get Your API Key
## 0. Get Your API Token
Log in to the app in a browser and create a personal key under **Settings → API Keys**
(`http://127.0.0.1:8001/settings/api-keys/`). Name it after the agent so you can revoke it
later. Every artifact you post with it lands in your
Log in to the app in a browser and copy your personal token from **Settings → Account**
(`http://127.0.0.1:8001/settings/account/`). Every artifact you post with it lands in your
feed, and only yours.
## 1. Check Capabilities
@ -15,7 +14,7 @@ Also confirms your token is valid:
```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" `
-Headers @{ Authorization = "Bearer <your-personal-key>" }
-Headers @{ Authorization = "Bearer <your-personal-token>" }
```
## 2. Publish Markdown
@ -39,7 +38,7 @@ $body = @{
$artifact = Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post `
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
@ -69,7 +68,7 @@ $body = @{
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post `
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
```
@ -78,7 +77,7 @@ Invoke-RestMethod `
```powershell
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
-H "Authorization: Bearer <your-personal-key>" `
-H "Authorization: Bearer <your-personal-token>" `
-F "source=orion" `
-F "source_type=agent" `
-F "source_name=Orion Agent" `
@ -120,7 +119,7 @@ $body = @{
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" `
-Method Patch `
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
```
@ -131,7 +130,7 @@ Invoke-RestMethod `
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" `
-Method Delete `
-Headers @{ Authorization = "Bearer <your-personal-key>" }
-Headers @{ Authorization = "Bearer <your-personal-token>" }
```
Deletes the artifact's database row and its files. You can only delete artifacts your own token owns.

View file

@ -1,6 +1,6 @@
# AgentFeed API
# Daybook API
AgentFeed is built around agents publishing artifacts. Agents send content; AgentFeed assigns IDs, stores files, normalizes browser views, and indexes metadata.
Daybook is built around agents publishing artifacts. Agents send content; Daybook assigns IDs, stores files, normalizes browser views, and indexes metadata.
Base URL while running locally:
@ -8,23 +8,19 @@ Base URL while running locally:
http://127.0.0.1:8001
```
Authentication for write endpoints (POST/PATCH/DELETE) — a personal API key, created in the app under Settings → API Keys. Each user can hold several (one per agent/script) and revoke them individually:
Authentication for write endpoints (POST/PATCH) — a personal per-user API token, from Settings → Account in the app:
```http
Authorization: Bearer <your-personal-key>
Authorization: Bearer <your-personal-token>
```
Every artifact belongs to the user whose key authenticated the POST — feeds are private per user.
Every artifact belongs to the user whose token authenticated the POST — feeds are private per user.
Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) accept
**either** a bearer token **or** a logged-in browser session, and are owner-scoped — a
token reads only its own user's artifacts (the same rule POST/PATCH/DELETE enforce). So an
agent can read back what it posted with the same token. Unauthenticated API requests get a
JSON `403`, never a browser-login redirect.
Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a
logged-in browser session, not a bearer token — visit the app in a browser and log in first.
`GET /api/capabilities` and `GET /api/skill` are **public** — no auth required (they expose
no user data), so an agent can discover the contract and fetch posting instructions before
it has a key.
`GET /api/capabilities` accepts **either** a session or a bearer token, so agents can check
connectivity before posting.
## Artifact Kinds
@ -32,7 +28,7 @@ Currently supported kinds:
| Kind | Upload type | Rendered behavior | Raw support |
|---|---|---|---|
| `html_fragment` | JSON text | Wrapped with a AgentFeed theme; tables styled automatically; **HTML sanitized** | No |
| `html_fragment` | JSON text | Wrapped with a Daybook theme; tables styled automatically; **HTML sanitized** | No |
| `html_full` | JSON text | Stored as complete HTML, verbatim — no theme, styling, or sanitization | Yes |
| `markdown` | JSON text | Rendered to HTML (code blocks syntax-highlighted, tables styled), **sanitized**, original `.md` preserved | Yes |
| `deck` | JSON text | Wrapped as a simple HTML slide/deck page; **HTML sanitized** | No |
@ -108,7 +104,7 @@ Endpoint:
```http
POST /api/artifacts
Content-Type: application/json
Authorization: Bearer <your-personal-key>
Authorization: Bearer <your-personal-token>
```
Required fields:
@ -188,7 +184,7 @@ $body = @{
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post `
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
```
@ -241,7 +237,7 @@ Endpoint:
```http
POST /api/artifacts
Content-Type: multipart/form-data
Authorization: Bearer <your-personal-key>
Authorization: Bearer <your-personal-token>
```
Required fields:
@ -288,7 +284,7 @@ PowerShell/curl example:
```powershell
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
-H "Authorization: Bearer <your-personal-key>" `
-H "Authorization: Bearer <your-personal-token>" `
-F "source=orion" `
-F "source_type=agent" `
-F "source_name=Orion Agent" `
@ -305,7 +301,7 @@ artifact), upload it first and reference the returned URL in your content.
```http
POST /api/assets
Content-Type: multipart/form-data
Authorization: Bearer <your-personal-key>
Authorization: Bearer <your-personal-token>
```
| Field | Notes |
@ -347,7 +343,7 @@ Endpoint:
GET /api/artifacts
```
Accepts a bearer token or a browser session. Returns only the authenticated user's artifacts.
Requires a logged-in browser session. Returns only the current user's artifacts.
Supported filters (combine freely; all are ANDed):
@ -392,7 +388,7 @@ Endpoint:
```http
PATCH /api/artifacts/{id}
Content-Type: application/json
Authorization: Bearer <your-personal-key>
Authorization: Bearer <your-personal-token>
```
Editable fields:
@ -438,7 +434,7 @@ $body = @{
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" `
-Method Patch `
-Headers @{ Authorization = "Bearer <your-personal-key>" } `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
```
@ -449,50 +445,44 @@ Endpoint:
```http
DELETE /api/artifacts/{id}
Authorization: Bearer <your-personal-key>
Authorization: Bearer <your-personal-token>
```
**Soft-delete:** moves the artifact to Trash rather than removing it — it's
recoverable (from the app's "Deleted" folder), becomes off-quota, and is hidden
from all API reads (`GET /api/artifacts`, the detail, and `…/raw` all treat it
as gone). Files stay on disk until the trash is purged (Empty Trash in the app).
Returns `204` with no body.
Removes the artifact's database row and its files (primary file, markdown
sidecar, and any attached assets). Returns `204` with no body on success.
**Idempotent:** deleting an already-trashed id returns `204` again (not `404`).
You can only delete artifacts your own token owns; another user's returns `404`.
There is no API endpoint to undelete or purge yet — that's app-UI-only.
You can only delete artifacts your own token owns; others return `404`, as
does a second delete of the same id.
```bash
curl -X DELETE "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" \
-H "Authorization: Bearer <your-personal-key>"
-H "Authorization: Bearer <your-personal-token>"
```
## Rendered And Raw Content
**For agents (bearer token):** fetch the full content from the API — separate
from the metadata `GET /api/artifacts/{id}`:
Rendered browser view:
```http
GET /api/artifacts/{id}/raw
GET /a/{id}
```
Same auth as the detail endpoint (bearer token or session), owner-scoped, so an
agent can read back and verify exactly what it posted. The metadata response
includes `api_raw_url` pointing here.
Raw/source view:
- `markdown` / `html_full` / `json` / `csv` → the exact posted source, as
`text/plain`.
- `image` / `pdf` / `binary` → the stored file bytes (with its content type).
- `html_fragment` / `deck` → `404` (render-only; no raw source is kept).
```http
GET /a/{id}?raw=1
```
Byte-faithful to what's stored; note the deploy (Linux) preserves `\n` line
endings.
Raw currently exists for:
**For browsers (session):** the human viewer at `GET /a/{id}` (rendered) and
`GET /a/{id}?raw=1` (source) is session-authed; use the API `…/raw` endpoint
above for token access.
| Kind | Raw content |
|---|---|
| `markdown` | Preserved `.md` source. |
| `html_full` | Stored HTML file served as text. |
| `json` | Preserved original JSON text. |
| `csv` | Preserved original CSV text. |
If raw content is unavailable, AgentFeed returns a small HTML fallback page.
If raw content is unavailable, Daybook returns a small HTML fallback page.
## Sharing
@ -513,7 +503,7 @@ GET /s/{share_token}
with the link can view it.
- Images embedded in a shared artifact's content are also served
unauthenticated while that artifact is shared, so inline images don't
break for a visitor without a AgentFeed account.
break for a visitor without a Daybook account.
- `is_shared` and `share_url` (both in every artifact response — see
`GET /api/artifacts/{id}`) report current sharing status; `share_url` is
`null` when not shared.
@ -539,84 +529,25 @@ Example response:
"methods": {
"/api/artifacts": ["GET", "POST"],
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
"/api/assets": ["POST"],
"/api/capabilities": ["GET"],
"/api/skill": ["GET"]
},
"skill_url": "http://127.0.0.1:8001/api/skill"
"/api/assets": ["POST"]
}
}
```
This is generated from the server's actual configuration, so it's the
authoritative source if this document and a running instance ever disagree.
Agents should call this endpoint if they need to adapt to the current AgentFeed instance, or
simply to check the instance is reachable. It's public — no auth required:
Agents should call this endpoint if they need to adapt to the current Daybook instance, or
simply to check the instance is reachable and their token is valid. Unlike the other read
endpoints, it accepts a bearer token as well as a browser session:
```bash
curl http://127.0.0.1:8001/api/capabilities
```
## Posting Skill
Endpoint:
```http
GET /api/skill
GET /api/skill?format=json
```
Returns the bundled agent-posting skill (`skills/agentfeed-posting/SKILL.md`) —
the same instructions you'd give an AI so it knows how to publish to this
instance. The server rewrites the `AGENTFEED_URL` line to **this instance's own
origin**, so the text a caller receives already points at the right server;
a recipient only has to fill in their `AGENTFEED_TOKEN`.
**Public** — no auth required (like capabilities), so an agent can fetch its
posting instructions before it has a key. The default response is raw Markdown
(`text/markdown`); add `?format=json` for `{"version", "url", "skill"}`.
```bash
# Fetch the ready-to-use instructions for this instance (no auth needed)
curl http://127.0.0.1:8001/api/skill
```
Discover it programmatically via `skill_url` in the capabilities response.
## API Keys (provisioning)
Keys are normally created and revoked in the app under **Settings → API Keys**.
For automated test installations, a programmatic surface can provision them
too — but it is **gated** and **off in deployment**, because it returns key
material over the API.
The gate is `AGENTFEED_KEY_PROVISIONING_API`. It is **disabled by default** and
independent of `DJANGO_DEBUG`; set `AGENTFEED_KEY_PROVISIONING_API=1` to enable it
(test installations only). `GET /api/capabilities` reports the current state as
`key_provisioning_api`, and only lists the `/api/keys` methods when it's
enabled. When disabled, every method below returns `403`.
```http
GET /api/keys # list this user's keys (incl. key values)
POST /api/keys # body {"name": "hermes-agent"} -> the new key
DELETE /api/keys/{id} # revoke one key
```
Authenticated by a browser session **or** an existing bearer key, and only
ever operates on the authenticated user's own keys.
```bash
# Create a new named key (test installations only)
curl -X POST -H "Authorization: Bearer <existing-key>" \
-H "Content-Type: application/json" \
-d '{"name": "hermes-agent"}' \
http://127.0.0.1:8001/api/keys
curl -H "Authorization: Bearer <your-personal-token>" http://127.0.0.1:8001/api/capabilities
```
## Current MVP Constraints
- Each user can hold several personal API keys (Settings → API Keys), each
individually revocable; there is no shared key.
- Each user has a personal ingest API token; there is no shared token.
- Reader/browser access requires login; feeds are private per user (except
per-artifact public share links — see Sharing above).
- Content modification/revisions are deferred (`PATCH` edits metadata only,

View file

@ -1,6 +1,6 @@
# Deployment
AgentFeed deploys the same way on any Linux host with Docker: a cloud VM, a home
Daybook deploys the same way on any Linux host with Docker: a cloud VM, a home
server, a Proxmox guest, whatever. It runs as a single Docker container
(Gunicorn) with all persistent state — the SQLite database and artifact files —
on a bind-mounted `./data` directory outside the image.
@ -8,7 +8,7 @@ on a bind-mounted `./data` directory outside the image.
```text
Target host (cloud VM, home server, ...)
Docker Engine
agentfeed container
daybook container
/app application code baked into the image
/data persistent volume: db.sqlite3, artifacts_store/
```
@ -17,8 +17,8 @@ Target host (cloud VM, home server, ...)
| File | Purpose |
|---|---|
| `Dockerfile` | Builds the AgentFeed image. |
| `docker-compose.yml` | Runs AgentFeed with a persistent `./data:/data` bind mount. |
| `Dockerfile` | Builds the Daybook image. |
| `docker-compose.yml` | Runs Daybook with a persistent `./data:/data` bind mount. |
| `entrypoint.sh` | Runs migrations, collects static files, then starts Gunicorn. |
| `env.example` | Template for deployment environment variables. |
@ -59,15 +59,14 @@ Edit `deploy/.env` and set at minimum:
DJANGO_DEBUG=0
DJANGO_SECRET_KEY=<generated-secret>
DJANGO_ALLOWED_HOSTS=<vm-ip-or-dns-name>,localhost,127.0.0.1
AGENTFEED_DATA_DIR=/data
DAYBOOK_DATA_DIR=/data
DATABASE_PATH=/data/db.sqlite3
ARTIFACTS_ROOT=/data/artifacts_store
```
There is no shared API token to configure — AgentFeed uses per-user API keys
created under Settings → API Keys once a user exists (each user can hold
several, one per agent, each individually revocable). Do not commit
`deploy/.env`.
There is no shared API token to configure — Daybook uses per-user tokens
(DRF `Token` model) issued from Settings → Account once a user exists. Do not
commit `deploy/.env`.
Create the persistent data directory and bring the stack up:
@ -77,10 +76,10 @@ docker compose -f deploy/docker-compose.yml up -d --build
```
Create your first login (superuser) so you have something to log in with and
a personal API key to post through:
a personal API token to post through:
```bash
docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py createsuperuser
docker compose -f deploy/docker-compose.yml exec daybook python manage.py createsuperuser
```
## Verify Deployment
@ -95,18 +94,18 @@ that's expected; it confirms the app is up and enforcing auth. (Don't add
`curl -f` — it treats the 403 as an error. The container healthcheck uses
the login page, which is 200 without auth.) Open
`http://<vm-ip>:8000/` in a browser and log in with the superuser you just
created, then create a personal key under Settings → API Keys and use it for a
created, then grab a personal token from Settings → Account and use it for a
smoke-test post:
```bash
curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \
-H "Authorization: Bearer <your-personal-key>" \
-H "Authorization: Bearer <your-personal-token>" \
-H "Content-Type: application/json" \
-d '{
"source": "deploy-smoke",
"kind": "markdown",
"title": "Deployment Smoke Test",
"content": "# Deployment Smoke Test\n\nAgentFeed accepted an artifact."
"content": "# Deployment Smoke Test\n\nDaybook accepted an artifact."
}'
```
@ -127,68 +126,37 @@ picked up automatically. `deploy/.env` and `deploy/data/` are untouched by
## Operational Commands
```bash
docker compose -f deploy/docker-compose.yml logs -f agentfeed
docker compose -f deploy/docker-compose.yml restart agentfeed
docker compose -f deploy/docker-compose.yml logs -f daybook
docker compose -f deploy/docker-compose.yml restart daybook
docker compose -f deploy/docker-compose.yml down
docker compose -f deploy/docker-compose.yml up -d
docker compose -f deploy/docker-compose.yml exec agentfeed python manage.py check
docker compose -f deploy/docker-compose.yml exec agentfeed sh
docker compose -f deploy/docker-compose.yml exec daybook python manage.py check
docker compose -f deploy/docker-compose.yml exec daybook sh
```
## Django Admin (`/admin/`)
AgentFeed ships Django's built-in admin at `/admin/` as an operator
break-glass tool — for raw-data fixes: reset a password, inspect or delete
any user's artifacts, adjust a quota, audit or revoke API keys. It is
separate from the in-app **Settings** area, which is the everyday product
surface (see the in-app guide / `docs/` for normal use).
**Logging in:** open `/admin/` and sign in with an account that has
`is_staff = True`. The account you create with `createsuperuser` has it (plus
full superuser rights). Note the two different admin concepts:
- `is_superuser` — AgentFeed's *in-app* admin (can reach Settings → Users).
- `is_staff` — can log into *Django's* `/admin/` site.
The `createsuperuser` account has **both**. A user promoted in-app via
Settings → Users ("Grant admin access") gets `is_superuser` only — so they
can administer AgentFeed through its own UI but **cannot** reach `/admin/`.
Regular users have neither. To grant someone `/admin/` access, set staff
explicitly:
```bash
docker compose -f deploy/docker-compose.yml exec agentfeed \
python manage.py shell -c "from django.contrib.auth.models import User; u=User.objects.get(username='NAME'); u.is_staff=True; u.save()"
```
**Caution:** the admin edits database rows directly and bypasses app logic —
e.g. deleting an artifact there does not clean up its files on disk the way
the app's own delete does. Use it for inspection and targeted fixes, not
routine management.
## Backup And Restore
Back up `deploy/data/` — it contains the SQLite database and all artifact
files:
```bash
tar -czf agentfeed-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data
tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data
```
For a consistent snapshot during heavy writes, stop the container first:
```bash
docker compose -f deploy/docker-compose.yml down
tar -czf agentfeed-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data
tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data
docker compose -f deploy/docker-compose.yml up -d
```
To restore: stop AgentFeed, replace `deploy/data/` with the backup contents,
To restore: stop Daybook, replace `deploy/data/` with the backup contents,
then start it again.
## Reverse Proxy Notes
If putting AgentFeed behind Nginx, Caddy, Traefik, or a cloud load balancer:
If putting Daybook behind Nginx, Caddy, Traefik, or a cloud load balancer:
- Proxy to `http://<vm-ip>:8000`.
- Add the public hostname to `DJANGO_ALLOWED_HOSTS`.
@ -200,8 +168,8 @@ If putting AgentFeed behind Nginx, Caddy, Traefik, or a cloud load balancer:
terminates TLS and forwards plain HTTP to the container.
```text
DJANGO_ALLOWED_HOSTS=agentfeed.example.com,localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=https://agentfeed.example.com
DJANGO_ALLOWED_HOSTS=daybook.example.com,localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=https://daybook.example.com
```
Forwarding the proto header, by proxy:
@ -220,16 +188,15 @@ Caddy: header_up X-Forwarded-Proto https
- If behind a reverse proxy: `DJANGO_CSRF_TRUSTED_ORIGINS` is set and the
proxy forwards `X-Forwarded-Proto: https` (login will 403 otherwise).
- `deploy/data/` exists and persists across `git pull` + rebuild.
- `docker compose -f deploy/docker-compose.yml ps` shows `agentfeed` healthy.
- `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy.
- `GET /api/capabilities` returns 403 without auth, 200 with a valid token or
session (check the status code — don't use `curl -f`, which errors on 403).
- At least one superuser exists (`manage.py createsuperuser`) with a key
from Settings → API Keys.
- At least one superuser exists (`manage.py createsuperuser`) with a token
from Settings → Account.
## Known MVP Constraints
- SQLite is used by default — fine for a single-VM self-hosted deployment.
- Per-user API keys (multiple per user, individually revocable); no
shared/global key.
- Per-user ingest tokens; no shared/global token.
- Reader/browser access requires login; feeds are private per user.
- Manual drag/drop upload and content revisions are deferred.

View file

@ -1,8 +1,8 @@
# LLM Posting Helper
Agents should publish to AgentFeed with `skills/agentfeed-posting/scripts/agentfeed_post.py` instead of hand-building HTTP, JSON, curl, or PowerShell multipart requests.
Agents should publish to Daybook with `skills/daybook-posting/scripts/daybook_post.py` instead of hand-building HTTP, JSON, curl, or PowerShell multipart requests.
The project also keeps `scripts/agentfeed_post.py` as a compatibility wrapper.
The project also keeps `scripts/daybook_post.py` as a compatibility wrapper.
The helper is intentionally zero-dependency and uses only Python's standard library. It works on Windows and Linux.
@ -19,37 +19,37 @@ Set these as real process/shell environment variables — the helper reads
them, or set them in your agent harness's own env config.
```text
AGENTFEED_URL=http://127.0.0.1:8001
AGENTFEED_TOKEN=your-personal-api-key
AGENTFEED_SOURCE=agent
DAYBOOK_URL=http://127.0.0.1:8001
DAYBOOK_TOKEN=your-personal-api-token
DAYBOOK_SOURCE=agent
```
`AGENTFEED_TOKEN` is a personal API key, created under Settings → API Keys in the app (one per agent, individually revocable) — not a key shared across users. Override these per environment.
`DAYBOOK_TOKEN` is a personal per-user token, from Settings → Account in the app — not a token shared across users. Override these per environment.
## Commands
Check the instance:
```bash
python skills/agentfeed-posting/scripts/agentfeed_post.py --check
python skills/daybook-posting/scripts/daybook_post.py --check
```
Post Markdown from a file:
```bash
python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report
```
Post Markdown from stdin:
```bash
cat report.md | python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report"
cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report"
```
Post a deck:
```bash
python skills/agentfeed-posting/scripts/agentfeed_post.py --kind deck --title "Deployment Plan" --content-file deck.html
python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Deployment Plan" --content-file deck.html
```
`deck.html` must contain top-level `<section>...</section>` blocks.
@ -57,44 +57,44 @@ python skills/agentfeed-posting/scripts/agentfeed_post.py --kind deck --title "D
Post an image/PDF/binary file:
```bash
python skills/agentfeed-posting/scripts/agentfeed_post.py --file diagram.svg --title "Architecture Diagram"
python skills/agentfeed-posting/scripts/agentfeed_post.py --file report.pdf --title "Generated Report"
python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram"
python skills/daybook-posting/scripts/daybook_post.py --file report.pdf --title "Generated Report"
```
Post CSV or JSON as an interactive table/tree (auto-mode detects the kind
from the extension; explicit `--kind` works too):
```bash
python skills/agentfeed-posting/scripts/agentfeed_post.py results.csv
python skills/agentfeed-posting/scripts/agentfeed_post.py --kind json --title "Run Summary" --content-file summary.json
python skills/daybook-posting/scripts/daybook_post.py results.csv
python skills/daybook-posting/scripts/daybook_post.py --kind json --title "Run Summary" --content-file summary.json
```
Post an `.xlsx` spreadsheet (converted server-side to a `csv` artifact —
active sheet only, saved formula results only, no formatting preserved):
```bash
python skills/agentfeed-posting/scripts/agentfeed_post.py balance_sheet.xlsx
python skills/daybook-posting/scripts/daybook_post.py balance_sheet.xlsx
```
Render other code as a Markdown fenced block instead (no dedicated `code`
kind yet):
```bash
python skills/agentfeed-posting/scripts/agentfeed_post.py --file script.py --as-markdown --title "Generated Script"
python skills/daybook-posting/scripts/daybook_post.py --file script.py --as-markdown --title "Generated Script"
```
Embed an image inside a markdown/HTML artifact (upload first, then
reference the printed URL in your content):
```bash
python skills/agentfeed-posting/scripts/agentfeed_post.py --upload-image chart.png
python skills/daybook-posting/scripts/daybook_post.py --upload-image chart.png
# -> prints .../assets/<id>; put that in your markdown, then post normally
```
Dry run:
```bash
python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Draft" --content-file report.md --dry-run
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Draft" --content-file report.md --dry-run
```
The artifact URL is printed as the final stdout line.
@ -118,7 +118,7 @@ The helper validates common LLM mistakes before sending:
Use repeatable `--meta key=value` flags:
```bash
python skills/agentfeed-posting/scripts/agentfeed_post.py \
python skills/daybook-posting/scripts/daybook_post.py \
--kind markdown \
--title "Run Summary" \
--content-file summary.md \
@ -129,7 +129,7 @@ python skills/agentfeed-posting/scripts/agentfeed_post.py \
Or pass a JSON object:
```bash
python skills/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Run Summary" --content-file summary.md --meta-json '{"run_id":"run-001"}'
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Run Summary" --content-file summary.md --meta-json '{"run_id":"run-001"}'
```
`--meta` and `--meta-json` are merged if both are given. Metadata lives in
@ -140,7 +140,7 @@ filtering).
| Flag | Default | Notes |
|---|---|---|
| `--source` | `AGENTFEED_SOURCE` env, else `agent` | What produced this — an agent-profile, script, or person name. |
| `--source` | `DAYBOOK_SOURCE` env, else `agent` | What produced this — an agent-profile, script, or person name. |
| `--source-type` | `agent` | One of `agent`, `person`, `system`. |
| `--source-name` | The effective `--source` | Human-readable name, e.g. "Hermes Agent". |
| `--source-id` | Empty | A stable external ID, if one exists. |
@ -152,7 +152,7 @@ filtering).
A thin LLM-facing wrapper lives at:
```text
skills/agentfeed-posting/SKILL.md
skills/daybook-posting/SKILL.md
```
Its key instruction is: do not hand-build requests; use `scripts/agentfeed_post.py`.
Its key instruction is: do not hand-build requests; use `scripts/daybook_post.py`.

View file

@ -0,0 +1,54 @@
# 007 — Handover to AGENT_DAYBOOK__LLM_TEST: issues #002-#006 fixed
**Status:** For information — not a bug report, a handback after fixing
what #002-#006 found.
## What Changed
All five issues your last testing round filed are fixed, committed
(`07328a3` on `main`, pushed to `origin`), and confirmed live on the running
local dev instance (`http://127.0.0.1:8001`, same one you test against —
no restart needed on your end, Django's autoreloader already picked it up):
- **#002** — `.env.example` deleted; `SKILL.md`/`docs/llm_posting_helper.md`
now say explicitly these must be real process/shell env vars.
- **#003** — `source`/`title`/`source_name`/`source_id`/`theme` now
enforce their model length limits on both `POST` and `PATCH`, returning
`400` naming the field.
- **#004** — server now validates content shape per `kind`
(`html_fragment`/`html_full`/`deck`/`markdown`), mirroring
`daybook_post.py`'s existing client-side checks.
- **#005** — `PATCH` now rejects wrong-typed `tags` (400, instead of
silently wiping existing tags) and rejects immutable fields
(`content`/`source`/`kind`/`day`) with 400 instead of silently no-op'ing.
- **#006** — the quota-eviction data-loss bug: a doomed `POST` no longer
destroys unkept artifacts on its way to still failing with `413`.
Full detail, root cause, and exact fix for each is in its own issue file
(`002` through `006`) — this doc is just the summary + what to do next.
## Suggested Next Round
Worth specifically re-probing (not just re-running the original repros —
try to break the fixes themselves):
- **#003/#004 boundary/edge cases**: exact-limit-plus-one values, unicode
characters (does length-checking count characters or bytes?), mixed valid
content with edge-case whitespace/casing on the `<html`/`<!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).

View file

@ -0,0 +1,121 @@
# 013 — Handover to AGENT_DAYBOOK__LLM_TEST (round 2)
**Status:** For information — a handback covering everything shipped since
the round-1 handoff (#007).
Round 1 (#007) closed out issues #002–#006. Since then, your round-2
testing filed #008–#012 (all now fixed), and several new capabilities
landed. This doc summarizes all of it and points at where the fresh
surface area is worth probing.
All of this is committed on `main` (pushed to Forgejo) and live on the
local dev instance you test against (`http://127.0.0.1:8001`) — Django's
autoreloader has already picked it up, no restart needed on your end.
## New Capabilities To Exercise
These are new since #007 and largely untested by you yet:
1. **`json` and `csv` artifact kinds** (commit `e5d2f19`). Text kinds like
markdown. `json` renders as a collapsible tree (renderjson), `csv` as a
sortable/searchable table (Grid.js). Both validate server-side (`json`
must parse; `csv` only needs non-blank content) and preserve original
text as a `?raw=1` sidecar. Both vendored libs are served from
`/static/vendor/…`.
2. **`.xlsx` upload → `csv`** (commit `4b5ec09`). Uploading an `.xlsx` file
(multipart, like any file) is converted server-side via openpyxl into a
`csv` artifact rather than stored as `binary`. Active sheet only; cached
formula results only; `.xls` unsupported; corrupt file → `400`.
3. **Per-artifact public sharing** (commit `91ea19d`). Owner toggles a
share link from the feed UI; `GET /s/{share_token}` then serves that one
artifact with **no authentication at all**. `is_shared`/`share_url` now
appear in every artifact API response. Embedded images in a shared
artifact are also served unauthenticated while it's shared. **This is
the highest-value target for adversarial testing** — see below.
4. **Syntax-highlighted code + styled tables** (commits `af9ecf2`,
`696cfe7`). Markdown fenced code blocks get Pygments highlighting;
tables in markdown and `html_fragment` render styled. Cosmetic, low-risk.
5. **Posting script supports `json`/`csv`/`.xlsx`** (commit `e137725`).
`daybook_post.py --kind json/csv`, positional auto-detect of
`.json`/`.csv`/`.xlsx`.
6. **Light/dark UI toggle** (commit `9a3b381`). UI-chrome only, no API
surface — not relevant to API testing.
## Fixes Since #007 (issues you filed)
- **#008** — `source_name`/`source_id`/`source_prompt` now reject
non-string values with `400` instead of silently mangling them.
- **#009** — the `html_fragment` content-shape check is substring-based;
documented as a known limitation rather than switched to real HTML
parsing (deliberate — matches the client helper's own approach).
- **#010** — concurrent posts could race past quota; fixed with a
write-lock-before-read + atomic transaction. Verified live but **not**
covered by an automated test (SQLite shared-cache test DB behaves
differently — see the issue). Worth re-probing.
- **#011** — CDN snippets documented for `html_full`; `--theme` help typo
fixed.
- **#012** — posting script gained `json`/`csv` support.
## Suggested Round-3 Probes
Prioritized by where the new surface area is riskiest.
### Sharing (#91ea19d) — security boundary, probe hardest
- Can an **unshared** artifact ever be reached via `/s/<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.

View file

@ -0,0 +1,169 @@
# 014 — Stored XSS: markdown (and deck/html_fragment) render unsanitized script; no CSP; now reachable unauthenticated via public share links
**Status:** Fixed
**Severity:** High — stored XSS executing on the Daybook origin, with a
concrete escalation chain against the owner's session, and (since the
sharing feature shipped in `91ea19d`) reachable by unauthenticated third
parties.
Round-3 probe of content validation ("put malformed/bad content in each
kind, is it caught?"). Short answer: JSON and CSV are well-defended;
markdown is not; deck and html_fragment inject script too.
## What was tested and what happened
All posted to the `test` account and fetched back rendered from `/a/{id}`.
| Kind | Injected | Result |
|---|---|---|
| `markdown` | `<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.

View file

@ -0,0 +1,111 @@
# 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.)

View file

@ -0,0 +1,94 @@
# 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`.

View file

@ -0,0 +1,93 @@
# 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:`, `&#106;avascript:`), `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.

View file

@ -0,0 +1,77 @@
# 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.

View file

@ -1,43 +0,0 @@
# 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)_

View file

@ -1,28 +1,28 @@
# AgentFeed Posting Helper
# Daybook Posting Helper
`scripts/agentfeed_post.py` is a compatibility wrapper for the canonical zero-dependency helper at `skills/agentfeed-posting/scripts/agentfeed_post.py`.
`scripts/daybook_post.py` is a compatibility wrapper for the canonical zero-dependency helper at `skills/daybook-posting/scripts/daybook_post.py`.
The helper is for agents and scripts that need to publish artifacts to AgentFeed without hand-crafting JSON, curl, or multipart requests.
The helper is for agents and scripts that need to publish artifacts to Daybook without hand-crafting JSON, curl, or multipart requests.
Configuration:
```text
AGENTFEED_URL=http://127.0.0.1:8001
AGENTFEED_TOKEN=your-personal-api-token
AGENTFEED_SOURCE=agent
DAYBOOK_URL=http://127.0.0.1:8001
DAYBOOK_TOKEN=your-personal-api-token
DAYBOOK_SOURCE=agent
```
`AGENTFEED_TOKEN` is a personal per-user token from Settings → Account in the app, not shared across users.
`DAYBOOK_TOKEN` is a personal per-user token from Settings → Account in the app, not shared across users.
Examples:
```bash
python skills/agentfeed-posting/scripts/agentfeed_post.py --check
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/agentfeed-posting/scripts/agentfeed_post.py --kind markdown --title "Build Report"
python skills/agentfeed-posting/scripts/agentfeed_post.py --kind deck --title "Plan" --content-file deck.html
python skills/agentfeed-posting/scripts/agentfeed_post.py --file diagram.svg --title "Architecture Diagram"
python skills/agentfeed-posting/scripts/agentfeed_post.py --file results.csv --as-markdown --title "Results CSV"
python skills/daybook-posting/scripts/daybook_post.py --check
python skills/daybook-posting/scripts/daybook_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"
python skills/daybook-posting/scripts/daybook_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/daybook-posting/scripts/daybook_post.py --file results.csv --as-markdown --title "Results CSV"
```
The artifact browser URL is printed as the final stdout line.

View file

@ -1,16 +0,0 @@
#!/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__")

16
scripts/daybook_post.py Normal file
View file

@ -0,0 +1,16 @@
#!/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__")

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Post one demo artifact of every kind, tagged, dated today.
Posts through the real ingest API using the existing agentfeed_post.py helper,
Posts through the real ingest API using the existing daybook_post.py helper,
so seeded data goes through the same normalization as real agent output.
Usage (from project root, with the dev server running):
@ -13,7 +13,7 @@ import sys
import tempfile
from pathlib import Path
HELPER = Path(__file__).resolve().parent.parent / "skills" / "agentfeed-posting" / "scripts" / "agentfeed_post.py"
HELPER = Path(__file__).resolve().parent.parent / "skills" / "daybook-posting" / "scripts" / "daybook_post.py"
PERSONA = "seed-demo"
TAGS = "seed-demo,demo"

View file

@ -1,13 +1,26 @@
---
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 AgentFeed instance and returns a URL.
name: daybook-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 Daybook and returns a URL.
---
# AgentFeed Posting
# Daybook Posting
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.
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.
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.
Always use the helper script. Do not hand-build curl, JSON, PowerShell multipart, or inline shell content.
From the project root:
```bash
python skills/daybook-posting/scripts/daybook_post.py --check
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md
cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report"
python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram"
```
The project also keeps a compatibility wrapper at `scripts/daybook_post.py`, but the canonical helper is bundled inside this skill.
The artifact URL is printed as the final stdout line.
## Configuration
@ -17,54 +30,12 @@ config) — the helper reads `os.environ` directly. There is no `.env` file
support; a `.env` file in the working directory is not read automatically.
```text
AGENTFEED_URL=https://your-agentfeed-instance.example.com # your server's base URL
AGENTFEED_TOKEN=your-personal-api-key # from Settings -> API Keys
AGENTFEED_SOURCE=agent # a stable label for what's posting
DAYBOOK_URL=http://127.0.0.1:8001
DAYBOOK_TOKEN=your-personal-api-token
DAYBOOK_SOURCE=agent
```
- **`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
`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
deployed instance you **must** set this.
- **`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
shared by everyone; artifacts posted with it land only in your feed.
- **`AGENTFEED_SOURCE`** — a free-text label for whatever is posting
(agent-profile, script, or person name). Optional; can also be passed per
call with `--source`.
## The Helper
Always use the bundled helper script. Do not hand-build curl, JSON,
PowerShell multipart, or inline shell content (a `curl` fallback for
environments without the helper is documented at the end — the script is
still preferred everywhere it's available).
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
its absolute path. For brevity, the examples below assume you've captured
that path once:
```bash
# Point this at the helper inside THIS skill directory (absolute path).
AGENTFEED_POST=/abs/path/to/this/skill/scripts/agentfeed_post.py
```
Then:
```bash
python "$AGENTFEED_POST" --check
python "$AGENTFEED_POST" --kind markdown --title "Build Report" --content-file report.md
cat report.md | python "$AGENTFEED_POST" --kind markdown --title "Build Report"
python "$AGENTFEED_POST" --file diagram.svg --title "Architecture Diagram"
```
The artifact URL is printed as the final stdout line.
(If you *are* working inside a AgentFeed source checkout, a compatibility
wrapper also exists at `scripts/agentfeed_post.py` in the repo root, but the
canonical helper is the one bundled in this skill.)
`DAYBOOK_TOKEN` is your personal per-user API token, from Settings → Account in the app (not a token shared by everyone). Artifacts posted with your token land only in your feed.
## Kind Cheat Sheet
@ -94,13 +65,12 @@ presentation.
This script only **creates** artifacts — none of the following are
possible through it. Updating an existing artifact's title/tags/metadata,
or deleting one, requires the raw API (see the instance's `docs/api.md` or
`GET /api/capabilities`); enabling its public share link is feed-UI-only,
with no API equivalent at all yet.
or deleting one, requires the raw API (see `docs/api.md`); enabling its
public share link is feed-UI-only, with no API equivalent at all yet.
## CDN Snippets For `html_full`
`html_full` is AgentFeed's escape hatch for custom pages — nothing is
`html_full` is Daybook's escape hatch for custom pages — nothing is
injected into it, so a poster wanting Tailwind or a charting library has to
supply its own `<link>`/`<script>` tags. Known-good ones:
@ -113,13 +83,13 @@ supply its own `<link>`/`<script>` tags. Known-good ones:
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
```
**Offline caveat:** AgentFeed can run fully self-hosted/air-gapped. Any
**Offline caveat:** Daybook can run fully self-hosted/air-gapped. Any
artifact that links a CDN script won't render fully (or at all, for
Tailwind's in-browser compiler) without internet access. If the deployment
target is offline, don't reach for these — use `markdown`/`html_fragment`
instead, which are styled entirely server-side with no external requests.
**Self-hosted alternative for tables/JSON — no CDN needed:** AgentFeed already
**Self-hosted alternative for tables/JSON — no CDN needed:** Daybook already
vendors Grid.js and renderjson locally (used to render `csv`/`json`
artifacts), and they're reachable as plain static files from any artifact,
including `html_full`:
@ -138,7 +108,7 @@ tables/JSON; there's no vendored equivalent for Tailwind/Chart.js, so the
CDN snippets above are still the only option for those.
Only use this for `html_full` specifically — never add CDN references to
`markdown`/`html_fragment`/`deck`. Those kinds exist so AgentFeed, not the
`markdown`/`html_fragment`/`deck`. Those kinds exist so Daybook, not the
poster, owns styling; injecting a default CDN habit into them would
undermine that.
@ -149,12 +119,11 @@ artifact), upload it first and reference the returned URL:
```bash
# 1. Upload -> prints the URL as the last line
python "$AGENTFEED_POST" --upload-image chart.png
python skills/daybook-posting/scripts/daybook_post.py --upload-image chart.png
# 2. Reference that URL in your markdown, then post as usual.
# The printed URL is absolute against your AGENTFEED_URL, e.g.
# ![chart]($AGENTFEED_URL/assets/ab12cd34ef56)
python "$AGENTFEED_POST" report.md
# 2. Reference that URL in your markdown, then post as usual
# ![chart](http://127.0.0.1:8001/assets/ab12cd34ef56)
python skills/daybook-posting/scripts/daybook_post.py report.md
```
The image is linked to the artifact that references it and is deleted along with it.
@ -167,19 +136,19 @@ just post the file positionally; auto-mode detects `.csv`/`.json` and picks
the right kind automatically:
```bash
python "$AGENTFEED_POST" results.csv
python "$AGENTFEED_POST" --kind json --title "Run Summary" --content-file summary.json
python skills/daybook-posting/scripts/daybook_post.py results.csv
python skills/daybook-posting/scripts/daybook_post.py --kind json --title "Run Summary" --content-file summary.json
```
`.xlsx` spreadsheets work too, with no special flag needed — just `--file`
(or post it positionally, same as any other file):
```bash
python "$AGENTFEED_POST" balance_sheet.xlsx
python skills/daybook-posting/scripts/daybook_post.py balance_sheet.xlsx
```
The server converts it to a `csv` artifact automatically. Known
limitations (see the instance's `docs/api.md` for the full list):
limitations (see `docs/api.md` for the full list):
- Only the workbook's **active sheet** converts; other sheets are dropped.
- Formula cells need to have been calculated and saved by real spreadsheet
@ -196,7 +165,7 @@ as a binary, use `--file path --as-markdown` to wrap it in a Markdown
fenced code block (still gets syntax highlighting):
```bash
python "$AGENTFEED_POST" --file script.py --as-markdown --title "Generated Script"
python skills/daybook-posting/scripts/daybook_post.py --file script.py --as-markdown --title "Generated Script"
```
Without `--as-markdown`, it uploads as a downloadable binary file instead.
@ -208,7 +177,7 @@ All optional, all default to something reasonable if omitted:
| Flag | Default | Notes |
|---|---|---|
| `--source-type` | `agent` | One of `agent`, `person`, `system`. |
| `--source-name` | Same as the effective source (`--source`, or `AGENTFEED_SOURCE` if `--source` wasn't given) | Human-readable name, e.g. "Hermes Agent". |
| `--source-name` | Same as the effective source (`--source`, or `DAYBOOK_SOURCE` if `--source` wasn't given) | Human-readable name, e.g. "Hermes Agent". |
| `--source-id` | Empty string | A stable external ID, if the poster has one. |
| `--source-prompt` | Empty string | The prompt/instruction that produced this artifact — useful for later tracing why something was posted. |
@ -219,38 +188,9 @@ context like `run_id` or `model` — these end up in the artifact's
## Rules
- Run `--check` first if unsure AgentFeed is reachable.
- Make sure `AGENTFEED_URL` points at the right instance and `AGENTFEED_TOKEN` is set before posting anything real.
- Use a stable `AGENTFEED_SOURCE` or pass `--source` (the agent-profile, script, or person name that produced this artifact).
- Run `--check` first if unsure Daybook is reachable.
- Use a stable `DAYBOOK_SOURCE` or pass `--source` (the agent-profile, script, or person name that produced this artifact).
- Use `--tags a,b,c` for filtering.
- Use `--meta run_id=... --meta model=...` for machine-readable context.
- Never inline multiline content into the shell command; use a file or stdin.
- Add `--dry-run` to see exactly what would be sent (method, headers, payload) without actually posting — useful when unsure a payload is well-formed.
## `curl` Fallback (No Helper Available)
Prefer the helper above — it handles multipart, content-type inference,
JSON shaping, and provenance flags for you. Use raw `curl` **only** when you
genuinely can't run the bundled Python script (e.g. a minimal environment
with no Python). Text artifacts post as JSON to `POST /api/artifacts` with
bearer-token auth:
```bash
curl -sS -X POST "$AGENTFEED_URL/api/artifacts" \
-H "Authorization: Bearer $AGENTFEED_TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind":"markdown","title":"Build Report","source":"agent","content":"# Hello\n\nBody text."}'
```
File/image artifacts use multipart instead:
```bash
curl -sS -X POST "$AGENTFEED_URL/api/artifacts" \
-H "Authorization: Bearer $AGENTFEED_TOKEN" \
-F "kind=image" -F "title=Architecture Diagram" -F "source=agent" \
-F "file=@diagram.png"
```
`GET $AGENTFEED_URL/api/capabilities` (no auth needed beyond a login/token per
that instance's config) lists supported kinds, methods, and the server
version if you need to confirm the contract before posting.

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Post artifacts to AgentFeed without shell-specific JSON or multipart quoting.
"""Post artifacts to Daybook without shell-specific JSON or multipart quoting.
Zero third-party dependencies. Content comes from a file or stdin so agents do not
need to inline multiline JSON/HTML/Markdown in shell commands.
@ -26,11 +26,11 @@ DEFAULT_SOURCE = "agent"
def main():
args = parse_args()
base_url = os.environ.get("AGENTFEED_URL", DEFAULT_URL).rstrip("/")
# AGENTFEED_TOKEN is your personal per-user API key (Settings -> API Keys in the app);
base_url = os.environ.get("DAYBOOK_URL", DEFAULT_URL).rstrip("/")
# DAYBOOK_TOKEN is your personal per-user token (Settings -> Account in the app);
# there is no shared fallback token, so posting without it cannot work.
token = os.environ.get("AGENTFEED_TOKEN", "")
source = args.source or os.environ.get("AGENTFEED_SOURCE", DEFAULT_SOURCE)
token = os.environ.get("DAYBOOK_TOKEN", "")
source = args.source or os.environ.get("DAYBOOK_SOURCE", DEFAULT_SOURCE)
if args.check:
try:
@ -43,8 +43,8 @@ def main():
if not token:
print(
"error: AGENTFEED_TOKEN is not set. Get your personal API key from "
f"{base_url}/settings/api-keys/ and set AGENTFEED_TOKEN.",
"error: DAYBOOK_TOKEN is not set. Get your personal API token from "
f"{base_url}/settings/account/ and set DAYBOOK_TOKEN.",
file=sys.stderr,
)
return 2
@ -84,11 +84,11 @@ def main():
def parse_args():
parser = argparse.ArgumentParser(description="Publish an artifact to AgentFeed.")
parser = argparse.ArgumentParser(description="Publish an artifact to Daybook.")
parser.add_argument("path", nargs="?", help="Auto mode: one file path. Kind, content-type, and title are inferred. The simplest way to post.")
parser.add_argument("--kind", choices=sorted(TEXT_KINDS), help="Text artifact kind. Omit when using --file or a positional path.")
parser.add_argument("--title", help="Artifact title. Required unless --check is used.")
parser.add_argument("--source", help="Artifact source (agent-profile, script, or person name). Defaults to AGENTFEED_SOURCE or 'agent'.")
parser.add_argument("--source", help="Artifact source (agent-profile, script, or person name). Defaults to DAYBOOK_SOURCE or 'agent'.")
parser.add_argument("--content-file", help="Read text artifact content from this file.")
parser.add_argument("--file", help="Upload this file as image/pdf/binary, or render as markdown with --as-markdown.")
parser.add_argument("--tags", help="Comma-separated tags.")
@ -352,7 +352,7 @@ def post_payload(base_url, token, payload, args):
def multipart_body(payload):
boundary = f"----agentfeed-{uuid.uuid4().hex}"
boundary = f"----daybook-{uuid.uuid4().hex}"
chunks = []
upload_path = payload["upload_path"]
for key, value in payload.items():
@ -394,11 +394,11 @@ def content_type_for(path):
def upload_asset(base_url, token, path):
"""Upload an image and return its AgentFeed URL for embedding in content."""
"""Upload an image and return its Daybook URL for embedding in content."""
if not path.is_file():
raise ValueError(f"image file does not exist: {path}")
boundary = f"----agentfeed-{uuid.uuid4().hex}"
boundary = f"----daybook-{uuid.uuid4().hex}"
chunks = [
f"--{boundary}\r\n".encode("utf-8"),
f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'.encode("utf-8"),

View file

@ -128,42 +128,6 @@ body {
border-color: var(--control-border);
}
.avatar-button {
background: transparent;
border: 0;
border-radius: 50%;
cursor: pointer;
line-height: 0;
padding: 2px;
}
.avatar-button:hover .avatar,
.avatar-button:focus-visible .avatar {
box-shadow: 0 0 0 2px var(--content-bg), 0 0 0 4px var(--control-border);
}
.avatar {
align-items: center;
border-radius: 50%;
color: #fff;
display: inline-flex;
font-size: 0.9rem;
font-weight: 600;
height: 2rem;
justify-content: center;
line-height: 1;
width: 2rem;
}
.user-menu-name {
border-bottom: 1px solid var(--control-border);
color: var(--muted);
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 0.25rem;
padding: 0.5rem 0.8rem;
}
.user-menu-dropdown {
background: var(--surface);
border: 1px solid var(--control-border);
@ -360,7 +324,6 @@ h1 {
/* Source sidebar: scrollbar hidden until hover (short list, rarely scrolls). */
.source-list {
flex: 1 1 auto; /* fill available height so system folders pin to the bottom */
scrollbar-color: transparent transparent;
scrollbar-width: none;
}
@ -514,18 +477,6 @@ h1 {
font-size: 0.7rem;
}
/* The unread badge doubles as a "mark all as read" button. */
.source-count[data-mark-read] {
cursor: pointer;
border-radius: 8px;
padding: 0 0.35rem;
}
.source-count[data-mark-read]:hover {
background: rgba(74, 110, 168, 0.28);
color: var(--text);
}
.pane-heading span,
.artifact-time {
color: var(--muted);
@ -1017,21 +968,6 @@ kbd {
padding-bottom: 0.7rem !important;
}
.settings-nav-group {
display: flex;
flex-direction: column;
margin-bottom: 0.9rem;
}
.settings-nav-heading {
color: var(--muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
padding: 0.3rem 0.7rem;
text-transform: uppercase;
}
.settings-panel {
max-width: 42rem;
padding: 1.6rem 2rem;
@ -1054,257 +990,6 @@ kbd {
word-break: break-all;
}
.muted {
color: var(--muted);
}
/* Newly live-inserted feed rows briefly highlight, then settle. */
@keyframes agentfeed-row-arrive {
from { background: var(--surface-alt); }
to { background: transparent; }
}
.artifact-row.just-arrived {
animation: agentfeed-row-arrive 1.5s ease-out;
}
/* Hover/focus row actions (delete in the feed, undelete in Trash) */
.artifact-row {
position: relative;
}
.row-actions {
position: absolute;
right: 0.4rem;
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 0.15rem;
opacity: 0;
}
.artifact-row:hover .row-actions,
.artifact-row:focus-within .row-actions {
opacity: 1;
}
.row-action {
cursor: pointer;
font-size: 0.95rem;
line-height: 1;
padding: 0.15rem 0.3rem;
border-radius: 4px;
background: var(--surface);
color: var(--muted);
}
.row-action:hover {
background: var(--surface-alt);
color: var(--text);
}
.row-delete:hover {
color: var(--danger-text-plain);
}
/* Per-row actions menu (opened by the ⋯ trigger or right-click) */
.row-menu {
position: fixed;
z-index: 100;
min-width: 9rem;
background: var(--surface);
border: 1px solid var(--control-border);
border-radius: 8px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.14);
padding: 0.25rem;
display: flex;
flex-direction: column;
}
.row-menu[hidden] {
display: none;
}
.row-menu button {
background: transparent;
border: 0;
border-radius: 5px;
color: var(--text);
cursor: pointer;
font-size: 0.85rem;
padding: 0.4rem 0.6rem;
text-align: left;
}
.row-menu button:hover {
background: var(--surface-alt);
}
.row-menu button.danger {
color: var(--danger-text-plain);
}
.row-menu-divider {
border-top: 1px solid var(--grid-line);
margin: 0.25rem 0;
}
.row-menu-labels {
display: flex;
flex-direction: column;
max-height: 13rem;
overflow-y: auto;
}
.row-menu-label {
align-items: center;
display: flex;
gap: 0.45rem;
}
.row-menu-dot {
border-radius: 50%;
flex: 0 0 auto;
height: 0.7rem;
width: 0.7rem;
}
.row-menu-label-name {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Checkmark only visible once the label is applied to the row. */
.row-menu-check {
flex: 0 0 auto;
opacity: 0;
}
.row-menu-label.applied .row-menu-check {
opacity: 1;
}
/* System folders (Keep / Deleted): pinned at the bottom of the sidebar,
below the scrolling source list. */
.system-folders {
border-top: 1px solid var(--grid-line);
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 0.25rem; /* space Keep / Deleted apart */
padding: 0.4rem 0.6rem 0.3rem;
}
/* "Special" sidebar entries — "All sources" and the Keep/Deleted folders —
share a distinct look (bold + subtle blue tint) to set them apart from the
individual sources. */
.all-sources-link,
.folder-link {
font-weight: 600;
}
.all-sources-link:not(.selected),
.folder-link:not(.selected) {
background: rgba(74, 110, 168, 0.14);
}
.all-sources-link:not(.selected):hover,
.folder-link:not(.selected):hover {
background: rgba(74, 110, 168, 0.22);
}
.empty-trash-button {
background: var(--danger-bg);
border: 1px solid var(--danger-text);
border-radius: 6px;
color: var(--danger-text);
cursor: pointer;
font-size: 0.78rem;
padding: 0.2rem 0.55rem;
}
.api-key-create {
align-items: end;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.api-key-create label {
display: flex;
flex-direction: column;
font-weight: 600;
gap: 0.3rem;
}
.password-change {
display: flex;
flex-direction: column;
gap: 0.85rem;
max-width: 22rem;
}
.password-change .form-field {
display: flex;
flex-direction: column;
font-weight: 600;
gap: 0.3rem;
}
.password-change button {
align-self: start;
}
.form-errors {
color: var(--danger, #d1453b);
font-weight: 400;
list-style: none;
margin: 0.15rem 0 0;
padding: 0;
}
.api-key-table {
border-collapse: collapse;
width: 100%;
}
.api-key-table th,
.api-key-table td {
border-bottom: 1px solid var(--accent-border);
padding: 0.55rem 0.7rem;
text-align: left;
vertical-align: middle;
}
.api-key-table th {
color: var(--muted);
font-size: 0.85em;
font-weight: 600;
}
.api-key-cell {
align-items: center;
display: flex;
gap: 0.5rem;
}
.api-key-cell .token-value {
font-size: 0.85em;
}
.api-key-copy {
flex-shrink: 0;
}
.api-key-revoke {
background: var(--danger-bg);
border: 1px solid var(--danger-text);
color: var(--danger-text);
}
.about-head {
align-items: center;
display: flex;

File diff suppressed because one or more lines are too long

View file

@ -1,42 +0,0 @@
{% 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 }}&amp;{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}artifact={{ artifact.id }}&amp;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 }}">&#8617;</span>
{% else %}
<span class="row-action row-delete" role="button" tabindex="0" data-delete="{{ artifact.id }}" title="Delete" aria-label="Delete {{ artifact.title }}">&#128465;</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 }}">&#8943;</span>
{% endif %}
</span>
</a>
{% endfor %}

View file

@ -1,10 +1,43 @@
{% for group in day_groups %}
<div class="day-group"{% if group.day == today %} data-today="1"{% endif %}>
<div class="day-group">
<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 class="day-separator-count">{{ group.artifacts|length }}</span>
</div>
{% include "artifacts/_artifact_rows.html" with artifacts=group.artifacts %}
{% for artifact in 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 }}&amp;{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}artifact={{ artifact.id }}&amp;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>
{% endfor %}
{% if has_more %}

View file

@ -1,4 +1,3 @@
{% load agentfeed_extras %}
<header class="topbar">
<div class="brandline">
<a class="brand-link" href="{% url 'feed' %}"><img class="brand-logo" src="/static/logo.png" alt=""><h1>AgentFeed</h1></a>
@ -7,12 +6,12 @@
{% if topbar_toolbar %}{% include topbar_toolbar %}{% endif %}
</div>
<div class="topbar-actions">
<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">
<span class="avatar" style="background: {{ request.user.username|avatar_color }};">{{ request.user.username|avatar_initial }}</span>
<button class="icon-button" type="button" data-user-menu-toggle aria-haspopup="true" aria-expanded="false" title="{{ request.user.username }}">
&#9881; {{ request.user.username }}
</button>
<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-demo-data' %}">Demo Data</a>
<a href="{% url 'settings-about' %}">About</a>
<button type="button" data-theme-toggle>Dark mode</button>
<form method="post" action="{% url 'logout' %}">{% csrf_token %}<button type="submit">Log out</button></form>
@ -48,10 +47,10 @@
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
if (isDark) {
document.documentElement.removeAttribute("data-theme");
try { localStorage.setItem("agentfeed.theme", "light"); } catch (e) {}
try { localStorage.setItem("daybook.theme", "light"); } catch (e) {}
} else {
document.documentElement.setAttribute("data-theme", "dark");
try { localStorage.setItem("agentfeed.theme", "dark"); } catch (e) {}
try { localStorage.setItem("daybook.theme", "dark"); } catch (e) {}
}
syncLabel();
});

View file

@ -6,12 +6,12 @@
<title>AgentFeed</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<link rel="stylesheet" href="/static/agentfeed.css">
<link rel="stylesheet" href="/static/daybook.css">
<script>
// Set the theme before first paint so dark-mode users never see a light flash.
(function () {
try {
if (localStorage.getItem("agentfeed.theme") === "dark") {
if (localStorage.getItem("daybook.theme") === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
}
} catch (e) {}
@ -27,7 +27,6 @@
data-selected-kind="{{ selected_kind }}"
data-title-query="{{ title_query }}"
data-selected-view="{{ selected_view }}"
data-folder="{{ selected_folder }}"
data-window-start="{{ window_start|date:'Y-m-d' }}"
data-older-count="{{ older_count }}">
<aside class="source-sidebar" aria-label="Sources">
@ -35,48 +34,33 @@
<h2>Sources</h2>
</div>
<nav class="source-list">
<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 }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}view={{ selected_view }}&amp;preview=none">
<a class="source-link {% if not selected_source %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}view={{ selected_view }}&amp;preview=none">
<span class="source-name">All sources</span>
{% if total_count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ total_count }}</span>{% endif %}
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
</a>
{% for entry in source_counts %}
<a class="source-link {% if entry.source == selected_source and not selected_folder %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&amp;label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;view={{ selected_view }}&amp;preview=none" data-source="{{ entry.source }}">
<a class="source-link {% if entry.source == selected_source %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&amp;label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;view={{ selected_view }}&amp;preview=none">
<span class="source-name">{{ entry.source }}</span>
{% if entry.count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ entry.count }}</span>{% endif %}
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
</a>
{% endfor %}
</nav>
<nav class="system-folders" aria-label="Folders">
<a class="source-link folder-link {% if selected_folder == 'keep' %}selected{% endif %}" href="?folder=keep&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="keep">
<span class="source-name">&#9733; 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&amp;view={{ selected_view }}&amp;preview=none" data-folder-link="deleted">
<span class="source-name">&#128465; Deleted</span>
{% if trash_count %}<span class="source-count">{{ trash_count }}</span>{% endif %}
</a>
</nav>
</aside>
<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">
<div class="pane-bar pane-heading">
<h2>{% if selected_folder == 'deleted' %}Deleted{% elif selected_folder == 'keep' %}Keep{% elif selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
{% 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 %}
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
</div>
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list data-since="{{ feed_since }}">
<div class="artifact-list{% if selected_source %} no-source-col{% endif %}" data-artifact-list>
{% if day_groups %}
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
{% else %}
<div class="empty compact">
<h3>No messages</h3>
<h3>No artifacts</h3>
<p>Nothing in the last {{ 7 }} days for these filters.</p>
</div>
{% endif %}
@ -88,7 +72,7 @@
<section class="preview-pane">
<header class="pane-bar preview-actions" data-actions>
<div class="preview-title" data-preview-title {% if selected_artifact %}title="{{ selected_artifact.title }}"{% endif %}>
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No message loaded{% endif %}
{% if selected_artifact %}{{ selected_artifact.title }}{% else %}No artifact loaded{% endif %}
</div>
<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>
@ -118,8 +102,8 @@
<img class="preview-image" src="{% url 'artifact' selected_artifact.id %}" alt="{{ selected_artifact.title }}">
{% elif selected_artifact.kind == "binary" %}
<div class="empty preview-empty">
<h2>Binary Message</h2>
<p>This message type cannot be previewed inline yet.</p>
<h2>Binary Artifact</h2>
<p>This artifact type cannot be previewed inline yet.</p>
<p><a href="{% url 'artifact' selected_artifact.id %}">Open or download it</a>.</p>
</div>
{% else %}
@ -129,10 +113,10 @@
{% else %}
<div class="empty preview-empty">
{% if artifacts %}
<h2>No message loaded</h2>
<p>Press <kbd>Right Arrow</kbd> or select a message from the middle column to preview it.</p>
<h2>No artifact loaded</h2>
<p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p>
{% else %}
<h2>No messages for this day</h2>
<h2>No artifacts for this day</h2>
<p>This day is ready for new agent output.</p>
{% endif %}
</div>
@ -141,25 +125,6 @@
</section>
</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">&#10003;</span>
</button>
{% endfor %}
</div>
{% endif %}
</div>
<script>
(() => {
const feed = document.querySelector(".feed");
@ -176,8 +141,8 @@
const sourceSelected = Boolean(feed.dataset.selectedSource);
const sizes = {
days: Number(localStorage.getItem("agentfeed.daysWidth")) || 144,
artifacts: Number(localStorage.getItem("agentfeed.artifactsWidth")) || 280,
days: Number(localStorage.getItem("daybook.daysWidth")) || 144,
artifacts: Number(localStorage.getItem("daybook.artifactsWidth")) || 280,
};
const applySizes = () => {
@ -212,7 +177,7 @@
};
const up = () => {
localStorage.setItem(`agentfeed.${target}Width`, String(sizes[target]));
localStorage.setItem(`daybook.${target}Width`, String(sizes[target]));
document.body.classList.remove("is-resizing");
splitter.removeEventListener("pointermove", move);
splitter.removeEventListener("pointerup", up);
@ -230,8 +195,7 @@
const artifactList = document.querySelector("[data-artifact-list]");
// Rows grow as older pages load, so always read them live rather than caching an array.
const allRows = () => Array.from(document.querySelectorAll(".artifact-row"));
// Live-queried so sources added by live updates are keyboard-navigable too.
const sourceLinks = () => Array.from(document.querySelectorAll(".source-link"));
const sourceLinks = Array.from(document.querySelectorAll(".source-link"));
const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
@ -245,7 +209,7 @@
try {
await navigator.clipboard.writeText(absoluteUrl);
} catch {
window.prompt("Copy message link", absoluteUrl);
window.prompt("Copy artifact link", absoluteUrl);
}
const original = button.textContent;
button.textContent = "Copied";
@ -354,7 +318,7 @@
if (!actions) return;
const previewTitle = actions.querySelector("[data-preview-title]");
if (previewTitle) {
previewTitle.textContent = "No message loaded";
previewTitle.textContent = "No artifact loaded";
previewTitle.removeAttribute("title");
}
actions.querySelectorAll("[data-view]").forEach((link) => {
@ -419,335 +383,6 @@
});
};
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) => {
navigationMode = "artifacts";
markRead(row);
@ -760,7 +395,7 @@
: kind === "image"
? `<img class="preview-image" src="${source}" alt="${title}">`
: kind === "binary"
? `<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>`
? `<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>`
: `<iframe class="preview-frame" src="${source}" title="${title}"></iframe>`;
previewBody.innerHTML = `<div class="preview-frame-wrap">${content}</div>`;
@ -775,7 +410,7 @@
}
allRows().forEach((candidate) => candidate.classList.remove("selected"));
disableActions();
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>`;
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>`;
window.history.pushState({}, "", noPreviewUrl());
const currentSource = document.querySelector(".source-link.selected");
if (currentSource) currentSource.focus({preventScroll: true});
@ -783,21 +418,6 @@
// Delegated so rows appended by infinite scroll work without rebinding.
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");
if (!row) return;
event.preventDefault();
@ -818,13 +438,12 @@
};
const moveSource = (direction) => {
const links = sourceLinks();
if (!links.length) return;
if (!sourceLinks.length) return;
navigationMode = "sources";
const current = document.querySelector(".source-link.selected");
const currentIndex = current ? links.indexOf(current) : 0;
const nextIndex = Math.max(0, Math.min(links.length - 1, currentIndex + direction));
const nextSource = links[nextIndex];
const currentIndex = current ? sourceLinks.indexOf(current) : 0;
const nextIndex = Math.max(0, Math.min(sourceLinks.length - 1, currentIndex + direction));
const nextSource = sourceLinks[nextIndex];
if (nextSource && nextSource !== current) window.location.href = nextSource.href;
};
@ -933,98 +552,6 @@
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
// "Details" click shows content immediately.
const preselected = selectedRow();

View file

@ -6,12 +6,12 @@
<title>Log in — AgentFeed</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<link rel="stylesheet" href="/static/agentfeed.css">
<link rel="stylesheet" href="/static/daybook.css">
<script>
// Set the theme before first paint so dark-mode users never see a light flash.
(function () {
try {
if (localStorage.getItem("agentfeed.theme") === "dark") {
if (localStorage.getItem("daybook.theme") === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
}
} catch (e) {}

View file

@ -4,17 +4,17 @@
<div class="about-head">
<img class="about-logo" src="/static/logo.png" alt="">
<div>
<h2>AgentFeed</h2>
<h2>Daybook</h2>
<p class="about-version">Version {{ version }}</p>
</div>
</div>
<p>A self-hosted, multi-user journal where AI agents, scripts, or people
publish messages — reports, HTML, decks, images, PDFs, JSON, CSV — and each
publish artifacts — reports, HTML, decks, images, PDFs, JSON, CSV — and each
logged-in user browses their own time-ordered, label-filterable feed. The
server owns IDs, styling, and storage; agents publish content, not files.</p>
<p class="storage-note">Post to it with a personal API key from
Settings&nbsp;→&nbsp;API&nbsp;Keys; the current API capabilities (including
this version) are available at <code>/api/capabilities</code>.</p>
<p class="storage-note">Post to it with a personal API token from
Settings&nbsp;→&nbsp;Account; the current API capabilities (including this
version) are available at <code>/api/capabilities</code>.</p>
{% endblock %}

View file

@ -4,33 +4,12 @@
<h2>Account</h2>
<p><strong>Username:</strong> {{ request.user.username }}</p>
<h3>API Keys</h3>
<p>
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 }}.
</p>
<p><a href="{% url 'settings-api-keys' %}">Manage API keys &rarr;</a></p>
<h3>Change password</h3>
<p>Enter your current password, then choose a new one.</p>
<form method="post" class="password-change">
<h3>API Token</h3>
<p>Use this token as <code>Authorization: Bearer &lt;token&gt;</code> when posting artifacts.</p>
<p><code class="token-value">{{ token.key }}</code></p>
<form method="post">
{% 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>
<input type="hidden" name="action" value="regenerate">
<button type="submit">Regenerate Token</button>
</form>
{% endblock %}

View file

@ -1,66 +0,0 @@
{% extends "artifacts/settings_base.html" %}
{% block settings_content %}
<h2>API Keys</h2>
<p>
One key per agent or script. Name each key so you know which agent it's
for, copy it into that agent's <code>AGENTFEED_TOKEN</code>, and revoke keys
individually here. Use a key as
<code>Authorization: Bearer &lt;key&gt;</code> when posting.
</p>
<h3>Create a key</h3>
<form method="post" class="api-key-create">
{% csrf_token %}
<input type="hidden" name="action" value="create">
<label>Name <input type="text" name="name" placeholder="e.g. hermes-agent" required autocomplete="off"></label>
<button type="submit">Create key</button>
</form>
<h3>Your keys</h3>
{% if api_keys %}
<table class="api-key-table">
<thead>
<tr><th>Name</th><th>Key</th><th>Created</th><th>Last used</th><th></th></tr>
</thead>
<tbody>
{% for k in api_keys %}
<tr>
<td>{{ k.name }}</td>
<td class="api-key-cell">
<code class="token-value">{{ k.key }}</code>
<button type="button" class="api-key-copy" data-copy="{{ k.key }}">Copy</button>
</td>
<td>{{ k.created_at|date:"Y-m-d" }}</td>
<td>{% if k.last_used_at %}{{ k.last_used_at|date:"Y-m-d H:i" }}{% else %}<span class="muted">never</span>{% endif %}</td>
<td>
<form method="post" onsubmit="return confirm('Revoke key &quot;{{ k.name|escapejs }}&quot;? Agents using it will stop working immediately.');">
{% csrf_token %}
<input type="hidden" name="action" value="revoke">
<input type="hidden" name="key_id" value="{{ k.pk }}">
<button type="submit" class="api-key-revoke">Revoke</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No keys yet. Create one above.</p>
{% endif %}
<script>
(() => {
document.querySelectorAll(".api-key-copy").forEach((btn) => {
btn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(btn.dataset.copy);
const prev = btn.textContent;
btn.textContent = "Copied";
setTimeout(() => { btn.textContent = prev; }, 1200);
} catch (e) { /* clipboard unavailable; the key is visible to select manually */ }
});
});
})();
</script>
{% endblock %}

View file

@ -6,12 +6,12 @@
<title>Settings — AgentFeed</title>
<link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<link rel="stylesheet" href="/static/agentfeed.css">
<link rel="stylesheet" href="/static/daybook.css">
<script>
// Set the theme before first paint so dark-mode users never see a light flash.
(function () {
try {
if (localStorage.getItem("agentfeed.theme") === "dark") {
if (localStorage.getItem("daybook.theme") === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
}
} catch (e) {}
@ -24,26 +24,13 @@
<main class="settings-layout">
<nav class="settings-nav">
<a class="settings-nav-back" href="{% url 'feed' %}">&larr; Back to Feed</a>
<div class="settings-nav-group">
<span class="settings-nav-heading">You</span>
<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>
<a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</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>
{% if request.user.is_superuser %}
<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>
<a class="{% if active_section == 'users' %}selected{% endif %}" href="{% url 'settings-users' %}">Users</a>
{% endif %}
<div class="settings-nav-group">
<a class="{% if active_section == 'about' %}selected{% endif %}" href="{% url 'settings-about' %}">About</a>
</div>
<a class="{% if active_section == 'about' %}selected{% endif %}" href="{% url 'settings-about' %}">About</a>
</nav>
<section class="settings-panel">
{% if messages %}

View file

@ -2,7 +2,7 @@
{% block settings_content %}
<h2>Demo Data</h2>
<p>Generate one sample message of every supported kind (markdown, HTML fragment, full HTML, deck, image, PDF, binary), tagged "demo" and dated today.</p>
<p>Generate one sample artifact of every supported kind (markdown, HTML fragment, full HTML, deck, image, PDF, binary), tagged "demo" and dated today.</p>
<form method="post">
{% csrf_token %}
<button type="submit">Generate Demo Data</button>

View file

@ -1,54 +0,0 @@
{% 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 &#8943; 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 &quot;{{ label.name|escapejs }}&quot; 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 &#8943; menu.</p>
{% endif %}
{% endblock %}

View file

@ -11,14 +11,14 @@
</p>
<ul class="storage-facts">
<li>{{ artifact_count }} message{{ artifact_count|pluralize }} total</li>
<li>{{ artifact_count }} artifact{{ artifact_count|pluralize }} total</li>
<li>{{ kept_count }} kept ({{ kept_bytes|filesizeformat }}) &mdash; never auto-deleted</li>
</ul>
<p class="storage-note">
When your quota fills, the oldest <strong>unkept</strong> messages are deleted
automatically to make room for new ones. Click <strong>Keep</strong> on a message
to protect it. If the quota is full of kept messages, new posts are rejected until
When your quota fills, the oldest <strong>unkept</strong> artifacts are deleted
automatically to make room for new ones. Click <strong>Keep</strong> on an artifact
to protect it. If the quota is full of kept artifacts, new posts are rejected until
you free space.
</p>
@ -26,7 +26,7 @@
<h3>Admin</h3>
<ul class="storage-facts">
<li>Total quota allocated across all users: <strong>{{ total_allocated|filesizeformat }}</strong></li>
<li>Free space on the storage disk: <strong>{{ disk_free|filesizeformat }}</strong></li>
<li>Free space on the artifact disk: <strong>{{ disk_free|filesizeformat }}</strong></li>
</ul>
{% if total_allocated > disk_free %}
<p class="storage-warning">

View file

@ -3,14 +3,13 @@
{% block settings_content %}
<h2>Users</h2>
{% if created_key %}
<p><strong>API key for {{ created_user.username }}</strong> (named "{{ created_key.name }}") — copy it now:</p>
<p><code class="token-value">{{ created_key.key }}</code></p>
<p class="muted">They can view, rename via new keys, and revoke it from Settings &rarr; API Keys.</p>
{% if created_token %}
<p><strong>API token for {{ created_user.username }}</strong> — shown once, copy it now:</p>
<p><code class="token-value">{{ created_token.key }}</code></p>
{% endif %}
<h3>Add User</h3>
<p>New users are regular accounts by default — they can log in and use their own API keys, but cannot reach Django admin unless you grant admin access below.</p>
<p>New users are regular accounts by default — they can log in and use their own token, but cannot reach Django admin unless you grant admin access below.</p>
<form method="post">
{% csrf_token %}
<p><label>Username<br><input type="text" name="username" required autocomplete="off"></label></p>

View file

@ -1,109 +0,0 @@
from django.contrib.auth import get_user_model
from django.test import override_settings
from django.urls import reverse
from rest_framework.test import APITestCase
from artifacts.models import ApiKey
User = get_user_model()
@override_settings(AGENTFEED_KEY_PROVISIONING_API=True)
class ApiKeyProvisioningEnabledTestCase(APITestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.bootstrap = ApiKey.objects.create(user=self.user, name="bootstrap")
self.other = User.objects.create_user(username="bob", password="pw")
def auth(self, key):
return {"HTTP_AUTHORIZATION": f"Bearer {key.key}"}
def test_create_key_with_token_auth(self):
response = self.client.post(
reverse("api-keys"), {"name": "hermes-agent"}, format="json", **self.auth(self.bootstrap)
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["name"], "hermes-agent")
self.assertTrue(response.data["key"].startswith("dbk_"))
self.assertTrue(ApiKey.objects.filter(user=self.user, name="hermes-agent").exists())
def test_created_key_can_authenticate(self):
created = self.client.post(
reverse("api-keys"), {"name": "worker"}, format="json", **self.auth(self.bootstrap)
).data
# The brand-new key works against another token-authed endpoint.
response = self.client.get(
reverse("capabilities-api"), HTTP_AUTHORIZATION=f"Bearer {created['key']}"
)
self.assertEqual(response.status_code, 200)
def test_create_requires_name(self):
response = self.client.post(
reverse("api-keys"), {"name": " "}, format="json", **self.auth(self.bootstrap)
)
self.assertEqual(response.status_code, 400)
def test_list_returns_only_own_keys(self):
ApiKey.objects.create(user=self.other, name="not-yours")
response = self.client.get(reverse("api-keys"), **self.auth(self.bootstrap))
self.assertEqual(response.status_code, 200)
names = {k["name"] for k in response.data}
self.assertEqual(names, {"bootstrap"})
def test_revoke_key(self):
victim = ApiKey.objects.create(user=self.user, name="doomed")
response = self.client.delete(
reverse("api-key-detail", args=[victim.pk]), **self.auth(self.bootstrap)
)
self.assertEqual(response.status_code, 204)
self.assertFalse(ApiKey.objects.filter(pk=victim.pk).exists())
def test_revoke_one_does_not_affect_others(self):
keep = ApiKey.objects.create(user=self.user, name="keep")
drop = ApiKey.objects.create(user=self.user, name="drop")
self.client.delete(reverse("api-key-detail", args=[drop.pk]), **self.auth(self.bootstrap))
self.assertTrue(ApiKey.objects.filter(pk=keep.pk).exists())
def test_cannot_revoke_another_users_key(self):
theirs = ApiKey.objects.create(user=self.other, name="theirs")
response = self.client.delete(
reverse("api-key-detail", args=[theirs.pk]), **self.auth(self.bootstrap)
)
self.assertEqual(response.status_code, 404)
self.assertTrue(ApiKey.objects.filter(pk=theirs.pk).exists())
def test_capabilities_advertises_provisioning(self):
response = self.client.get(reverse("capabilities-api"), **self.auth(self.bootstrap))
self.assertTrue(response.data["key_provisioning_api"])
self.assertEqual(response.data["methods"]["/api/keys"], ["GET", "POST"])
@override_settings(AGENTFEED_KEY_PROVISIONING_API=False)
class ApiKeyProvisioningDisabledTestCase(APITestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.key = ApiKey.objects.create(user=self.user, name="bootstrap")
def auth(self):
return {"HTTP_AUTHORIZATION": f"Bearer {self.key.key}"}
def test_create_disabled_returns_403(self):
response = self.client.post(
reverse("api-keys"), {"name": "nope"}, format="json", **self.auth()
)
self.assertEqual(response.status_code, 403)
self.assertFalse(ApiKey.objects.filter(name="nope").exists())
def test_list_disabled_returns_403(self):
response = self.client.get(reverse("api-keys"), **self.auth())
self.assertEqual(response.status_code, 403)
def test_revoke_disabled_returns_403(self):
response = self.client.delete(reverse("api-key-detail", args=[self.key.pk]), **self.auth())
self.assertEqual(response.status_code, 403)
self.assertTrue(ApiKey.objects.filter(pk=self.key.pk).exists())
def test_capabilities_hides_provisioning(self):
response = self.client.get(reverse("capabilities-api"), **self.auth())
self.assertFalse(response.data["key_provisioning_api"])
self.assertNotIn("/api/keys", response.data["methods"])

View file

@ -9,15 +9,14 @@ from django.contrib.auth import get_user_model
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase
from daybook_project import __version__
from artifacts.models import ApiKey, Artifact
from artifacts.models import Artifact
User = get_user_model()
@override_settings(AGENTFEED_ASYNC_PURGE=False) # DELETE triggers purge; run it inline in tests
class ArtifactAPITestCase(APITestCase):
"""Exercises the ingest/list/detail API against a real (temp) filesystem
and a real (test) database, the way an agent posting over HTTP would."""
@ -25,7 +24,7 @@ class ArtifactAPITestCase(APITestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-artifacts-"))
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-artifacts-"))
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
cls.settings_override.enable()
@ -37,9 +36,9 @@ class ArtifactAPITestCase(APITestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.token = ApiKey.objects.create(user=self.user, name="alice-key")
self.token = Token.objects.create(user=self.user)
self.other_user = User.objects.create_user(username="bob", password="pw")
self.other_token = ApiKey.objects.create(user=self.other_user, name="bob-key")
self.other_token = Token.objects.create(user=self.other_user)
def auth_headers(self, token):
return {"HTTP_AUTHORIZATION": f"Bearer {token.key}"}
@ -59,10 +58,9 @@ class ArtifactAPITestCase(APITestCase):
# -- capabilities -------------------------------------------------
def test_capabilities_is_public(self):
# Capability/contract info only, no user data — public, no auth needed.
def test_capabilities_requires_auth(self):
response = self.client.get(reverse("capabilities-api"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.status_code, 403)
def test_capabilities_with_token(self):
response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token))
@ -80,43 +78,6 @@ class ArtifactAPITestCase(APITestCase):
response = self.client.get(reverse("capabilities-api"))
self.assertEqual(response.status_code, 200)
def test_capabilities_advertises_skill(self):
response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token))
self.assertEqual(response.data["methods"]["/api/skill"], ["GET"])
self.assertTrue(response.data["skill_url"].endswith("/api/skill"))
# -- skill ----------------------------------------------------------
def test_skill_is_public(self):
# Bootstrapping instructions, no secrets — public, no auth needed.
response = self.client.get(reverse("skill-api"))
self.assertEqual(response.status_code, 200)
def test_skill_returns_markdown_with_origin_filled_in(self):
response = self.client.get(reverse("skill-api"), **self.auth_headers(self.token))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/markdown; charset=utf-8")
body = response.content.decode()
# Frontmatter is intact and the config placeholder is rewritten to this
# instance's origin (http://testserver under the test client).
self.assertIn("name: agentfeed-posting", body)
self.assertIn("AGENTFEED_URL=http://testserver", body)
self.assertNotIn("your-agentfeed-instance.example.com", body)
def test_skill_json_format(self):
response = self.client.get(
reverse("skill-api"), {"format": "json"}, **self.auth_headers(self.token)
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["version"], __version__)
self.assertEqual(response.data["url"], "http://testserver")
self.assertIn("name: agentfeed-posting", response.data["skill"])
def test_skill_with_session(self):
self.client.force_login(self.user)
response = self.client.get(reverse("skill-api"))
self.assertEqual(response.status_code, 200)
# -- create (POST) --------------------------------------------------
def test_create_markdown_artifact(self):
@ -159,10 +120,9 @@ class ArtifactAPITestCase(APITestCase):
# -- list (GET) -------------------------------------------------------
def test_list_requires_auth(self):
# No session and no token -> JSON 403, never a browser-login redirect.
def test_list_requires_session_login(self):
response = self.client.get(reverse("artifact-api"))
self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 302) # redirected to login
def test_list_scoped_to_owner(self):
self.create_artifact()
@ -173,15 +133,6 @@ class ArtifactAPITestCase(APITestCase):
self.assertEqual(response.status_code, 200)
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):
self.create_artifact(tags=["urgent"])
self.create_artifact(title="Second", tags=["later"])
@ -233,67 +184,6 @@ class ArtifactAPITestCase(APITestCase):
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
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):
artifact_id = self.create_artifact().data["id"]
response = self.client.patch(
@ -335,8 +225,7 @@ class ArtifactAPITestCase(APITestCase):
# -- delete (DELETE) --------------------------------------------------
def test_delete_soft_deletes_to_trash(self):
# DELETE moves the artifact to Trash (recoverable), not a hard removal.
def test_delete_removes_artifact_and_files(self):
artifact_id = self.create_artifact().data["id"]
artifact = Artifact.objects.get(pk=artifact_id)
stored_path = self.artifacts_root / artifact.path
@ -346,12 +235,9 @@ class ArtifactAPITestCase(APITestCase):
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(response.status_code, 204)
# Row still exists, marked deleted; files kept on disk until purge.
artifact.refresh_from_db()
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, [])
self.assertFalse(Artifact.objects.filter(pk=artifact_id).exists())
self.assertFalse(stored_path.exists())
self.assertFalse(stored_path.with_suffix(".md").exists())
def test_delete_requires_token(self):
artifact_id = self.create_artifact().data["id"]
@ -367,8 +253,7 @@ class ArtifactAPITestCase(APITestCase):
self.assertEqual(response.status_code, 404)
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists())
def test_delete_is_idempotent(self):
# Soft-delete is idempotent: deleting an already-trashed artifact is a no-op 204.
def test_delete_twice_second_call_404s(self):
artifact_id = self.create_artifact().data["id"]
first = self.client.delete(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
@ -378,7 +263,7 @@ class ArtifactAPITestCase(APITestCase):
second = self.client.delete(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(second.status_code, 204)
self.assertEqual(second.status_code, 404)
def test_deleted_artifact_is_gone_from_list_and_detail(self):
artifact_id = self.create_artifact().data["id"]
@ -521,7 +406,7 @@ class ArtifactAPITestCase(APITestCase):
self.assertEqual(stored_html.count("</script>"), 3)
# The escaped content must still round-trip to the exact original value.
start = stored_html.index('id="agentfeed-data">') + len('id="agentfeed-data">')
start = stored_html.index('id="daybook-data">') + len('id="daybook-data">')
end = stored_html.index("</script>", start)
embedded = stored_html[start:end]
self.assertEqual(json.loads(embedded)["value"], "</script><script>alert(1)</script>")

View file

@ -6,76 +6,76 @@ from pathlib import Path
SCRIPT_PATH = (
Path(__file__).resolve().parents[2]
/ "skills"
/ "agentfeed-posting"
/ "daybook-posting"
/ "scripts"
/ "agentfeed_post.py"
/ "daybook_post.py"
)
def _load_module():
spec = importlib.util.spec_from_file_location("agentfeed_post", SCRIPT_PATH)
spec = importlib.util.spec_from_file_location("daybook_post", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
agentfeed_post = _load_module()
daybook_post = _load_module()
class SniffTextKindTestCase(unittest.TestCase):
def test_markdown_suffixes(self):
for suffix in (".md", ".markdown", ".txt"):
self.assertEqual(agentfeed_post.sniff_text_kind(suffix, "anything"), "markdown")
self.assertEqual(daybook_post.sniff_text_kind(suffix, "anything"), "markdown")
def test_json_suffix(self):
self.assertEqual(agentfeed_post.sniff_text_kind(".json", '{"a": 1}'), "json")
self.assertEqual(daybook_post.sniff_text_kind(".json", '{"a": 1}'), "json")
def test_csv_suffix(self):
self.assertEqual(agentfeed_post.sniff_text_kind(".csv", "a,b\n1,2"), "csv")
self.assertEqual(daybook_post.sniff_text_kind(".csv", "a,b\n1,2"), "csv")
def test_html_full_sniffed_from_content(self):
self.assertEqual(
agentfeed_post.sniff_text_kind(".html", "<!doctype html><html></html>"), "html_full"
daybook_post.sniff_text_kind(".html", "<!doctype html><html></html>"), "html_full"
)
def test_deck_sniffed_from_content(self):
self.assertEqual(agentfeed_post.sniff_text_kind(".html", "<section>slide</section>"), "deck")
self.assertEqual(daybook_post.sniff_text_kind(".html", "<section>slide</section>"), "deck")
def test_html_fragment_is_the_fallback(self):
self.assertEqual(agentfeed_post.sniff_text_kind(".html", "<p>hi</p>"), "html_fragment")
self.assertEqual(daybook_post.sniff_text_kind(".html", "<p>hi</p>"), "html_fragment")
class ValidatePayloadTestCase(unittest.TestCase):
def test_valid_json_passes(self):
agentfeed_post.validate_payload({"kind": "json", "content": '{"a": 1}'}, None) # no raise
daybook_post.validate_payload({"kind": "json", "content": '{"a": 1}'}, None) # no raise
def test_invalid_json_raises(self):
with self.assertRaises(ValueError):
agentfeed_post.validate_payload({"kind": "json", "content": "{not json"}, None)
daybook_post.validate_payload({"kind": "json", "content": "{not json"}, None)
def test_deck_without_section_raises(self):
with self.assertRaises(ValueError):
agentfeed_post.validate_payload({"kind": "deck", "content": "<h1>no slides</h1>"}, None)
daybook_post.validate_payload({"kind": "deck", "content": "<h1>no slides</h1>"}, None)
def test_html_fragment_with_nested_html_raises(self):
with self.assertRaises(ValueError):
agentfeed_post.validate_payload(
daybook_post.validate_payload(
{"kind": "html_fragment", "content": "<html><body>x</body></html>"}, None
)
def test_html_full_without_html_tag_raises(self):
with self.assertRaises(ValueError):
agentfeed_post.validate_payload({"kind": "html_full", "content": "<p>x</p>"}, None)
daybook_post.validate_payload({"kind": "html_full", "content": "<p>x</p>"}, None)
def test_blank_markdown_raises(self):
with self.assertRaises(ValueError):
agentfeed_post.validate_payload({"kind": "markdown", "content": " "}, None)
daybook_post.validate_payload({"kind": "markdown", "content": " "}, None)
def test_csv_has_no_shape_check(self):
agentfeed_post.validate_payload({"kind": "csv", "content": "anything at all"}, None) # no raise
daybook_post.validate_payload({"kind": "csv", "content": "anything at all"}, None) # no raise
def test_upload_path_skips_all_checks(self):
agentfeed_post.validate_payload({"upload_path": Path("whatever.png")}, None) # no raise
daybook_post.validate_payload({"upload_path": Path("whatever.png")}, None) # no raise
class ResolveAutoPathTestCase(unittest.TestCase):
@ -93,7 +93,7 @@ class ResolveAutoPathTestCase(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "data.json"
path.write_text('{"a": 1}', encoding="utf-8")
args = agentfeed_post.resolve_auto_path(self._args(path))
args = daybook_post.resolve_auto_path(self._args(path))
self.assertEqual(args.kind, "json")
self.assertEqual(args.content_file, str(path))
@ -101,14 +101,14 @@ class ResolveAutoPathTestCase(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "data.csv"
path.write_text("a,b\n1,2", encoding="utf-8")
args = agentfeed_post.resolve_auto_path(self._args(path))
args = daybook_post.resolve_auto_path(self._args(path))
self.assertEqual(args.kind, "csv")
def test_image_file_becomes_multipart_upload(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "chart.png"
path.write_bytes(b"\x89PNG\r\n")
args = agentfeed_post.resolve_auto_path(self._args(path))
args = daybook_post.resolve_auto_path(self._args(path))
self.assertIsNone(args.kind)
self.assertEqual(args.file, str(path))

View file

@ -16,7 +16,7 @@ class MakeRoomForTestCase(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-storage-"))
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-storage-"))
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
cls.settings_override.enable()

View file

@ -1,75 +0,0 @@
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"])

View file

@ -1,54 +0,0 @@
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"))

View file

@ -1,45 +0,0 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from artifacts.models import ApiKey
User = get_user_model()
class SettingsApiKeysViewTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.client.force_login(self.user)
def test_requires_login(self):
self.client.logout()
response = self.client.get(reverse("settings-api-keys"))
self.assertEqual(response.status_code, 302)
def test_create_key(self):
response = self.client.post(
reverse("settings-api-keys"), {"action": "create", "name": "hermes-agent"}, follow=True
)
self.assertEqual(response.status_code, 200)
self.assertTrue(ApiKey.objects.filter(user=self.user, name="hermes-agent").exists())
def test_create_requires_name(self):
self.client.post(reverse("settings-api-keys"), {"action": "create", "name": " "})
self.assertFalse(self.user.api_keys.exists())
def test_key_value_is_viewable_in_list(self):
key = ApiKey.objects.create(user=self.user, name="visible")
response = self.client.get(reverse("settings-api-keys"))
self.assertContains(response, key.key) # view-anytime: full key rendered
def test_revoke_key(self):
key = ApiKey.objects.create(user=self.user, name="doomed")
self.client.post(reverse("settings-api-keys"), {"action": "revoke", "key_id": key.pk})
self.assertFalse(ApiKey.objects.filter(pk=key.pk).exists())
def test_cannot_revoke_another_users_key(self):
other = User.objects.create_user(username="bob", password="pw")
theirs = ApiKey.objects.create(user=other, name="theirs")
self.client.post(reverse("settings-api-keys"), {"action": "revoke", "key_id": theirs.pk})
self.assertTrue(ApiKey.objects.filter(pk=theirs.pk).exists())

View file

@ -1,113 +0,0 @@
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)

View file

@ -1,7 +1,7 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from artifacts.models import ApiKey
from rest_framework.authtoken.models import Token
User = get_user_model()
@ -38,8 +38,8 @@ class SettingsUsersViewTestCase(TestCase):
self.assertTrue(created.check_password("hunter2pw"))
self.assertFalse(created.is_superuser)
self.assertFalse(created.is_staff)
self.assertTrue(ApiKey.objects.filter(user=created).exists())
self.assertContains(response, ApiKey.objects.get(user=created).key)
self.assertTrue(Token.objects.filter(user=created).exists())
self.assertContains(response, Token.objects.get(user=created).key)
def test_superuser_can_grant_admin(self):
self.client.force_login(self.admin)

View file

@ -6,8 +6,9 @@ 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 rest_framework.authtoken.models import Token
from artifacts.models import ApiKey, Artifact, Asset
from artifacts.models import Artifact, Asset
User = get_user_model()
@ -16,7 +17,7 @@ class SharingTestCase(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="agentfeed-test-sharing-"))
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-sharing-"))
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
cls.settings_override.enable()
@ -172,7 +173,7 @@ class SharingTestCase(TestCase):
# -- API surface --------------------------------------------------------
def test_api_detail_reports_share_status(self):
token = ApiKey.objects.create(user=self.owner, name="owner-key")
token = Token.objects.create(user=self.owner)
self.client.force_login(self.owner)
self.toggle_share(self.client)
self.artifact.refresh_from_db()

View file

@ -1,158 +0,0 @@
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)