daybook/artifacts/views.py
peter d1b6a19eb3 Fix concurrent posts racing past quota (issue #010)
make_room_for() read usage before any write lock was held, so concurrent
requests could each read stale data and both conclude "this fits" -
confirmed reproducible (measured 616 bytes over a 1551-byte quota with 5
truly concurrent posts).

Fix: force SQLite to acquire its write lock immediately via a no-op UPDATE
on the user's UserProfile row, before reading `used`, wrapped in
transaction.atomic() through the subsequent Artifact.objects.create() so
the lock is held across the whole eviction+insert sequence. Added a 20s
DB timeout as headroom for legitimate contention.

Deliberately no retry/backoff logic - this project's actual load (a
low-volume self-hosted tool) doesn't need it, and SQLite has no row-level
locking regardless, so quota-affecting writes serialize globally either
way. Did fix a real bug found while verifying this: exception handling
around artifact creation only caught QuotaExceeded, so a genuine lock
timeout left the just-written file orphaned on disk - both call sites now
clean up on any exception.

Verified live against the real dev instance (twice, with 5 truly
simultaneous posts against a tight quota) rather than via an automated
test - Django's in-memory shared-cache test database uses a different,
non-retryable SQLite locking model that fails for reasons unrelated to
this fix's correctness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:22:37 +01:00

1137 lines
40 KiB
Python

