diff --git a/artifacts/admin.py b/artifacts/admin.py index 5fd255a..0a0d7f2 100644 --- a/artifacts/admin.py +++ b/artifacts/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Artifact, UserProfile +from .models import Artifact, Asset, UserProfile @admin.register(Artifact) @@ -14,3 +14,10 @@ class ArtifactAdmin(admin.ModelAdmin): class UserProfileAdmin(admin.ModelAdmin): list_display = ("user", "quota_bytes") search_fields = ("user__username",) + + +@admin.register(Asset) +class AssetAdmin(admin.ModelAdmin): + list_display = ("id", "owner", "artifact", "content_type", "size_bytes", "created_at") + list_filter = ("owner", "content_type") + search_fields = ("id",) diff --git a/artifacts/migrations/0011_add_asset.py b/artifacts/migrations/0011_add_asset.py new file mode 100644 index 0000000..f2bc2c0 --- /dev/null +++ b/artifacts/migrations/0011_add_asset.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.15 on 2026-07-24 10:13 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0010_backfill_sizes_and_profiles'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Asset', + fields=[ + ('id', models.CharField(max_length=12, primary_key=True, serialize=False)), + ('path', models.CharField(max_length=500)), + ('content_type', models.CharField(max_length=100)), + ('size_bytes', models.BigIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('artifact', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='artifacts.artifact')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assets', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/artifacts/models.py b/artifacts/models.py index 12e9255..3ce7209 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -78,3 +78,28 @@ class UserProfile(models.Model): def __str__(self): return f"{self.user} quota={self.quota_bytes}" + + +class Asset(models.Model): + """An image uploaded for embedding inside an artifact's content. + + Uploaded before the artifact exists, so `artifact` is null until the + artifact referencing its URL is created. Unattached assets are swept + after ASSET_ORPHAN_GRACE; attached ones die with their artifact. + """ + + id = models.CharField(max_length=12, primary_key=True) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="assets") + artifact = models.ForeignKey( + Artifact, on_delete=models.CASCADE, related_name="assets", null=True, blank=True + ) + path = models.CharField(max_length=500) + content_type = models.CharField(max_length=100) + size_bytes = models.BigIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.id} ({self.content_type})" diff --git a/artifacts/storage.py b/artifacts/storage.py index 8a0a675..36e7069 100644 --- a/artifacts/storage.py +++ b/artifacts/storage.py @@ -6,11 +6,13 @@ usage is a cheap SUM rather than a stat of every file. """ import shutil +from datetime import timedelta from django.conf import settings from django.db.models import Sum +from django.utils import timezone -from .models import Artifact, UserProfile +from .models import Artifact, Asset, UserProfile class QuotaExceeded(Exception): @@ -32,19 +34,38 @@ def artifact_disk_size(relative_path, kind): def delete_artifact_files(artifact): - """Remove an artifact's files from disk (primary + markdown sidecar).""" + """Remove an artifact's files from disk (primary, markdown sidecar, assets). + + Assets are cascade-deleted from the DB by the FK, but their files must be + removed here explicitly. + """ root = settings.ARTIFACTS_ROOT primary = root / artifact.path primary.unlink(missing_ok=True) if artifact.kind == Artifact.MARKDOWN: primary.with_suffix(".md").unlink(missing_ok=True) + for asset in artifact.assets.all(): + delete_asset_file(asset) + + +def delete_asset_file(asset): + (settings.ARTIFACTS_ROOT / asset.path).unlink(missing_ok=True) + + +def artifact_total_bytes(artifact): + """An artifact's own bytes plus those of the assets embedded in it.""" + asset_bytes = artifact.assets.aggregate(total=Sum("size_bytes")).get("total") or 0 + return artifact.size_bytes + asset_bytes def user_usage_bytes(user): - return ( - Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") - or 0 + artifact_bytes = ( + Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0 ) + asset_bytes = ( + Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0 + ) + return artifact_bytes + asset_bytes def user_quota_bytes(user): @@ -73,7 +94,8 @@ def make_room_for(user, incoming_bytes): for artifact in unkept: if used + incoming_bytes <= quota: break - used -= artifact.size_bytes + # Freeing an artifact also frees the assets embedded in it. + used -= artifact_total_bytes(artifact) delete_artifact_files(artifact) artifact.delete() evicted.append(artifact) @@ -83,6 +105,27 @@ def make_room_for(user, incoming_bytes): return evicted +ASSET_ORPHAN_GRACE = timedelta(hours=24) + + +def sweep_orphan_assets(user=None): + """Delete assets uploaded but never referenced by an artifact. + + Runs opportunistically on upload. The grace period gives an agent time to + upload images and then post the artifact that references them. + """ + cutoff = timezone.now() - ASSET_ORPHAN_GRACE + orphans = Asset.objects.filter(artifact__isnull=True, created_at__lt=cutoff) + if user is not None: + orphans = orphans.filter(owner=user) + count = 0 + for asset in orphans: + delete_asset_file(asset) + asset.delete() + count += 1 + return count + + def disk_free_bytes(): """Free space on the volume that holds the artifact store.""" return shutil.disk_usage(settings.ARTIFACTS_ROOT).free diff --git a/artifacts/urls.py b/artifacts/urls.py index c1d2a36..a3d1f65 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -9,6 +9,8 @@ urlpatterns = [ path("a//read", views.mark_artifact_read, name="artifact-mark-read"), path("a//keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), path("a/", views.artifact_view, name="artifact"), + path("assets/", views.asset_view, name="asset"), + path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"), path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"), path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), path("api/artifacts/", views.ArtifactDetailView.as_view(), name="artifact-detail-api"), diff --git a/artifacts/views.py b/artifacts/views.py index 12c89b6..498ce12 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -30,13 +30,14 @@ from rest_framework.views import APIView from .demo_data import create_demo_artifacts from .forms import DaybookLoginForm -from .models import Artifact, TagColor +from .models import Artifact, Asset, TagColor from .storage import ( QuotaExceeded, artifact_disk_size, delete_artifact_files, disk_free_bytes, make_room_for, + sweep_orphan_assets, total_allocated_bytes, user_quota_bytes, user_usage_bytes, @@ -47,6 +48,11 @@ TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Art 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] @@ -112,6 +118,80 @@ class ArtifactListCreateView(APIView): 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): @@ -407,7 +487,7 @@ def create_text_artifact(data, request, user): (target_dir / f"{artifact_id}.md").unlink(missing_ok=True) return Response({"detail": str(exc)}, status=413) - return Artifact.objects.create( + artifact = Artifact.objects.create( id=artifact_id, owner=user, source=source, @@ -422,6 +502,8 @@ def create_text_artifact(data, request, user): size_bytes=size_bytes, **source_fields(data, source), ) + attach_referenced_assets(artifact, content) + return artifact def create_binary_artifact(data, user): @@ -518,6 +600,27 @@ def generate_id(): 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 "): diff --git a/docs/api.md b/docs/api.md index 9285708..4cfce20 100644 --- a/docs/api.md +++ b/docs/api.md @@ -220,6 +220,48 @@ curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` -F "file=@C:\path\to\diagram.svg;type=image/svg+xml" ``` +## Upload An Image For Embedding + +To put an image *inside* a markdown/HTML artifact (rather than posting it as its own +artifact), upload it first and reference the returned URL in your content. + +```http +POST /api/assets +Content-Type: multipart/form-data +Authorization: Bearer +``` + +| Field | Notes | +|---|---| +| `file` | Required. Must be an `image/*` content type. Max 50 MB. | + +Response: + +```json +{ + "id": "ab12cd34ef56", + "url": "http://127.0.0.1:8001/assets/ab12cd34ef56", + "content_type": "image/png", + "size_bytes": 20481 +} +``` + +Then reference the URL normally: + +```markdown +![Chart](http://127.0.0.1:8001/assets/ab12cd34ef56) +``` + +Lifecycle and quota: + +- The image counts toward your quota **immediately on upload**, and can trigger eviction + of old unkept artifacts like any other post. +- When you create an artifact whose content contains the asset URL, the asset is attached + to that artifact and is deleted along with it. +- Uploads never referenced by any artifact are swept after 24 hours. +- Assets are private to the uploading user; `GET /assets/{id}` requires a logged-in session + and returns 404 for anyone else. + ## List Artifacts Endpoint: diff --git a/skills/daybook-posting/SKILL.md b/skills/daybook-posting/SKILL.md index ba73adc..d3fa546 100644 --- a/skills/daybook-posting/SKILL.md +++ b/skills/daybook-posting/SKILL.md @@ -45,6 +45,23 @@ DAYBOOK_SOURCE=agent For images, PDFs, SVGs, and other files, use `--file`; the helper infers multipart and content type. +## Images Inside Content + +To embed an image *within* a markdown or HTML artifact (rather than posting it as its own +artifact), upload it first and reference the returned URL: + +```bash +# 1. Upload -> prints the URL as the last line +python skills/daybook-posting/scripts/daybook_post.py --upload-image chart.png + +# 2. Reference that URL in your markdown, then post as usual +# ![chart](http://127.0.0.1:8001/assets/ab12cd34ef56) +python skills/daybook-posting/scripts/daybook_post.py report.md +``` + +The image is linked to the artifact that references it and is deleted along with it. +Unreferenced uploads are cleaned up after 24 hours. + For CSV, JSON, or code that should render today, use `--file path --as-markdown` to post it as a Markdown fenced code block. Without `--as-markdown`, these upload as downloadable binary files. ## Rules diff --git a/skills/daybook-posting/scripts/daybook_post.py b/skills/daybook-posting/scripts/daybook_post.py index b2e7563..cc8c9a8 100644 --- a/skills/daybook-posting/scripts/daybook_post.py +++ b/skills/daybook-posting/scripts/daybook_post.py @@ -49,6 +49,19 @@ def main(): ) return 2 + if args.upload_image: + try: + response = upload_asset(base_url, token, Path(args.upload_image)) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + except (HTTPError, URLError) as exc: + print_http_error(exc) + return 1 + print(json.dumps(response, indent=2)) + print(response.get("url", "")) + return 0 + try: if args.path: resolve_auto_path(args) @@ -87,11 +100,12 @@ def parse_args(): parser.add_argument("--source-prompt", default="", help="Prompt/instruction that produced the artifact.") parser.add_argument("--source-type", choices=["agent", "person", "system"], default="agent") parser.add_argument("--as-markdown", action="store_true", help="Wrap --file content in a Markdown fenced code block instead of uploading it.") + parser.add_argument("--upload-image", help="Upload an image for embedding in artifact content. Prints the URL to reference in markdown/HTML.") parser.add_argument("--check", action="store_true", help="Fetch /api/capabilities and exit.") parser.add_argument("--dry-run", action="store_true", help="Print request details without posting.") args = parser.parse_args() - if args.check: + if args.check or args.upload_image: return args if args.path: @@ -370,6 +384,28 @@ def content_type_for(path): return mimetypes.guess_type(path.name)[0] or "application/octet-stream" +def upload_asset(base_url, token, path): + """Upload an image and return its Daybook URL for embedding in content.""" + if not path.is_file(): + raise ValueError(f"image file does not exist: {path}") + + boundary = f"----daybook-{uuid.uuid4().hex}" + chunks = [ + f"--{boundary}\r\n".encode("utf-8"), + f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'.encode("utf-8"), + f"Content-Type: {content_type_for(path)}\r\n\r\n".encode("utf-8"), + path.read_bytes(), + b"\r\n", + f"--{boundary}--\r\n".encode("utf-8"), + ] + request = Request(f"{base_url}/api/assets", data=b"".join(chunks), method="POST") + request.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") + request.add_header("Authorization", f"Bearer {token}") + request.add_header("Accept", "application/json") + with urlopen(request, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + + def request_json(method, url, token=""): request = Request(url, method=method) request.add_header("Accept", "application/json")