import csv import hashlib import html import io import json import mimetypes import re import secrets from datetime import timedelta from pathlib import Path from urllib.parse import urlencode import markdown import nh3 import openpyxl from django.conf import settings from django.contrib import messages from django.contrib.auth import views as auth_views from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.db import OperationalError, transaction 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.urls import reverse from django.utils.dateparse import parse_date, parse_datetime from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.http import require_POST from rest_framework.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, TagColor from .storage import ( QuotaExceeded, SIDECAR_SUFFIXES, artifact_disk_size, delete_artifact_files, disk_free_bytes, make_room_for, purge_trashed, sweep_orphan_assets, total_allocated_bytes, user_quota_bytes, user_usage_bytes, ) TEXT_KINDS = { Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK, Artifact.JSON, Artifact.CSV, } # Affects storage layout, renderer choice, or feed ordering - never editable after create. IMMUTABLE_PATCH_FIELDS = {"content", "source", "kind", "day"} 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 ASSET_URL_RE = re.compile(r"/assets/([0-9a-f]{12})") def filter_by_label(queryset, label): matching_ids = [artifact.id for artifact in queryset.only("id", "tags") if label in artifact.tags] return queryset.filter(pk__in=matching_ids) DEFAULT_THEMES = { Artifact.HTML_FRAGMENT: "report", Artifact.MARKDOWN: "report", Artifact.DECK: "deck", } 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) if source := request.query_params.get("source"): artifacts = artifacts.filter(source=source) if label := request.query_params.get("label"): artifacts = filter_by_label(artifacts, label) if day := request.query_params.get("day"): artifacts = artifacts.filter(day=day) if kind := request.query_params.get("kind"): artifacts = artifacts.filter(kind=kind) if title_query := request.query_params.get("q"): artifacts = artifacts.filter(title__icontains=title_query) return Response([serialize_artifact(artifact, request) for artifact in artifacts]) def post(self, request): user = resolve_token_user(request) if user is None: return HttpResponseForbidden("Invalid or missing API token") if request.content_type and request.content_type.startswith("multipart/"): artifact = create_binary_artifact(request.data, request, user) if isinstance(artifact, Response): return artifact apply_tag_colors(request.data) return Response(serialize_artifact(artifact, request), status=201) data = request.data kind = data.get("kind") if kind not in TEXT_KINDS: return Response( {"detail": "kind must be html_fragment, html_full, markdown, deck, json, or csv"}, status=400, ) required = ["source", "title", "content"] missing = [field for field in required if not data.get(field)] if missing: return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400) source_error = validate_source_fields(data) if source_error: return source_error content_error = validate_content_shape(kind, data["content"]) if content_error: return content_error artifact = create_text_artifact(data, request, user) if isinstance(artifact, Response): return artifact apply_tag_colors(data) return Response(serialize_artifact(artifact, request), status=201) class AssetUploadView(APIView): """Upload an image for embedding inside artifact content. Returns a stable URL the agent puts in its markdown/fragment. The asset is attached to whichever artifact later references that URL. """ parser_classes = [MultiPartParser, FormParser] def post(self, request): user = resolve_token_user(request) if user is None: return HttpResponseForbidden("Invalid or missing API token") uploaded = request.data.get("file") if not uploaded: return Response({"detail": "Missing required field: file"}, status=400) content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or "" if not content_type.startswith("image/"): return Response({"detail": "Only image uploads are supported."}, status=400) if uploaded.size > MAX_ASSET_BYTES: return Response( {"detail": f"Image exceeds the {MAX_ASSET_BYTES // (1024 * 1024)} MB limit."}, status=413, ) sweep_orphan_assets(user) asset_id = generate_asset_id() suffix = safe_suffix(uploaded.name, content_type) directory = Path("_assets") / timezone.localdate().isoformat() (settings.ARTIFACTS_ROOT / directory).mkdir(parents=True, exist_ok=True) stored_path = directory / f"{asset_id}{suffix}" with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target: for chunk in uploaded.chunks(): target.write(chunk) size_bytes = (settings.ARTIFACTS_ROOT / stored_path).stat().st_size try: make_room_for(user, size_bytes) except QuotaExceeded as exc: (settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True) return Response({"detail": str(exc)}, status=413) asset = Asset.objects.create( id=asset_id, owner=user, path=stored_path.as_posix(), content_type=content_type, size_bytes=size_bytes, ) return Response( { "id": asset.id, "url": request.build_absolute_uri(reverse("asset", args=[asset.id])), "content_type": asset.content_type, "size_bytes": asset.size_bytes, }, status=201, ) @xframe_options_sameorigin def asset_view(request, asset_id): asset = get_object_or_404(Asset, pk=asset_id) is_owner = request.user.is_authenticated and asset.owner_id == request.user.id # Assets are only reachable via a URL embedded in their parent artifact's # content, so if that artifact is currently shared, its assets are # effectively public too - otherwise a shared artifact's inline images # would break for anyone who isn't logged in as the owner. is_via_shared_artifact = asset.artifact_id is not None and bool(asset.artifact.share_token) if not (is_owner or is_via_shared_artifact): raise Http404("Asset file not found") path = safe_artifact_path(asset.path) if not path.exists(): raise Http404("Asset file not found") return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream") 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) return Response(serialize_artifact(artifact, request)) def patch(self, request, artifact_id): user = resolve_token_user(request) 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) data = request.data not_editable = IMMUTABLE_PATCH_FIELDS & set(data.keys()) if not_editable: return Response( {"detail": f"Not editable via PATCH: {', '.join(sorted(not_editable))}"}, status=400, ) length_error = validate_field_lengths(data, ["title", "source_name", "source_id"]) if length_error: return length_error string_error = validate_string_fields(data, ["source_name", "source_id", "source_prompt"]) if string_error: return string_error if "title" in data: if not data["title"]: return Response({"detail": "title cannot be blank"}, status=400) artifact.title = data["title"] if "tags" in data: if not isinstance(data["tags"], (str, list)): return Response({"detail": "tags must be a string or an array"}, status=400) artifact.tags = normalize_tags(data["tags"]) if "source_prompt" in data: artifact.source_prompt = data["source_prompt"] or "" if "source_type" in data: if data["source_type"] not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}: return Response({"detail": "source_type must be agent, person, or system"}, status=400) artifact.source_type = data["source_type"] if "source_name" in data: artifact.source_name = data["source_name"] or "" if "source_id" in data: artifact.source_id = data["source_id"] or "" if "metadata" in data: if not isinstance(data["metadata"], dict): return Response({"detail": "metadata must be an object"}, status=400) artifact.metadata = data["metadata"] if "tag_colors" in data: try: apply_tag_colors(data) except ValueError as exc: return Response({"detail": str(exc)}, status=400) artifact.save( update_fields=[ "title", "tags", "source_prompt", "source_type", "source_name", "source_id", "metadata", ] ) return Response(serialize_artifact(artifact, request)) def delete(self, request, artifact_id): user = resolve_token_user(request) 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"]) 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. 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__, "artifact_kinds": [choice[0] for choice in Artifact.KIND_CHOICES], "filters": ["source", "label", "day", "kind", "q"], "views": ["rendered", "raw"], "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, } ) 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 def apply_feed_filters(queryset, source="", label="", kind="", title_query=""): if source: queryset = queryset.filter(source=source) if label: queryset = filter_by_label(queryset, label) if kind: queryset = queryset.filter(kind=kind) if title_query: queryset = queryset.filter(title__icontains=title_query) return queryset def group_artifacts_by_day(artifacts, overrides): """Turn a flat, reverse-chronological list into [{day, artifacts}] groups.""" groups = [] for artifact in artifacts: # Source has its own column in the row, so chips are the free-form tags only. artifact.label_chips = tags_with_colors(artifact.tags, overrides) if not groups or groups[-1]["day"] != artifact.day: groups.append({"day": artifact.day, "artifacts": []}) groups[-1]["artifacts"].append(artifact) return groups @login_required def feed(request): selected_source = request.GET.get("source", "") selected_label = request.GET.get("label", "") selected_kind = request.GET.get("kind", "") title_query = request.GET.get("q", "") today = timezone.localdate() 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 artifacts = apply_feed_filters( base, 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() overrides = tag_color_overrides() day_groups = group_artifacts_by_day(window_artifacts, overrides) selected_artifact = None if not preview_none: 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: toggle_query["label"] = selected_label if selected_kind: toggle_query["kind"] = selected_kind if title_query: toggle_query["q"] = title_query if selected_artifact: toggle_query["artifact"] = selected_artifact.id rendered_url = f"?{urlencode(toggle_query)}" 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_scoped = apply_feed_filters( live, "", selected_label, selected_kind, title_query ) source_counts = ( source_scoped.values("source") .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}) return render( request, "artifacts/feed.html", { "artifacts": window_artifacts, "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, "selected_artifact": selected_artifact, "selected_kind": selected_kind, "selected_label": selected_label, "selected_source": selected_source, "selected_view": selected_view, "preview_none": preview_none, "today": today, "title_query": title_query, "kind_choices": Artifact.KIND_CHOICES, }, ) @login_required @require_POST def mark_artifact_read(request, artifact_id): artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) if artifact.read_at is None: artifact.read_at = timezone.now() artifact.save(update_fields=["read_at"]) return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()}) @login_required @require_POST def toggle_artifact_keep(request, artifact_id): artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) artifact.is_kept = not artifact.is_kept artifact.save(update_fields=["is_kept"]) return JsonResponse({"id": artifact.id, "is_kept": artifact.is_kept}) @login_required @require_POST def toggle_artifact_share(request, artifact_id): artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) if artifact.share_token: artifact.share_token = None else: artifact.share_token = secrets.token_urlsafe(32) artifact.save(update_fields=["share_token"]) share_url = ( request.build_absolute_uri(reverse("artifact-shared", args=[artifact.share_token])) if artifact.share_token else None ) 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"]) 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 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), request.GET.get("source", ""), request.GET.get("label", ""), request.GET.get("kind", ""), request.GET.get("q", ""), ).filter(day__lt=before).order_by("-created_at") # Take a whole page of days at a time so a day is never split across pages. page_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS]) if not page_days: return HttpResponse("") page_artifacts = list(artifacts.filter(day__gte=page_days[-1])) day_groups = group_artifacts_by_day(page_artifacts, tag_color_overrides()) remaining = artifacts.filter(day__lt=page_days[-1]).count() return render( request, "artifacts/_day_groups.html", { "day_groups": day_groups, "next_before": page_days[-1].isoformat(), "has_more": remaining > 0, "selected_artifact": None, "selected_source": request.GET.get("source", ""), "selected_label": request.GET.get("label", ""), "selected_kind": request.GET.get("kind", ""), "title_query": request.GET.get("q", ""), "selected_view": "raw" if request.GET.get("view") == "raw" else "rendered", "today": timezone.localdate(), }, ) @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): artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) return serve_artifact_file(request, artifact) @xframe_options_sameorigin def shared_artifact_view(request, share_token): artifact = get_object_or_404(Artifact, share_token=share_token) return serve_artifact_file(request, artifact) def serve_artifact_file(request, artifact): if request.GET.get("raw") == "1": 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") return HttpResponse(raw_unavailable_html(artifact), content_type="text/html") path = safe_artifact_path(artifact.path) if not path.exists(): raise Http404("Artifact file not found") content_type, _ = mimetypes.guess_type(path.name) response = FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream") # The sanitized kinds legitimately contain zero scripts, so a # script-blocking CSP is both safe and strong here — and because it's a # response header it also neutralizes any script in artifacts stored # *before* create-time sanitization existed. Not applied to html_full # (poster's own scripts by contract) or json/csv (their viewers need JS; # their user data is separately escaped). if artifact.kind in SANITIZED_KINDS: response["Content-Security-Policy"] = ( "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'" ) return response def create_text_artifact(data, request, user): now = timezone.localtime() artifact_id = generate_id() source = slugify(data["source"]) or "unknown" kind = data["kind"] theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base") title = data["title"] content = data["content"] directory = Path(source) / now.date().isoformat() target_dir = settings.ARTIFACTS_ROOT / directory target_dir.mkdir(parents=True, exist_ok=True) if kind == Artifact.HTML_FULL: # Deliberately verbatim: html_full is the "poster owns everything, # including risk" kind. Not sanitized. See issue #014. rendered = content elif kind == Artifact.MARKDOWN: md_html = markdown.markdown( content, extensions=["extra", "sane_lists", "codehilite"], extension_configs={"codehilite": {"guess_lang": False}}, ) rendered = wrap_html(sanitize_html(md_html), title, theme, request) elif kind == Artifact.DECK: rendered = wrap_deck(sanitize_html(content), title, request) elif kind == Artifact.JSON: rendered = wrap_json_viewer(content, title, request) elif kind == Artifact.CSV: rendered = wrap_csv_viewer(content, title, request) else: # html_fragment rendered = wrap_html(sanitize_html(content), title, theme, request) sidecar_suffix = SIDECAR_SUFFIXES.get(kind) if sidecar_suffix: (target_dir / f"{artifact_id}{sidecar_suffix}").write_text(content, encoding="utf-8") stored_path = directory / f"{artifact_id}.html" (settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8") def cleanup_files(): (settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True) if sidecar_suffix: (target_dir / f"{artifact_id}{sidecar_suffix}").unlink(missing_ok=True) size_bytes = artifact_disk_size(stored_path.as_posix(), kind) try: with transaction.atomic(): make_room_for(user, size_bytes) artifact = Artifact.objects.create( id=artifact_id, owner=user, source=source, title=title, kind=kind, theme=theme, day=now.date(), path=stored_path.as_posix(), original_kind=kind, tags=normalize_tags(data.get("tags", [])), source_prompt=data.get("source_prompt", ""), size_bytes=size_bytes, **source_fields(data, source), ) except QuotaExceeded as exc: cleanup_files() return Response({"detail": str(exc)}, status=413) except Exception: cleanup_files() raise attach_referenced_assets(artifact, content) return artifact XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" def is_xlsx_upload(uploaded, content_type): if content_type == XLSX_CONTENT_TYPE: return True return Path(uploaded.name).suffix.lower() == ".xlsx" def xlsx_to_csv_text(uploaded): """Converts the active sheet of an uploaded .xlsx workbook to CSV text. Only the active sheet is used - other sheets in a multi-sheet workbook are silently dropped. data_only=True reads cached formula *results* rather than formula source, since a results grid is what's useful here. """ workbook = openpyxl.load_workbook(io.BytesIO(uploaded.read()), data_only=True, read_only=True) sheet = workbook.active buffer = io.StringIO() # lineterminator="\n": csv.writer defaults to "\r\n", which then gets a # second newline translation from Path.write_text()'s own text-mode # handling when this string is saved to disk, producing a blank line # between every row on Windows. writer = csv.writer(buffer, lineterminator="\n") for row in sheet.iter_rows(values_only=True): writer.writerow(["" if cell is None else cell for cell in row]) return buffer.getvalue() def create_binary_artifact(data, request, user): uploaded = data.get("file") required = ["source", "title"] missing = [field for field in required if not data.get(field)] if not uploaded: missing.append("file") if missing: return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400) source_error = validate_source_fields(data) if source_error: return source_error title = data["title"] content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or "" if is_xlsx_upload(uploaded, content_type): try: content = xlsx_to_csv_text(uploaded) except Exception as exc: return Response({"detail": f"Could not read .xlsx file: {exc}"}, status=400) # Built as a plain dict via .get() rather than {**data, ...}: `data` is # a QueryDict for multipart uploads, and spreading a QueryDict with ** # bypasses its __getitem__ override and exposes its internal # one-value-per-list storage directly, silently wrapping every value # (title included) in a list. text_data = {"source": data.get("source"), "title": data.get("title")} for optional_field in ( "tags", "source_type", "source_name", "source_id", "source_prompt", "metadata", ): # Only set if actually provided - create_text_artifact's own # data.get(field, default) calls need the key absent, not None, # to fall through to their intended defaults. value = data.get(optional_field) if value is not None: text_data[optional_field] = value text_data["kind"] = Artifact.CSV text_data["content"] = content return create_text_artifact(text_data, request, user) now = timezone.localtime() artifact_id = generate_id() source = slugify(data["source"]) or "unknown" kind = binary_kind(content_type) directory = Path(source) / now.date().isoformat() target_dir = settings.ARTIFACTS_ROOT / directory target_dir.mkdir(parents=True, exist_ok=True) suffix = safe_suffix(uploaded.name, content_type) stored_path = directory / f"{artifact_id}{suffix}" with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target: for chunk in uploaded.chunks(): target.write(chunk) size_bytes = artifact_disk_size(stored_path.as_posix(), kind) try: with transaction.atomic(): make_room_for(user, size_bytes) artifact = Artifact.objects.create( id=artifact_id, owner=user, source=source, title=title, kind=kind, theme="", day=now.date(), path=stored_path.as_posix(), original_kind=kind, tags=normalize_tags(data.get("tags", [])), source_prompt=data.get("source_prompt", ""), size_bytes=size_bytes, **source_fields(data, source), ) except QuotaExceeded as exc: (settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True) return Response({"detail": str(exc)}, status=413) except Exception: (settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True) raise return artifact # Allowlist for sanitizing the "themed" text kinds (markdown, deck, # html_fragment) — kinds where AgentFeed, not the poster, owns presentation, so # they must never be a script-execution vector (issue #014). html_full is the # deliberate exception (verbatim, poster owns everything) and is NOT run # through this. The list keeps ordinary formatting + tables + code-highlight # markup and drops everything else (script, on* handlers, style, unknown tags). SANITIZE_ALLOWED_TAGS = { "a", "abbr", "b", "blockquote", "br", "caption", "code", "col", "colgroup", "dd", "del", "details", "div", "dl", "dt", "em", "figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "ins", "kbd", "li", "mark", "ol", "p", "pre", "s", "section", "small", "span", "strong", "sub", "summary", "sup", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "u", "ul", } SANITIZE_ALLOWED_ATTRS = { "*": {"class", "id", "title"}, "a": {"href", "title"}, # nh3 adds rel="noopener noreferrer" itself "img": {"src", "alt", "title", "width", "height"}, "td": {"colspan", "rowspan"}, "th": {"colspan", "rowspan", "scope"}, "col": {"span"}, "colgroup": {"span"}, } # Kinds whose stored HTML is sanitized at create time and served with a # script-blocking CSP (they legitimately contain no JS of their own). SANITIZED_KINDS = {Artifact.MARKDOWN, Artifact.DECK, Artifact.HTML_FRAGMENT} def sanitize_html(body): """Allowlist-sanitize author-influenced HTML. nh3 parses to a DOM and keeps only known-safe nodes (never a blocklist), and re-serializes well-formed HTML as a side effect. javascript:/data: URLs are dropped by restricting url_schemes; relative URLs (e.g. /assets/ images) pass through.""" return nh3.clean( body, tags=SANITIZE_ALLOWED_TAGS, attributes=SANITIZE_ALLOWED_ATTRS, url_schemes={"http", "https", "mailto"}, ) def wrap_html(body, title, theme, request): stylesheet = request.build_absolute_uri(f"/static/themes/{theme}.css") pygments_stylesheet = request.build_absolute_uri("/static/themes/pygments.css") return f""" {html.escape(title)} {body} """ def wrap_deck(body, title, request): stylesheet = request.build_absolute_uri("/static/themes/deck.css") return f""" {html.escape(title)}
{body}
""" SCRIPT_CLOSE_RE = re.compile(r" tag it's placed inside. Safe specifically for JSON text: JSON's grammar treats `\\/` as an escaped '/', so this round-trips correctly through JSON.parse - it is not a general-purpose HTML/JS escaping technique. """ return SCRIPT_CLOSE_RE.sub("<\\/script", text) def wrap_json_viewer(content, title, request): renderjson_src = request.build_absolute_uri("/static/vendor/renderjson/renderjson.js") safe_json = escape_script_body(content) return f""" {html.escape(title)}
""" def wrap_csv_viewer(content, title, request): """Parses CSV server-side into columns/rows and hands Grid.js plain data - no client-side CSV parsing plugin needed, keeping normalization server-owned. """ gridjs_js = request.build_absolute_uri("/static/vendor/gridjs/gridjs.umd.js") gridjs_css = request.build_absolute_uri("/static/vendor/gridjs/mermaid.min.css") rows = [row for row in csv.reader(io.StringIO(content)) if row] # Normalize ragged rows to a single width so no cell is ever dropped by # Grid.js (which keys body cells by column). Widen the header for rows # longer than it (auto-naming the extra columns), and pad short rows with # empty strings. Issue #016. width = max((len(row) for row in rows), default=0) header = rows[0] if rows else [] columns = [header[i] if i < len(header) else f"Column {i + 1}" for i in range(width)] data = [[row[i] if i < len(row) else "" for i in range(width)] for row in rows[1:]] table_json = escape_script_body(json.dumps({"columns": columns, "data": data})) return f""" {html.escape(title)}
""" def generate_id(): while True: artifact_id = secrets.token_hex(4) if not Artifact.objects.filter(pk=artifact_id).exists(): return artifact_id def generate_asset_id(): while True: asset_id = secrets.token_hex(6) if not Asset.objects.filter(pk=asset_id).exists(): return asset_id def attach_referenced_assets(artifact, content): """Link assets whose URLs appear in the artifact's content. Assets are uploaded before the artifact exists, so this runs at creation time and ties each referenced asset to the artifact that embeds it. """ referenced = set(ASSET_URL_RE.findall(content or "")) if not referenced: return 0 return Asset.objects.filter( owner=artifact.owner, id__in=referenced, artifact__isnull=True ).update(artifact=artifact) def resolve_token_user(request): auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): return None key = auth[len("Bearer "):].strip() if not key: return None try: api_key = ApiKey.objects.select_related("user").get(key=key) except ApiKey.DoesNotExist: return None # Stamp last-used so stale keys are visible in the UI, but throttle the # write to at most once a minute to avoid a DB write on every request. now = timezone.now() if api_key.last_used_at is None or (now - api_key.last_used_at).total_seconds() > 60: ApiKey.objects.filter(pk=api_key.pk).update(last_used_at=now) return api_key.user def resolve_request_user(request): """The acting user from either a browser session or a bearer API key. Used by the JSON read endpoints so agents can read back with the same token they post with (owner-scoped). None if neither authenticates.""" if request.user.is_authenticated: return request.user return resolve_token_user(request) def normalize_tags(tags): if isinstance(tags, str): return [tag.strip() for tag in tags.split(",") if tag.strip()] if isinstance(tags, list): return [str(tag).strip() for tag in tags if str(tag).strip()] return [] HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") TAG_PALETTE = [ "#4f6df5", "#e0578c", "#2fa88f", "#d98b2b", "#8c5fd6", "#3fa1d8", "#c14f4f", "#5aa552", ] def tag_color(name, overrides): if override := overrides.get(name): return override index = int(hashlib.sha256(name.encode("utf-8")).hexdigest(), 16) % len(TAG_PALETTE) return TAG_PALETTE[index] def tag_color_overrides(): return dict(TagColor.objects.values_list("name", "color")) def tags_with_colors(tags, overrides=None): overrides = overrides if overrides is not None else tag_color_overrides() return [{"name": tag, "color": tag_color(tag, overrides)} for tag in tags] def labels_with_colors_list(labels, overrides=None): overrides = overrides if overrides is not None else tag_color_overrides() return [{"name": label, "color": tag_color(label, overrides)} for label in labels] def labels_with_colors(artifact, overrides=None): return labels_with_colors_list([artifact.source, *artifact.tags], overrides) FIELD_LENGTH_LIMITS = { "source": 80, "title": 200, "source_name": 120, "source_id": 120, "theme": 40, } def validate_field_lengths(data, fields): for field in fields: value = data.get(field) if value and len(str(value)) > FIELD_LENGTH_LIMITS[field]: return Response( {"detail": f"{field} must be {FIELD_LENGTH_LIMITS[field]} characters or fewer"}, status=400, ) return None def validate_string_fields(data, fields): """Rejects non-string JSON types (object/array/number) instead of letting them reach save(), where Django's CharField/TextField silently coerce via str() - which for a dict/list produces Python repr() syntax, not JSON, and is irreversible. """ for field in fields: value = data.get(field) if value is not None and not isinstance(value, str): return Response({"detail": f"{field} must be a string"}, status=400) return None def validate_content_shape(kind, content): lower = (content or "").lower() if kind == Artifact.DECK and "... blocks"}, status=400 ) if kind == Artifact.HTML_FRAGMENT and any(tag in lower for tag in (""}, status=400 ) if kind == Artifact.MARKDOWN and not (content or "").strip(): return Response({"detail": "markdown content cannot be blank"}, status=400) if kind == Artifact.JSON: try: json.loads(content) # RecursionError (deeply nested JSON) is a RuntimeError, not a # ValueError, so it must be caught explicitly or it becomes a 500. except (TypeError, ValueError, RecursionError) as exc: return Response({"detail": f"content must be valid JSON: {exc}"}, status=400) return None def validate_source_fields(data): length_error = validate_field_lengths(data, ["source", "title", "source_name", "source_id", "theme"]) if length_error: return length_error string_error = validate_string_fields(data, ["source_name", "source_id", "source_prompt"]) if string_error: return string_error if source_type := data.get("source_type"): if source_type not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}: return Response({"detail": "source_type must be agent, person, or system"}, status=400) try: parse_metadata_value(data.get("metadata", {})) except ValueError as exc: return Response({"detail": str(exc)}, status=400) try: parse_tag_colors_value(data.get("tag_colors", {})) except ValueError as exc: return Response({"detail": str(exc)}, status=400) return None def parse_tag_colors_value(value): if value in (None, ""): return {} if isinstance(value, str): try: value = json.loads(value) except json.JSONDecodeError as exc: raise ValueError("tag_colors must be an object") from exc if not isinstance(value, dict): raise ValueError("tag_colors must be an object") for tag, color in value.items(): if not HEX_COLOR_RE.match(str(color)): raise ValueError(f"tag_colors[{tag}] must be a hex color like #RRGGBB") return value def apply_tag_colors(data): tag_colors = parse_tag_colors_value(data.get("tag_colors", {})) for tag, color in tag_colors.items(): TagColor.objects.update_or_create(name=tag, defaults={"color": color}) def source_fields(data, source): return { "source_type": data.get("source_type") or Artifact.AGENT, "source_name": data.get("source_name") or source, "source_id": data.get("source_id") or "", "metadata": parse_metadata_value(data.get("metadata", {})), } def parse_metadata_value(value): if value in (None, ""): return {} if isinstance(value, dict): return value if isinstance(value, str): try: parsed = json.loads(value) except json.JSONDecodeError as exc: raise ValueError("metadata must be an object") from exc if isinstance(parsed, dict): return parsed raise ValueError("metadata must be an object") def binary_kind(content_type): if content_type.startswith("image/"): return Artifact.IMAGE if content_type == "application/pdf": return Artifact.PDF return Artifact.BINARY def safe_suffix(filename, content_type): suffix = Path(filename).suffix.lower() if suffix and suffix[1:].isalnum() and len(suffix) <= 10: return suffix return mimetypes.guess_extension(content_type) or ".bin" def serialize_artifact(artifact, request): return { "id": artifact.id, "url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])), "api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])), "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, "kind": artifact.kind, "theme": artifact.theme, "day": artifact.day.isoformat(), "created_at": artifact.created_at.isoformat(), "tags": tags_with_colors(artifact.tags), "labels": labels_with_colors(artifact), "source_prompt": artifact.source_prompt, "source_type": artifact.source_type, "source_name": artifact.source_name, "source_id": artifact.source_id, "metadata": artifact.metadata, "read_at": artifact.read_at.isoformat() if artifact.read_at else None, "is_kept": artifact.is_kept, "is_shared": artifact.is_shared, "share_url": ( request.build_absolute_uri(reverse("artifact-shared", args=[artifact.share_token])) if artifact.share_token else None ), "size_bytes": artifact.size_bytes, } def safe_artifact_path(relative_path): root = settings.ARTIFACTS_ROOT.resolve() path = (root / relative_path).resolve() if root not in path.parents and path != root: raise Http404("Invalid artifact path") return path def raw_artifact_path(artifact): path = safe_artifact_path(artifact.path) if artifact.kind == Artifact.HTML_FULL: return path sidecar_suffix = SIDECAR_SUFFIXES.get(artifact.kind) if sidecar_suffix: return path.with_suffix(sidecar_suffix) return None class AgentFeedLoginView(auth_views.LoginView): template_name = "artifacts/login.html" authentication_form = AgentFeedLoginForm def form_valid(self, form): response = super().form_valid(form) if form.cleaned_data.get("remember_me"): self.request.session.set_expiry(REMEMBER_ME_SESSION_AGE) else: self.request.session.set_expiry(0) return response @login_required def settings_home(request): return redirect("settings-account") @login_required def settings_account(request): return render( request, "artifacts/settings_account.html", {"active_section": "account", "key_count": request.user.api_keys.count()}, ) @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()}, ) @login_required def settings_users(request): if not request.user.is_superuser: return HttpResponseForbidden("Admin access required") created_user = None created_key = None if request.method == "POST": username = request.POST.get("username", "").strip() password = request.POST.get("password", "") grant_admin = request.POST.get("is_superuser") == "on" if not username or not password: messages.error(request, "Username and password are required.") elif User.objects.filter(username=username).exists(): messages.error(request, f"A user named '{username}' already exists.") else: created_user = User.objects.create_user(username=username, password=password) 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") messages.success(request, f"Created user '{username}'.") return render( request, "artifacts/settings_users.html", { "active_section": "users", "users": User.objects.order_by("username"), "created_user": created_user, "created_key": created_key, }, ) @login_required def settings_about(request): from daybook_project import __version__ return render( request, "artifacts/settings_about.html", {"active_section": "about", "version": __version__}, ) @login_required def settings_demo_data(request): if request.method == "POST": count = create_demo_artifacts(request.user, request) messages.success(request, f"Created {count} demo artifacts.") return redirect("settings-demo-data") return render( request, "artifacts/settings_demo_data.html", {"active_section": "demo-data"}, ) @login_required def settings_storage(request): used = user_usage_bytes(request.user) quota = user_quota_bytes(request.user) kept_bytes = ( Artifact.objects.filter(owner=request.user, is_kept=True) .aggregate(total=Sum("size_bytes")) .get("total") or 0 ) context = { "active_section": "storage", "used_bytes": used, "quota_bytes": quota, "kept_bytes": kept_bytes, "percent_used": min(100, round(used / quota * 100)) if quota else 0, "artifact_count": Artifact.objects.filter(owner=request.user).count(), "kept_count": Artifact.objects.filter(owner=request.user, is_kept=True).count(), } if request.user.is_superuser: context["is_superuser"] = True context["total_allocated"] = total_allocated_bytes() context["disk_free"] = disk_free_bytes() return render(request, "artifacts/settings_storage.html", context) def raw_unavailable_html(artifact): return f""" Raw source unavailable

Raw source unavailable

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

"""