#015 — deeply nested JSON raised RecursionError (a RuntimeError, not ValueError) and became an unhandled 500 on two paths: - json-kind content validation now catches RecursionError -> 400. - Added a custom DRF EXCEPTION_HANDLER (artifacts/exception_handlers.py, wired via REST_FRAMEWORK) mapping otherwise-unhandled RecursionError -> 400, covering the parser-level crash on deeply nested request bodies (metadata etc.) too. #016 — wrap_csv_viewer handed Grid.js rows without reconciling width to the header, so over-long rows silently dropped cells. Now normalizes every row to the max width: long rows widen the columns (auto-named "Column N"), short rows pad with "". No cell is dropped. Both verified live; 4 regression tests added. 106 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1310 lines
48 KiB
Python
1310 lines
48 KiB
Python
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 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, 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")
|
|
|
|
|
|
@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
|
|
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")
|
|
|
|
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
|
|
@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
|
|
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)
|
|
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 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
|
|
# 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/<id> 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"""<!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]
|
|
# 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"""<!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_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 "<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)
|
|
# 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"),
|
|
"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 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>
|
|
"""
|