import csv
import hashlib
import html
import io
import json
import mimetypes
import re
import secrets
import time
from datetime import timedelta
from pathlib import Path
from urllib.parse import urlencode
import markdown
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 slugify
from django.urls import reverse
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 DaybookLoginForm
from .models import Artifact, Asset, TagColor
from .storage import (
QuotaExceeded,
SIDECAR_SUFFIXES,
artifact_disk_size,
delete_artifact_files,
disk_free_bytes,
make_room_for,
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 Daybook 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",
}
@method_decorator(login_required, name="get")
class ArtifactListCreateView(APIView):
parser_classes = [JSONParser, FormParser, MultiPartParser]
def get(self, request):
artifacts = Artifact.objects.filter(owner=request.user)
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, 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,
)
@login_required
@xframe_options_sameorigin
def asset_view(request, asset_id):
asset = get_object_or_404(Asset, pk=asset_id, owner=request.user)
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")
@method_decorator(login_required, name="get")
class ArtifactDetailView(APIView):
def get(self, request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
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)
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
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")
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
delete_artifact_files(artifact)
artifact.delete()
return Response(status=204)
class CapabilitiesView(APIView):
"""Static capability info. Reachable by a logged-in session or a valid API token,
so agents can check connectivity before posting. Exposes no user data."""
def get(self, request):
if not request.user.is_authenticated and resolve_token_user(request) is None:
return HttpResponseForbidden("Login or a valid API token is required")
return Response(
{
"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": {
"/api/artifacts": ["GET", "POST"],
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
"/api/assets": ["POST"],
},
}
)
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"
all_artifacts = Artifact.objects.filter(owner=request.user)
artifacts = apply_feed_filters(
all_artifacts, selected_source, selected_label, selected_kind, title_query
)
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
if not artifacts.filter(day__gte=window_start).exists():
# Nothing recent for these filters: fall back to the most recent days that
# do match, so selecting a source is never met with a blank list.
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
if recent_days:
window_start = recent_days[-1]
window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at"))
older_count = artifacts.filter(day__lt=window_start).count()
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_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 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(
all_artifacts, "", 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")
)
all_labels = sorted({tag for tags in all_artifacts.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,
"source_counts": source_counts,
"total_count": source_scoped.filter(read_at__isnull=True).count(),
"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
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),
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
@xframe_options_sameorigin
def artifact_view(request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
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)
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
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:
rendered = content
elif kind == Artifact.MARKDOWN:
rendered = wrap_html(
markdown.markdown(
content,
extensions=["extra", "sane_lists", "codehilite"],
extension_configs={"codehilite": {"guess_lang": False}},
),
title,
theme,
request,
)
elif kind == Artifact.DECK:
rendered = wrap_deck(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:
rendered = wrap_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
def create_binary_artifact(data, 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
now = timezone.localtime()
artifact_id = generate_id()
source = slugify(data["source"]) or "unknown"
title = data["title"]
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
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
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"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<link rel="stylesheet" href="{stylesheet}">
<link rel="stylesheet" href="{pygments_stylesheet}">
</head>
<body class="artifact">
{body}
</body>
</html>
"""
def wrap_deck(body, title, request):
stylesheet = request.build_absolute_uri("/static/themes/deck.css")
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<link rel="stylesheet" href="{stylesheet}">
</head>
<body class="deck">
<main class="slides">{body}</main>
</body>
</html>
"""
SCRIPT_CLOSE_RE = re.compile(r"</script", re.IGNORECASE)
def escape_script_body(text):
"""Prevent literal `</script` in embedded raw text from prematurely
closing the <script> 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"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<script src="{renderjson_src}"></script>
<style>
body {{ margin: 0; padding: 1.2rem; background: #fffaf0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9rem; }}
</style>
</head>
<body class="artifact">
<script type="application/json" id="daybook-data">{safe_json}</script>
<div id="daybook-json-view"></div>
<script>
(function () {{
var raw = document.getElementById("daybook-data").textContent;
var data = JSON.parse(raw);
renderjson.set_show_to_level(2);
document.getElementById("daybook-json-view").appendChild(renderjson(data));
}})();
</script>
</body>
</html>
"""
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]
columns = rows[0] if rows else []
data = rows[1:] if len(rows) > 1 else []
table_json = escape_script_body(json.dumps({"columns": columns, "data": data}))
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<link rel="stylesheet" href="{gridjs_css}">
<script src="{gridjs_js}"></script>
<style>
body {{ margin: 0; padding: 1.2rem; background: #fffaf0; }}
</style>
</head>
<body class="artifact">
<script type="application/json" id="daybook-data">{table_json}</script>
<div id="daybook-csv-view"></div>
<script>
(function () {{
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("daybook-csv-view"));
}})();
</script>
</body>
</html>
"""
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:
return Token.objects.select_related("user").get(key=key).user
except Token.DoesNotExist:
return None
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_content_shape(kind, content):
lower = (content or "").lower()
if kind == Artifact.DECK and "<section" not in lower:
return Response(
{"detail": "deck needs top-level <section>...</section> blocks"}, status=400
)
if kind == Artifact.HTML_FRAGMENT and any(tag in lower for tag in ("<html", "<!doctype", "<body")):
return Response(
{"detail": "html_fragment must be body-only HTML; do not include doctype/html/body"},
status=400,
)
if kind == Artifact.HTML_FULL and "<html" not in lower:
return Response(
{"detail": "html_full must be a complete HTML document containing <html>"}, 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)
except (TypeError, ValueError) 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
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"),
"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,
"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 DaybookLoginView(auth_views.LoginView):
template_name = "artifacts/login.html"
authentication_form = DaybookLoginForm
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):
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",
{"token": token, "active_section": "account"},
)
@login_required
def settings_users(request):
if not request.user.is_superuser:
return HttpResponseForbidden("Admin access required")
created_user = None
created_token = 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_token = Token.objects.create(user=created_user)
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_token": created_token,
},
)
@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"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Raw source unavailable</title>
<style>
body {{ background: #f4f1ea; color: #1d2528; font-family: system-ui, sans-serif; margin: 0; padding: 2rem; }}
main {{ background: #fffaf0; border-radius: 16px; max-width: 42rem; padding: 1.4rem; }}
</style>
</head>
<body>
<main>
<h1>Raw source unavailable</h1>
<p>Daybook does not have preserved raw source for <strong>{html.escape(artifact.title)}</strong>.</p>
</main>
</body>
</html>
"""