Add private author-managed attachments with retained revisions
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a
This commit is contained in:
parent
075633e4b5
commit
d9da608e65
12 changed files with 752 additions and 12 deletions
|
|
@ -83,6 +83,13 @@ def open_local_image(space_slug: str, path: str):
|
|||
|
||||
def local_asset_url(space_slug: str, reference: str) -> str:
|
||||
"""Map space-root or page-relative assets to the authenticated app route."""
|
||||
path = local_asset_path(reference)
|
||||
if not path or PurePosixPath(path).suffix.lower() not in _TYPES:
|
||||
return ""
|
||||
return reverse("spaces:asset", kwargs={"slug": space_slug, "asset_path": path})
|
||||
|
||||
|
||||
def local_asset_path(reference: str) -> str:
|
||||
try:
|
||||
parsed = urlsplit(reference)
|
||||
except ValueError:
|
||||
|
|
@ -98,11 +105,16 @@ def local_asset_url(space_slug: str, reference: str) -> str:
|
|||
return ""
|
||||
try:
|
||||
asset_parts(path)
|
||||
if PurePosixPath(path).suffix.lower() not in _TYPES:
|
||||
return ""
|
||||
except ValueError:
|
||||
return ""
|
||||
return reverse("spaces:asset", kwargs={"slug": space_slug, "asset_path": path})
|
||||
return path
|
||||
|
||||
|
||||
def local_download_url(space_slug: str, reference: str) -> str:
|
||||
path = local_asset_path(reference)
|
||||
if not path:
|
||||
return ""
|
||||
return reverse("spaces:download", kwargs={"slug": space_slug, "asset_path": path})
|
||||
|
||||
|
||||
class _AssetLinks(HTMLParser):
|
||||
|
|
@ -115,7 +127,9 @@ class _AssetLinks(HTMLParser):
|
|||
result = []
|
||||
for name, value in attrs:
|
||||
if (tag, name) in {("img", "src"), ("a", "href")} and value:
|
||||
value = local_asset_url(self.space_slug, value) or value
|
||||
value = (local_asset_url(self.space_slug, value)
|
||||
or (local_download_url(self.space_slug, value) if tag == "a" else "")
|
||||
or value)
|
||||
result.append(name if value is None else f'{name}="{escape(value, quote=True)}"')
|
||||
self.output.append("<" + tag + (" " + " ".join(result) if result else "") + ">")
|
||||
|
||||
|
|
|
|||
196
coulomb_social/apps/spaces/attachments.py
Normal file
196
coulomb_social/apps/spaces/attachments.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""Page attachments: immutable files, revisioned associations and bounded uploads."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
from django.utils.text import slugify
|
||||
|
||||
from . import pageops
|
||||
from .assets import (
|
||||
asset_parts,
|
||||
local_asset_path,
|
||||
local_asset_url,
|
||||
local_download_url,
|
||||
open_local_asset,
|
||||
)
|
||||
|
||||
KEY = "coulomb_attachments"
|
||||
MAX_UPLOAD_BYTES = 16 * 1024 * 1024
|
||||
MAX_ATTACHMENTS = 100
|
||||
|
||||
|
||||
def page_files(space, doc):
|
||||
"""List page associations plus existing body/Visual references, without I/O."""
|
||||
from .content import render_markdown
|
||||
|
||||
files = {local_asset_path(e["path"]): {**e, "managed": True} for e in entries(doc.meta)}
|
||||
|
||||
def add_reference(value):
|
||||
path = local_asset_path(value)
|
||||
if path and path not in files:
|
||||
files[path] = {"path": "assets/" + path, "name": Path(path).name, "managed": False}
|
||||
|
||||
class Links(HTMLParser):
|
||||
def handle_starttag(self, tag, attrs):
|
||||
for key, value in attrs:
|
||||
if (tag, key) in {("a", "href"), ("img", "src")} and value:
|
||||
add_reference(value)
|
||||
|
||||
parser = Links()
|
||||
parser.feed(render_markdown(doc.body))
|
||||
parser.close()
|
||||
add_reference(doc.visual)
|
||||
return [{**entry, "download_url": local_download_url(space, entry["path"]),
|
||||
"open_url": local_asset_url(space, entry["path"])} for entry in files.values()]
|
||||
|
||||
|
||||
def entries(meta):
|
||||
raw = meta.get(KEY, "[]")
|
||||
try:
|
||||
result = json.loads(raw)
|
||||
if not isinstance(result, list) or len(result) > MAX_ATTACHMENTS:
|
||||
raise ValueError
|
||||
paths = set()
|
||||
for entry in result:
|
||||
if (not isinstance(entry, dict) or set(entry) != {"name", "path"}
|
||||
or not isinstance(entry["name"], str) or not entry["name"]
|
||||
or len(entry["name"]) > 200 or not isinstance(entry["path"], str)
|
||||
or not entry["path"].startswith("assets/")):
|
||||
raise ValueError
|
||||
asset_parts(entry["path"][7:])
|
||||
if entry["path"] in paths:
|
||||
raise ValueError
|
||||
paths.add(entry["path"])
|
||||
return result
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
raise pageops.PageOpsError("The attachment list needs operator review") from exc
|
||||
|
||||
|
||||
def _metadata(space, doc, files):
|
||||
meta = dict(title=doc.title, abstractor=doc.abstractor, visual=doc.visual,
|
||||
space=space)
|
||||
meta.update({k: v for k, v in doc.meta.items() if k not in meta})
|
||||
meta[KEY] = json.dumps(files, ensure_ascii=True)
|
||||
return meta
|
||||
|
||||
|
||||
def _current(space, page, revision):
|
||||
doc = pageops.read_page(space, page)
|
||||
if not doc.exists:
|
||||
raise pageops.PageOpsError("Page not found")
|
||||
if not revision or doc.revision != revision:
|
||||
raise pageops.PageOpsError("This page changed. Reopen it before changing attachments")
|
||||
return doc
|
||||
|
||||
|
||||
def _save(space, doc, meta):
|
||||
return pageops.write_page(
|
||||
space, doc.slug, title=doc.title, abstractor=doc.abstractor,
|
||||
visual=doc.visual, body=doc.body, extra_meta=meta, expected_revision=doc.revision,
|
||||
)
|
||||
|
||||
|
||||
@pageops.content_writer
|
||||
def add(space, page, upload, *, revision):
|
||||
doc = _current(space, page, revision)
|
||||
files = entries(doc.meta)
|
||||
if len(files) >= MAX_ATTACHMENTS:
|
||||
raise pageops.PageOpsError("This page already has 100 attachments")
|
||||
if upload is None or upload.size > MAX_UPLOAD_BYTES:
|
||||
raise pageops.PageOpsError("Choose a file no larger than 16 MiB")
|
||||
raw = upload.read(MAX_UPLOAD_BYTES + 1)
|
||||
if not raw or len(raw) > MAX_UPLOAD_BYTES:
|
||||
raise pageops.PageOpsError("Choose a non-empty file no larger than 16 MiB")
|
||||
label = Path(upload.name.replace("\\", "/")).name
|
||||
label = "".join(c for c in label if c.isprintable())[:200] or "attachment"
|
||||
suffix = Path(label).suffix.lower()
|
||||
if not re.fullmatch(r"\.[a-z0-9]{1,12}", suffix):
|
||||
suffix = ""
|
||||
stem = slugify(Path(label).stem)[:80] or "attachment"
|
||||
identity = uuid.uuid4().hex
|
||||
name = f"upload-{identity}-{stem}{suffix}"
|
||||
path = pageops.assets_dir(space) / name
|
||||
# Exclusive creation and the shared writer lock preserve every older asset.
|
||||
if path.exists() or path.is_symlink():
|
||||
raise pageops.PageOpsError("Attachment address already exists")
|
||||
meta = _metadata(space, doc, [*files, {"name": label, "path": "assets/" + name}])
|
||||
record = {
|
||||
"id": identity, "version": 1, "op": "attachment_upload", "phase": "started",
|
||||
"source_space": space, "dest_space": space,
|
||||
"source_page": doc.slug, "dest_page": doc.slug,
|
||||
"source_revision": doc.revision,
|
||||
"dest_revision": hashlib.sha256(
|
||||
pageops.dump_frontmatter(meta, doc.body).encode()).hexdigest(),
|
||||
"assets": [name], "asset_sha256": hashlib.sha256(raw).hexdigest(),
|
||||
}
|
||||
pageops._write_record(record)
|
||||
try:
|
||||
with open_local_asset(space, name, create=True) as stream:
|
||||
stream.write(raw)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
pageops._fsync_directory(path.parent)
|
||||
result = _save(space, doc, meta)
|
||||
if result.revision != record["dest_revision"]:
|
||||
raise pageops.PageOpsError("Attachment publication needs operator review")
|
||||
pageops._clear_record(record)
|
||||
return result
|
||||
except BaseException:
|
||||
pageops._recover_record(record)
|
||||
raise
|
||||
|
||||
|
||||
@pageops.content_writer
|
||||
def remove(space, page, reference, *, revision):
|
||||
doc = _current(space, page, revision)
|
||||
files = entries(doc.meta)
|
||||
remaining = [entry for entry in files if entry["path"] != reference]
|
||||
if len(remaining) == len(files):
|
||||
raise pageops.PageOpsError("Attachment not found on this page")
|
||||
# File bytes remain available to retained revisions and other page references.
|
||||
return _save(space, doc, _metadata(space, doc, remaining))
|
||||
|
||||
|
||||
def recover(record):
|
||||
"""Keep revision-referenced complete bytes; discard only this failed upload."""
|
||||
try:
|
||||
identity = record["id"]
|
||||
if not re.fullmatch(r"[a-f0-9]{32}", identity):
|
||||
return "held"
|
||||
space, page = record["source_space"], record["source_page"]
|
||||
if (record.get("version") != 1 or record.get("phase") != "started"
|
||||
or record["dest_space"] != space or record["dest_page"] != page
|
||||
or len(record["assets"]) != 1):
|
||||
return "held"
|
||||
name = record["assets"][0]
|
||||
if not name.startswith(f"upload-{identity}-") or len(asset_parts(name)) != 1:
|
||||
return "held"
|
||||
for field in ("source_revision", "dest_revision", "asset_sha256"):
|
||||
if not re.fullmatch(r"[a-f0-9]{64}", record[field]):
|
||||
return "held"
|
||||
current = pageops.page_file(space, page)
|
||||
if pageops.assets_dir(space).is_symlink():
|
||||
return "held"
|
||||
revision = pageops.content_root() / ".revisions" / space / page / (
|
||||
record["dest_revision"] + ".md")
|
||||
if any(p.is_symlink() for p in (revision, *revision.parents)):
|
||||
return "held"
|
||||
if revision.exists():
|
||||
if pageops._file_revision(revision) != record["dest_revision"]:
|
||||
return "held"
|
||||
with open_local_asset(space, name) as stream:
|
||||
if hashlib.file_digest(stream, "sha256").hexdigest() != record["asset_sha256"]:
|
||||
return "held"
|
||||
elif current.is_file() and pageops._file_revision(current) == record["source_revision"]:
|
||||
pageops._discard_recorded_assets(space, [name])
|
||||
else:
|
||||
return "held"
|
||||
pageops._clear_record(record)
|
||||
return "cleared"
|
||||
except (KeyError, TypeError, ValueError, OSError, pageops.PageOpsError):
|
||||
return "held"
|
||||
|
|
@ -448,6 +448,9 @@ def _finish_transfer_record(record, source_path, dest_confirmed):
|
|||
|
||||
|
||||
def _recover_record(record):
|
||||
if isinstance(record, dict) and record.get("op") == "attachment_upload":
|
||||
from .attachments import recover
|
||||
return recover(record)
|
||||
if record == "held" or not _record_is_actionable(record):
|
||||
logger.error("content recovery left a journal for operator review")
|
||||
return "held"
|
||||
|
|
@ -661,7 +664,8 @@ _BODY_ASSET = re.compile(
|
|||
)
|
||||
|
||||
|
||||
def _copy_assets(source_space, dest_space, dest_page, visual, body, before_create=None):
|
||||
def _copy_assets(source_space, dest_space, dest_page, visual, body, before_create=None,
|
||||
attached=()):
|
||||
"""Copy local Visual and Markdown link/image destinations as one staged set."""
|
||||
from .assets import asset_parts, open_local_asset
|
||||
|
||||
|
|
@ -725,7 +729,8 @@ def _copy_assets(source_space, dest_space, dest_page, visual, body, before_creat
|
|||
body = _BODY_ASSET.sub(
|
||||
lambda match: match["prefix"] + copy_reference(match["url"]), body,
|
||||
)
|
||||
return visual, body, created
|
||||
attached = [{**entry, "path": copy_reference(entry["path"])} for entry in attached]
|
||||
return visual, body, created, attached
|
||||
except BaseException:
|
||||
for path in created:
|
||||
path.unlink(missing_ok=True)
|
||||
|
|
@ -764,8 +769,11 @@ def copy_page(
|
|||
on_asset(name)
|
||||
|
||||
try:
|
||||
visual, body, created = _copy_assets(
|
||||
from .attachments import KEY, entries
|
||||
|
||||
visual, body, created, attached = _copy_assets(
|
||||
source_space, dest_space, dest_page, src.visual, src.body, note_asset,
|
||||
attached=entries(src.meta),
|
||||
)
|
||||
new_title = title if title is not None else (src.title or dest_page)
|
||||
if title is None and new_title == src.title and source_space == dest_space:
|
||||
|
|
@ -774,7 +782,8 @@ def copy_page(
|
|||
dest_space, dest_page, title=new_title, abstractor=src.abstractor,
|
||||
visual=visual, body=body, expected_revision="",
|
||||
extra_meta={k: v for k, v in src.meta.items()
|
||||
if k not in {"title", "abstractor", "visual", "space"}},
|
||||
if k not in {"title", "abstractor", "visual", "space", KEY}}
|
||||
| ({KEY: json.dumps(attached)} if KEY in src.meta else {}),
|
||||
)
|
||||
if record is not None:
|
||||
record["dest_revision"] = doc.revision
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ urlpatterns = [
|
|||
),
|
||||
path("new/", views.space_create, name="create"),
|
||||
path("<slug:slug>/assets/<path:asset_path>", views.space_asset, name="asset"),
|
||||
path("<slug:slug>/downloads/<path:asset_path>", views.space_download, name="download"),
|
||||
path("<slug:slug>/pages/<slug:page_slug>/attachments/",
|
||||
views.page_attachments, name="page_attachments"),
|
||||
path("<slug:slug>/edit/", views.space_edit, name="edit"),
|
||||
path("<slug:slug>/sharing/", views.space_sharing, name="sharing"),
|
||||
path("<slug:slug>/archive/", views.space_archive, name="archive"),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import hashlib
|
|||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
|
|
@ -21,8 +22,8 @@ from django.views.decorators.http import require_http_methods, require_POST, req
|
|||
|
||||
from coulomb_social.apps.core.principal import build_principal
|
||||
|
||||
from . import pageops
|
||||
from .assets import local_asset_url, open_local_image
|
||||
from . import attachments, pageops
|
||||
from .assets import local_asset_url, open_local_asset, open_local_image
|
||||
from .content import (
|
||||
clear_content_cache_for_repo,
|
||||
clear_content_cache_for_space,
|
||||
|
|
@ -79,6 +80,81 @@ def space_asset(request: HttpRequest, slug: str, asset_path: str) -> HttpRespons
|
|||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
@require_safe
|
||||
def space_download(request, slug, asset_path):
|
||||
principal, forbid = _principal_or_forbid(request, "space_asset")
|
||||
if forbid:
|
||||
return forbid
|
||||
space = get_space_for_member(principal.get("member"), slug)
|
||||
if space is None or Space.objects.filter(slug=slug).exclude(pk=space.pk).exists():
|
||||
return HttpResponseNotFound("Attachment not found.")
|
||||
try:
|
||||
file = open_local_asset(slug, asset_path)
|
||||
except (OSError, ValueError):
|
||||
return HttpResponseNotFound("Attachment not found.")
|
||||
response = FileResponse(file, as_attachment=True, filename=PurePosixPath(asset_path).name,
|
||||
content_type="application/octet-stream")
|
||||
response["Cache-Control"] = "private, no-store"
|
||||
response["X-Content-Type-Options"] = "nosniff"
|
||||
response["Content-Security-Policy"] = "default-src 'none'; sandbox"
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def page_attachments(request, slug, page_slug):
|
||||
principal, forbid = _principal_or_forbid(
|
||||
request, "page_edit" if request.method == "POST" else "space_detail")
|
||||
if forbid:
|
||||
return forbid
|
||||
member = principal.get("member")
|
||||
space = get_space_for_member(member, slug)
|
||||
if space is None:
|
||||
return HttpResponseNotFound("Page not found.")
|
||||
can_write = can_write_space(member, space)
|
||||
if request.method == "POST" and not can_write:
|
||||
return HttpResponseForbidden("You have read-only access to this space.")
|
||||
try:
|
||||
doc = pageops.read_page(slug, page_slug)
|
||||
if not doc.exists:
|
||||
return HttpResponseNotFound("Page not found.")
|
||||
if request.method == "POST":
|
||||
try:
|
||||
length = int(request.META.get("CONTENT_LENGTH") or 0)
|
||||
except (ValueError, TypeError):
|
||||
return HttpResponse("Invalid upload length.", status=400)
|
||||
if length < 0:
|
||||
return HttpResponse("Invalid upload length.", status=400)
|
||||
if length > attachments.MAX_UPLOAD_BYTES + 65536:
|
||||
return HttpResponse("Choose a file no larger than 16 MiB.", status=413)
|
||||
action = request.POST.get("action")
|
||||
if action == "add":
|
||||
attachments.add(slug, page_slug, request.FILES.get("file"),
|
||||
revision=request.POST.get("revision"))
|
||||
messages.success(request, "Attachment added.")
|
||||
elif action == "remove":
|
||||
attachments.remove(slug, page_slug, request.POST.get("reference"),
|
||||
revision=request.POST.get("revision"))
|
||||
messages.success(
|
||||
request, "Attachment removed from this page. Earlier revisions retain it.")
|
||||
else:
|
||||
raise pageops.PageOpsError("Choose an attachment action")
|
||||
clear_content_cache_for_space(space)
|
||||
return redirect("spaces:page_attachments", slug=slug, page_slug=page_slug)
|
||||
files = attachments.page_files(slug, doc)
|
||||
except (pageops.PageOpsError, OSError) as exc:
|
||||
messages.error(request, _mutation_error(exc))
|
||||
if request.method == "POST":
|
||||
return redirect("spaces:page_attachments", slug=slug, page_slug=page_slug)
|
||||
return HttpResponse("Attachments unavailable.", status=409)
|
||||
response = render(request, "spaces/attachments.html", {
|
||||
"space": space, "doc": doc, "files": files, "can_write": can_write,
|
||||
})
|
||||
response["Cache-Control"] = "private, no-store"
|
||||
return response
|
||||
|
||||
|
||||
def _principal_or_forbid(request: HttpRequest, resource: str):
|
||||
principal = build_principal(request, authz_resource_id=resource)
|
||||
if not principal["authz_allow"]:
|
||||
|
|
|
|||
49
coulomb_social/templates/spaces/attachments.html
Normal file
49
coulomb_social/templates/spaces/attachments.html
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Attachments — {{ doc.title }}{% endblock %}
|
||||
{% block content %}
|
||||
<p><a href="{% url 'spaces:detail' space.slug %}?page={{ doc.slug }}">← {{ doc.title }}</a></p>
|
||||
<h1>Attachments</h1>
|
||||
<p class="muted">Files for {{ doc.title }}. Access follows this space’s sharing settings.</p>
|
||||
<div class="card">
|
||||
<ul>
|
||||
{% for file in files %}
|
||||
<li style="margin:1rem 0;">
|
||||
<strong>{{ file.name }}</strong>
|
||||
{% if file.open_url %}<a class="btn secondary small" href="{{ file.open_url }}" target="_blank" rel="noopener">Open image</a>{% endif %}
|
||||
<a class="btn secondary small" href="{{ file.download_url }}">Download</a>
|
||||
{% if can_write %}
|
||||
{% if file.managed %}
|
||||
<form method="post" style="display:inline;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="remove">
|
||||
<input type="hidden" name="revision" value="{{ doc.revision }}">
|
||||
<input type="hidden" name="reference" value="{{ file.path }}">
|
||||
<button class="btn secondary small" type="submit">Remove from page</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="{% url 'spaces:page_edit' space.slug doc.slug %}">Edit page reference</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No attachments on this page.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% if can_write %}
|
||||
<div class="card">
|
||||
<h2>Add attachment</h2>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add">
|
||||
<input type="hidden" name="revision" value="{{ doc.revision }}">
|
||||
<p><label for="attachment-file">File (up to 16 MiB)</label><br>
|
||||
<input id="attachment-file" name="file" type="file" required></p>
|
||||
<button class="btn" type="submit">Add attachment</button>
|
||||
</form>
|
||||
<p class="muted">Removing a file from this page keeps it in earlier revisions. Existing links and visuals can be changed in the page editor.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Read-only access. Authors can add or remove attachments.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -109,6 +109,12 @@
|
|||
title="Copy the page content to paste into an email" aria-controls="copy-content-status">
|
||||
Copy content to clipboard
|
||||
</button>
|
||||
{% if page.source == 'content-plane' %}
|
||||
<details>
|
||||
<summary class="btn secondary small">Attachments</summary>
|
||||
<p><a href="{% url 'spaces:page_attachments' space.slug page.slug %}">{% if can_write %}Open, download or manage attachments{% else %}Open or download attachments{% endif %}</a></p>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if can_write %}
|
||||
<a class="btn small" href="{% url 'spaces:page_edit' space.slug page.slug|default:'index' %}">Edit page</a>
|
||||
|
|
|
|||
94
scripts/test-attachments-browser.mjs
Normal file
94
scripts/test-attachments-browser.mjs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import {chromium} from 'playwright';
|
||||
|
||||
const base = new URL(process.argv[2]);
|
||||
assert.equal(base.protocol, 'http:');
|
||||
assert.ok(['localhost', '127.0.0.1'].includes(base.hostname));
|
||||
const browser = await chromium.launch({headless: true});
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
const unexpected = [];
|
||||
await context.route('**/*', route => {
|
||||
if (new URL(route.request().url()).origin === base.origin) return route.continue();
|
||||
unexpected.push(route.request().url());
|
||||
return route.abort();
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
async function login(subject) {
|
||||
await page.goto(`${base.origin}/auth/dev-login/`);
|
||||
await page.getByLabel('Subject').fill(subject);
|
||||
await page.getByLabel('Tenant').fill('tenant:attachment-test');
|
||||
await page.getByRole('button', {name: 'Establish session'}).click();
|
||||
await page.waitForURL('**/app/');
|
||||
}
|
||||
const shared = `${base.origin}/app/spaces/attachments-shared/`;
|
||||
const destination = `${base.origin}/app/spaces/attachments-private/`;
|
||||
const source = `${shared}?page=notes`;
|
||||
async function menu(url, author) {
|
||||
await page.goto(url);
|
||||
await page.locator('summary', {hasText: 'Attachments'}).click();
|
||||
await page.getByRole('link', {name: author
|
||||
? 'Open, download or manage attachments' : 'Open or download attachments', exact: true}).click();
|
||||
await page.waitForURL('**/attachments/');
|
||||
}
|
||||
async function downloadedBytes() {
|
||||
const pending = page.waitForEvent('download');
|
||||
await page.getByRole('link', {name: 'Download', exact: true}).click();
|
||||
const stream = await (await pending).createReadStream();
|
||||
const chunks = [];
|
||||
for await (const chunk of stream) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString();
|
||||
}
|
||||
await login('file-author');
|
||||
await menu(source, true);
|
||||
await page.getByLabel('File (up to 16 MiB)').setInputFiles({
|
||||
name: 'meeting-notes.txt', mimeType: 'text/plain', buffer: Buffer.from('Synthetic attachment bytes'),
|
||||
});
|
||||
await page.getByRole('button', {name: 'Add attachment', exact: true}).click();
|
||||
await page.waitForURL('**/attachments/');
|
||||
assert.match(await page.locator('main').innerText(), /Attachment added/);
|
||||
const originalDownload = await page.getByRole('link', {name: 'Download', exact: true}).getAttribute('href');
|
||||
assert.equal(await downloadedBytes(), 'Synthetic attachment bytes');
|
||||
await login('file-reader');
|
||||
await menu(source, false);
|
||||
assert.equal(await page.getByRole('button', {name: 'Add attachment', exact: true}).count(), 0);
|
||||
assert.equal(await page.getByRole('button', {name: 'Remove from page', exact: true}).count(), 0);
|
||||
assert.equal(await downloadedBytes(), 'Synthetic attachment bytes');
|
||||
await login('file-outsider');
|
||||
assert.equal((await page.goto(new URL(originalDownload, base).href)).status(), 404);
|
||||
assert.equal((await page.goto(`${shared}pages/notes/attachments/`)).status(), 404);
|
||||
await login('file-author');
|
||||
await page.goto(source);
|
||||
await page.getByRole('link', {name: 'Duplicate page', exact: true}).click();
|
||||
await page.getByLabel('Destination space').selectOption('attachments-private');
|
||||
await page.getByLabel('Title', {exact: false}).fill('Independent files');
|
||||
await page.getByLabel('New page slug', {exact: false}).fill('copied');
|
||||
await page.getByRole('button', {name: 'Create duplicate'}).click();
|
||||
await page.waitForURL('**?page=copied');
|
||||
await menu(`${destination}?page=copied`, true);
|
||||
assert.equal(await downloadedBytes(), 'Synthetic attachment bytes');
|
||||
const copiedDownload = await page.getByRole('link', {name: 'Download', exact: true}).getAttribute('href');
|
||||
assert.notEqual(copiedDownload, originalDownload);
|
||||
await page.getByRole('button', {name: 'Remove from page', exact: true}).click();
|
||||
await page.waitForURL('**/attachments/');
|
||||
assert.match(await page.locator('main').innerText(), /No attachments on this page/);
|
||||
await menu(source, true);
|
||||
assert.equal(await downloadedBytes(), 'Synthetic attachment bytes');
|
||||
await page.goto(source);
|
||||
await page.getByRole('link', {name: 'Transfer', exact: true}).click();
|
||||
await page.getByLabel('Destination space').selectOption('attachments-private');
|
||||
await page.getByLabel('Slug in destination', {exact: false}).fill('moved');
|
||||
await page.getByRole('button', {name: 'Transfer page', exact: true}).click();
|
||||
await page.waitForURL('**?page=moved');
|
||||
await menu(`${destination}?page=moved`, true);
|
||||
assert.equal(await downloadedBytes(), 'Synthetic attachment bytes');
|
||||
const movedDownload = await page.getByRole('link', {name: 'Download', exact: true}).getAttribute('href');
|
||||
await login('file-reader');
|
||||
assert.equal((await page.goto(source)).status(), 404);
|
||||
assert.equal((await page.goto(new URL(movedDownload, base).href)).status(), 404);
|
||||
assert.deepEqual(unexpected, []);
|
||||
console.log('Attachment menu upload, download, independent copy, move and removal passed');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
239
tests/test_attachments.py
Normal file
239
tests/test_attachments.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"""Author-managed attachments preserve permissions, revisions and crash recovery."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
from coulomb_social.apps.spaces import attachments, pageops
|
||||
from coulomb_social.apps.spaces.assets import local_download_url
|
||||
from coulomb_social.apps.spaces.content import load_space_page
|
||||
from coulomb_social.apps.spaces.models import SpaceMembership
|
||||
from coulomb_social.apps.spaces.services import create_space, put_space_page
|
||||
from tests.test_space_permissions import make_member
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def people(tmp_path, settings, client):
|
||||
settings.CONTENT_ROOT = str(tmp_path / "content")
|
||||
owner, reader, outsider = [make_member(name) for name in ("author", "reader", "outsider")]
|
||||
space = create_space(owner, title="Shared files", slug="shared-files")
|
||||
SpaceMembership.objects.create(space=space, member=reader, role="viewer")
|
||||
doc = put_space_page(owner, space, "notes", title="Notes", body="Retained notes",
|
||||
extra_meta={"source_created_at": "2020-01-02T03:04:05Z"})
|
||||
client.force_login(owner.user)
|
||||
return space, doc, owner, reader, outsider
|
||||
|
||||
|
||||
def upload(space, doc, name="Report.pdf", data=b"synthetic PDF"):
|
||||
return attachments.add(space.slug, doc.slug, SimpleUploadedFile(name, data),
|
||||
revision=doc.revision)
|
||||
|
||||
|
||||
def test_upload_download_and_remove_preserve_source_and_retained_revisions(people, client):
|
||||
space, doc, _, reader, _ = people
|
||||
url = reverse("spaces:page_attachments", args=[space.slug, doc.slug])
|
||||
result = client.post(url, {"action": "add", "revision": doc.revision,
|
||||
"file": SimpleUploadedFile("Report.pdf", b"synthetic PDF")})
|
||||
assert result.status_code == 302
|
||||
added = pageops.read_page(space.slug, doc.slug)
|
||||
entry, = attachments.entries(added.meta)
|
||||
assert entry["name"] == "Report.pdf"
|
||||
assert added.body == doc.body
|
||||
assert added.meta["source_created_at"] == doc.meta["source_created_at"]
|
||||
client.force_login(reader.user)
|
||||
response = client.get(local_download_url(space.slug, entry["path"]))
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "application/octet-stream"
|
||||
assert response["Content-Disposition"].startswith("attachment;")
|
||||
assert response["Cache-Control"] == "private, no-store"
|
||||
assert response["X-Content-Type-Options"] == "nosniff"
|
||||
assert b"".join(response.streaming_content) == b"synthetic PDF"
|
||||
client.force_login(people[2].user)
|
||||
assert client.post(url, {"action": "remove", "revision": added.revision,
|
||||
"reference": entry["path"]}).status_code == 302
|
||||
current = pageops.read_page(space.slug, doc.slug)
|
||||
assert attachments.entries(current.meta) == [] and current.body == doc.body
|
||||
assert (pageops.space_dir(space.slug) / entry["path"]).read_bytes() == b"synthetic PDF"
|
||||
retained = pageops.content_root() / ".revisions" / space.slug / doc.slug
|
||||
assert (retained / (doc.revision + ".md")).is_file()
|
||||
assert entry["path"] in (retained / (added.revision + ".md")).read_text()
|
||||
|
||||
|
||||
def test_attachment_permissions_and_revocation_cover_reads_and_mutations(people, client):
|
||||
space, doc, owner, reader, outsider = people
|
||||
added = upload(space, doc)
|
||||
entry, = attachments.entries(added.meta)
|
||||
url = reverse("spaces:page_attachments", args=[space.slug, doc.slug])
|
||||
download = local_download_url(space.slug, entry["path"])
|
||||
foreign = make_member("foreign", tenant="tenant:b")
|
||||
SpaceMembership.objects.create(space=space, member=foreign, role="owner")
|
||||
client.force_login(reader.user)
|
||||
assert client.get(url).status_code == 200
|
||||
assert b"Add attachment" not in client.get(url).content
|
||||
assert client.post(url, {"action": "remove"}).status_code == 403
|
||||
for member in (outsider, foreign):
|
||||
client.force_login(member.user)
|
||||
assert client.get(url).status_code == client.get(download).status_code == 404
|
||||
assert client.post(url, {"action": "add"}).status_code == 404
|
||||
SpaceMembership.objects.filter(space=space, member=reader).delete()
|
||||
client.force_login(reader.user)
|
||||
assert client.get(url).status_code == client.get(download).status_code == 404
|
||||
client.force_login(owner.user)
|
||||
assert client.post(download).status_code == 405
|
||||
space.is_active = False
|
||||
space.save()
|
||||
assert client.get(download).status_code == 404
|
||||
client.logout()
|
||||
assert client.get(url).status_code == client.get(download).status_code == 302
|
||||
|
||||
|
||||
def test_stale_missing_or_foreign_attachment_actions_do_not_change_files(people, client):
|
||||
space, doc, owner, _, _ = people
|
||||
added = upload(space, doc)
|
||||
before = {str(p): p.read_bytes() for p in pageops.content_root().rglob('*') if p.is_file()}
|
||||
for revision in (doc.revision, None, ""):
|
||||
with pytest.raises(pageops.PageOpsError, match="changed"):
|
||||
attachments.add(space.slug, doc.slug, SimpleUploadedFile("more.txt", b"more"),
|
||||
revision=revision)
|
||||
with pytest.raises(pageops.PageOpsError, match="not found"):
|
||||
attachments.remove(space.slug, doc.slug, "assets/elsewhere.txt", revision=added.revision)
|
||||
after = {str(p): p.read_bytes() for p in pageops.content_root().rglob('*') if p.is_file()}
|
||||
assert after == before
|
||||
guarded = Client(enforce_csrf_checks=True)
|
||||
guarded.force_login(owner.user)
|
||||
url = reverse("spaces:page_attachments", args=[space.slug, doc.slug])
|
||||
assert guarded.post(url, {"action": "remove"}).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["../pages/notes.md", "nested/../../private", "..%2fsecret",
|
||||
"missing", "link.txt", "folder/link.txt"])
|
||||
def test_download_refuses_missing_traversal_and_symlinks(people, client, tmp_path, path):
|
||||
space, _, _, _, _ = people
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("private")
|
||||
(pageops.assets_dir(space.slug) / "link.txt").symlink_to(secret)
|
||||
(pageops.assets_dir(space.slug) / "folder").symlink_to(tmp_path, target_is_directory=True)
|
||||
assert client.get(reverse("spaces:download", args=[space.slug, path])).status_code == 404
|
||||
|
||||
|
||||
def test_active_content_is_download_only_and_filenames_are_escaped(people, client):
|
||||
space, doc, _, _, _ = people
|
||||
added = upload(space, doc, '<script>alert(1)</script>.html', b'<script>synthetic()</script>')
|
||||
entry, = attachments.entries(added.meta)
|
||||
response = client.get(local_download_url(space.slug, entry["path"]))
|
||||
assert response["Content-Disposition"].startswith("attachment;")
|
||||
assert response["Content-Type"] == "application/octet-stream"
|
||||
assert "sandbox" in response["Content-Security-Policy"]
|
||||
assert b"".join(response.streaming_content) == b'<script>synthetic()</script>'
|
||||
menu = client.get(reverse("spaces:page_attachments", args=[space.slug, doc.slug]))
|
||||
assert b"<script>" not in menu.content
|
||||
|
||||
|
||||
def test_inline_body_files_have_authenticated_download_links(people, client):
|
||||
space, doc, _, _, _ = people
|
||||
(pageops.assets_dir(space.slug) / "legacy.txt").write_bytes(b"legacy bytes")
|
||||
doc = pageops.write_page(space.slug, doc.slug, title="Notes",
|
||||
body="[Existing resource](assets/legacy.txt)")
|
||||
file, = attachments.page_files(space.slug, doc)
|
||||
assert not file["managed"]
|
||||
assert file["download_url"] in load_space_page(space, doc.slug).html
|
||||
menu = client.get(reverse("spaces:page_attachments", args=[space.slug, doc.slug]))
|
||||
assert b"Edit page reference" in menu.content
|
||||
|
||||
|
||||
def test_edit_and_rename_keep_attachment_associations(people, client):
|
||||
space, doc, _, _, _ = people
|
||||
added = upload(space, doc)
|
||||
response = client.post(reverse("spaces:page_edit", args=[space.slug, doc.slug]), {
|
||||
"title": "Renamed notes", "slug": "renamed", "body": "Updated notes",
|
||||
"revision": added.revision,
|
||||
})
|
||||
assert response.status_code == 302
|
||||
changed = pageops.read_page(space.slug, "renamed")
|
||||
assert changed.body == "Updated notes\n"
|
||||
assert attachments.entries(changed.meta) == attachments.entries(added.meta)
|
||||
assert changed.meta["source_created_at"] == doc.meta["source_created_at"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["copy_page", "transfer_page"])
|
||||
def test_copy_move_and_detach_keep_attachment_bytes_independent(people, operation):
|
||||
space, doc, owner, _, _ = people
|
||||
added = upload(space, doc)
|
||||
target = create_space(owner, title="Other", slug="other")
|
||||
copied = getattr(pageops, operation)(space.slug, doc.slug, target.slug, "copied")
|
||||
original, = attachments.entries(added.meta)
|
||||
entry, = attachments.entries(copied.meta)
|
||||
assert entry["path"] != original["path"]
|
||||
assert (pageops.space_dir(target.slug) / entry["path"]).read_bytes() == b"synthetic PDF"
|
||||
attachments.remove(target.slug, copied.slug, entry["path"], revision=copied.revision)
|
||||
assert (pageops.space_dir(space.slug) / original["path"]).read_bytes() == b"synthetic PDF"
|
||||
assert pageops.recover_interrupted_content() == {"cleared": 0, "held": 0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase", ["partial", "bytes_saved", "revision_saved", "page_saved"])
|
||||
def test_upload_process_death_preserves_page_or_complete_retained_attachment(people, phase):
|
||||
space, doc, _, _, _ = people
|
||||
code = r'''
|
||||
import os,sys
|
||||
from django.conf import settings
|
||||
settings.configure(CONTENT_ROOT=sys.argv[1])
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from coulomb_social.apps.spaces import attachments,pageops
|
||||
phase=sys.argv[4]
|
||||
opening=attachments.open_local_asset
|
||||
def open_asset(*a,**kw):
|
||||
f=opening(*a,**kw)
|
||||
if phase=='partial' and kw.get('create'):
|
||||
f.write(b'partial'); f.flush(); os.fsync(f.fileno()); os._exit(86)
|
||||
return f
|
||||
attachments.open_local_asset=open_asset
|
||||
save=attachments._save
|
||||
def saving(*a):
|
||||
if phase=='bytes_saved':os._exit(86)
|
||||
return save(*a)
|
||||
attachments._save=saving
|
||||
remember=pageops._remember
|
||||
def remembering(space,page,data):
|
||||
remember(space,page,data)
|
||||
if phase=='revision_saved' and b'coulomb_attachments' in data:os._exit(86)
|
||||
pageops._remember=remembering
|
||||
clear=pageops._clear_record
|
||||
def clearing(*a):
|
||||
if phase=='page_saved':os._exit(86)
|
||||
return clear(*a)
|
||||
pageops._clear_record=clearing
|
||||
attachments.add(sys.argv[2],'notes',SimpleUploadedFile('report.txt',b'complete bytes'),
|
||||
revision=sys.argv[3])
|
||||
'''
|
||||
result = subprocess.run([sys.executable, "-c", code, str(pageops.content_root()),
|
||||
space.slug, doc.revision, phase], capture_output=True, timeout=20,
|
||||
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
|
||||
assert result.returncode == 86, result.stderr.decode()
|
||||
assert pageops.recover_interrupted_content() == {"cleared": 1, "held": 0}
|
||||
current = pageops.read_page(space.slug, doc.slug)
|
||||
assert current.body == doc.body
|
||||
assets = list(pageops.assets_dir(space.slug).iterdir())
|
||||
if phase in {"partial", "bytes_saved"}:
|
||||
assert current.revision == doc.revision and not assets
|
||||
else:
|
||||
assert len(assets) == 1 and assets[0].read_bytes() == b"complete bytes"
|
||||
retained = pageops.content_root() / ".revisions" / space.slug / doc.slug
|
||||
assert any(attachments.KEY in p.read_text() for p in retained.glob("*.md"))
|
||||
if phase == "page_saved":
|
||||
assert len(attachments.entries(current.meta)) == 1
|
||||
|
||||
|
||||
def test_oversized_upload_refuses_before_file_or_page_write(people, monkeypatch):
|
||||
space, doc, _, _, _ = people
|
||||
monkeypatch.setattr(attachments, "MAX_UPLOAD_BYTES", 4)
|
||||
with pytest.raises(pageops.PageOpsError, match="16 MiB"):
|
||||
upload(space, doc, data=b"too large")
|
||||
assert pageops.read_page(space.slug, doc.slug).revision == doc.revision
|
||||
assert not list(pageops.assets_dir(space.slug).iterdir())
|
||||
|
|
@ -165,11 +165,16 @@ def test_purge_stops_before_deleting_when_a_revision_hash_does_not_match(trees):
|
|||
'after_assets', 'after_page', 'published')
|
||||
] + [('transfer_page', phase) for phase in ('aliased', 'source_deleted')])
|
||||
def test_process_death_during_copy_or_move_is_recoverable(trees, operation, phase):
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
source = _notes('Retained content [attachment](assets/second.txt)')
|
||||
(pageops.assets_dir('source') / 'second.txt').write_bytes(b'synthetic attachment')
|
||||
(pageops.assets_dir('source') / 'managed.txt').write_bytes(b'managed attachment')
|
||||
source = pageops.write_page('source', 'notes', title=source.title, body=source.body,
|
||||
extra_meta={'coulomb_attachments': json.dumps([
|
||||
{'name': 'Managed file', 'path': 'assets/managed.txt'}])})
|
||||
original = Path(source.path).read_bytes()
|
||||
child = r"""
|
||||
import os, sys
|
||||
|
|
@ -245,6 +250,10 @@ getattr(pageops, sys.argv[2])('source', 'notes', 'destination', 'notes')
|
|||
destination = pageops.assets_dir('destination')
|
||||
assert (destination / 'notes-diagram.png').read_bytes() == b'synthetic image'
|
||||
assert (destination / 'notes-second.txt').read_bytes() == b'synthetic attachment'
|
||||
assert (destination / 'notes-managed.txt').read_bytes() == b'managed attachment'
|
||||
from coulomb_social.apps.spaces.attachments import entries
|
||||
assert entries(pageops.read_page('destination', 'notes').meta) == [
|
||||
{'name': 'Managed file', 'path': 'assets/notes-managed.txt'}]
|
||||
assert not list((trees / '.recovery').glob('*.json'))
|
||||
assert pageops.recover_interrupted_content() == {'cleared': 0, 'held': 0}
|
||||
|
||||
|
|
|
|||
|
|
@ -341,9 +341,18 @@ def test_capsule_preparation_is_private_fresh_and_has_a_new_attempt_id(prepared,
|
|||
assert proofs[0] != proofs[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def native_observer_tmp():
|
||||
# The native observer deliberately requires /tmp, regardless of TMPDIR.
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="csoc-observer-test-", dir="/tmp") as directory:
|
||||
yield Path(directory)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changed_image", [False, True])
|
||||
def test_observer_prepares_without_capturing_or_refuses_missing_release(
|
||||
prepared, tmp_path, monkeypatch, changed_image):
|
||||
prepared, native_observer_tmp, monkeypatch, changed_image):
|
||||
import runpy
|
||||
import sys
|
||||
|
||||
|
|
@ -354,7 +363,7 @@ def test_observer_prepares_without_capturing_or_refuses_missing_release(
|
|||
finally:
|
||||
sys.path.pop(0)
|
||||
monkeypatch.setattr(supervisor.os, "geteuid", lambda: 0) # simulated root context only
|
||||
output = tmp_path / "observed-capsule"
|
||||
output = native_observer_tmp / "observed-capsule"
|
||||
if changed_image:
|
||||
node.dep["spec"]["template"]["spec"]["containers"][0]["image"] += "0"
|
||||
with pytest.raises(RuntimeError, match="prerequisite_missing"):
|
||||
|
|
|
|||
36
tests/usecases/test_attachments_browser.py
Normal file
36
tests/usecases/test_attachments_browser.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""Synthetic attachment menu journey through the real application UI."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from coulomb_social.apps.spaces.services import create_space, put_space_page, set_space_access
|
||||
from tests.test_space_permissions import make_member
|
||||
|
||||
|
||||
@pytest.mark.browser
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_attachment_upload_copy_move_download_and_detach_through_ui(tmp_path, settings, request):
|
||||
settings.DEBUG = True
|
||||
settings.OIDC_ENABLED = False
|
||||
settings.USER_ENGINE_BASE_URL = ""
|
||||
settings.FLEX_AUTH_BASE_URL = ""
|
||||
settings.FORGEJO_BASE_URL = ""
|
||||
settings.CONTENT_ROOT = str(tmp_path / "content")
|
||||
owner, reader, outsider = [make_member(n, tenant="tenant:attachment-test")
|
||||
for n in ("file-author", "file-reader", "file-outsider")]
|
||||
for member in (owner, reader, outsider):
|
||||
member.issuer = "https://local.dev/issuer"
|
||||
member.save()
|
||||
space = create_space(owner, title="Shared attachments", slug="attachments-shared")
|
||||
create_space(owner, title="Private destination", slug="attachments-private")
|
||||
put_space_page(owner, space, "notes", title="Notes", body="Synthetic notes")
|
||||
set_space_access(owner, space, reader.pk, "viewer")
|
||||
server = request.getfixturevalue("live_server")
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
result = subprocess.run(
|
||||
["node", str(root / "scripts/test-attachments-browser.mjs"), str(server)],
|
||||
cwd=root, capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
Loading…
Add table
Add a link
Reference in a new issue