Uploaded documents: no orphans, no corrupted files, and one boundary for where the bytes live #663
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#663
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The maintainer's rule, from the list of 2026-10-02: "An uploaded document (PDF, ODT, DOCX, PNG, JPG), belongs only to the user that uploads it, and is stored on an isolated folder. Orphan files on that folder are not tolerated. Corrupted files either. Data integrity is key. A step closer to storage-oriented plugins." Ownership and the per-person folder are in place. Orphans are removed on delete but not on every other path, nothing looks for corruption after a file arrives, and the code that touches the bytes assumes a local directory in five places.
What exists today
UploadedDocument(src/postulo/documents/models.py:564) keepsfileunderdocuments/<owner pk>/<YYYY>/<MM>/(upload_to_documents,:45-50). Download goes throughOwnedObjectMixinandserve_private_file(documents/views.py:653-667,core/files.py:65-117); a foreign file is a 404. Nothing re-checks, at serve time, that the stored name lies under the owner's own prefix;resolve_media_path(core/files.py:37-48) checks only that it lies underMEDIA_ROOT.post_deletereceivers that remove the file after the transaction commits and only when no other row uses the name (documents/signals.py:38-69), andprune_media. Account deletion names uploads and renders inaccounts/deletion.py:61-81.FileFieldwrites the bytes when the row is saved, inside the request's transaction (ATOMIC_REQUESTS,config/settings/base.py:149). The delete side has anon_commithook and a test (tests/test_document_record.py:174); the write side has nothing, so a request that fails after the file is written leaves the file with no row.importer.loadis one@transaction.atomic(core/importer.py:405) and writes each file as it goes (:1000-1004,:1034-1038). A failure half way rolls the rows back and leaves every file already written.prune_medianames as its reason (documents/management/commands/prune_media.py:1-9).prune_mediais only run by hand. It is named in the wiki (Files-and-what-you-sent.md:154-155) and inrestore's closing message (core/management/commands/restore.py:83), and not in the scheduler's pass (notifications/management/commands/send_due_reminders.py:168-200), which reaps export archives and expired kept pages. Its own hand-kept list is #355's (open): it omitsExportArchive.file, so it would delete live exports.UploadedDocument.checksumis written once, the first time the bytes are here (documents/models.py:614,:645-669, #217). Nothing compares it with the bytes afterwards.RenderedDocumenthas the one check,file_is_as_rendered(:869-885), and it is asked only when deciding whether an earlier render can be handed back (documents/rendering.py:633).UPLOAD_FIELDShas no checksum (core/export.py:306-318), so on import the checksum is computed from whatever bytes arrived (UploadedDocument.save) and a damaged file gets a valid-looking one. For sent documentsSENT_FIELDScarries it (core/export.py:324) and the importer takes it as written without hashing the file (core/importer.py:1017-1041). The zip's own CRC-32 is the only check;_extractcatches onlyKeyError(core/importer.py:1255-1261).documents/archiving.py:135opens the file and hands it to a store plugin withmetadata_for(document), which includes the stored checksum (documents/stores.py:95), without checking that the bytes match it.pdf, doc, docx, odt, rtf, txt, png, jpg, jpeg(documents/models.py:581-590) and the 20 MB cap in the form (documents/forms.py:29,:508-515). A file calledcv.pdfthat is not a PDF is stored, hashed and delivered; the type in the response is guessed from the name (core/files.py:90). The importer runs neither the extension validator nor any cap on uploads (size is #466's). Two precedents for a content check already exist:jobs/pages.py:85-101(is_what_it_says: first bytes of a PNG, JPEG, WebP or PDF, "not a decoder, and deliberately not one") andcore/pictures.py:240-244(SVG recognised by a pattern).jobs/pages.py:740-775says of an archive "nothing in it is taken on trust: not the sizes, not the checksums, not what it says a file is" and recomputes every figure; uploads are not restored that way.StorePlugin(postulo.plugins.api) is for copies andplugins/localstore/__init__.pyis "a plugin in shape only", whoseputisdocument.file.saveand which is called once, for renders (documents/rendering.py:546); uploads are written by the form'sFileField, not through it. Underneath,FileFielduses Django's storage API, withFileSystemStorageas the default (config/settings/base.py:619-622). Around that, five places use the directory directly:core/files.py:37-117(path.is_file(),path.open,X-Accel-RedirectandX-Sendfile),prune_media(rgloboverMEDIA_ROOT),accounts/deletion.py:84-151(Pathandrmdir),core/backup.py:328-398and:510(tars the tree), andcore/server_views.py:136(sums the tree for the overview). #13 decided "local stays the source of truth. External stores receive copies" and left an external-only mode "deliberately not proposed".Checked against
mainatf09b6010b.Proposal
Five commit-sized parts, in this order.
documents.integrity.looks_like(extension, head_and_tail)(or a module besidecore/pictures.py) that answers whether the bytes are plausibly what the extension says:%PDF-at the start and%%EOFnear the end for a PDF; a PNG or JPEG that Pillow can open (core/pictures.decode's guard on pixels applies; the bytes are not re-encoded, because an upload is kept as it was); a ZIP with themimetypeentry first for ODT and a[Content_Types].xmlentry for DOCX, listed from the zip directory and never unpacked;{\rtffor RTF; the OLE2 header for DOC; text with no NUL bytes for TXT. The container questions go topuremagicif the recommendation under Decisions is taken, and are answered by hand from the zip directory if it is not. Used by the upload form (clean_file), the importer and the scrub below. The refusal says what was expected, not how it failed. Nothing opens the file with a document library.jobs/pages.pydoes for kept pages; the sent-document checksum is checked the same way.archiving.sendcompares size and checksum before it hands bytes to a store, and does not send a copy that does not match.serve_private_filefor an upload checks that the stored name starts withdocuments/<owner pk>/.manage.py verify_media(and a slice of it in the scheduler's pass, a fixed number of rows each time, oldest-checked first) reads each upload and render in chunks and reports: a row whose file is missing, a row whose size or SHA-256 differs from what was recorded, and a file under the person's folder that no row names. It never deletes a corrupted file. The Files page shows a short "This file is missing" or "This file no longer matches what was uploaded" on the row, and the person can upload a replacement as a new version (replaces). The operator sees the totals under Server settings → Overview.pages._writedoes,jobs/pages.py:301-322), and the importer removes every file it wrote whenloadfails. A request that fails after those have returned rolls the row back out of this code's reach (Django hason_commitand no rollback hook), and that case is the sweep's: the grace period below exists for it. (b) Sweep side: the scheduled pass runs the orphan listing over the same derived list of file fields that #355 proposes (apps.get_models()), and removes only a file whose modification time is older than a grace period, never one a request may still be about to attach. The command's manual--removestays for the rest.documents/: open for reading, save, delete, exists, size, list names under a prefix, and how to hand a file to a browser (a stream, anX-Accelpath, or an expiring address). The default is today's behaviour.serve_private_file,prune_media,deletion,backupand the overview call it and notPath. Nothing is published onpostulo.plugins.apiuntil a plugin needs it; the wiki's Files and what you sent gets a short section on what a storage backend has to provide.Decisions
prune_mediawas written not to have ("the failure mode of the opposite default is somebody's CV"). The person is told and replaces it.X-Accel-Redirectthe bytes never pass through Django. Size is a new column beside the checksum (it is not stored today;DocumentMetadata.sizereads it from disk).doc,rtfandtxt(documents/models.py:585-589). Recommended: keep all nine, because narrowing refuses what people already store, and say so in the wiki. Question for the maintainer: aredoc,rtfandtxtmeant to stay?--removeon by default. Recommended: scheduled, with a 24-hour grace period, logged line by line, and a setting to switch it off. It depends on #355 landing first, so that a live export archive counts as referenced. Conflict:prune_medialists by default on purpose (its docstring, #217); this keeps that for the manual command and changes only the unattended one, which can only reach files that are older than a grace period and have no row.puremagicfor the container questions and Pillow (already a dependency) for images, with the hand-written agreement table between extension and detected type, which no library can supply.Prior art
Content type:
puremagic(https://github.com/cdgriffith/puremagic): identifies files by magic numbers in pure Python, with scanners for ZIP contents; MIT; 2.2.0 released 2026-04-08; requires Python 3.12 or later (Postulo:>=3.12.4); no runtime dependencies; 0 advisories on OSV; not a dependency today. Its detection of DOCX and ODT from ZIP contents is as its README states and was not run here: not verified.filetype(https://github.com/h2non/filetype.py): MIT; 1.2.0, released 2022-11-02, so no release for nearly four years; no dependencies; reads only the first 261 bytes; 0 advisories. Maintenance is the reason not to pick it.python-magic(MIT, 0.4.27, 2022-06-07, 0 advisories on OSV): a binding tolibmagic, which the image does not install (docker/Dockerfile:246-250); adds a system package and a C parser of untrusted files. Not adopted.magika(Google; Apache-2.0; 1.0.3, 2026-05-04): a model-based detector that requiresonnxruntime. A heavy dependency for nine extensions. Not adopted.pypdf(BSD-3-Clause, 6.19.0, 2026-09-16): has had many denial-of-service advisories on OSV (101 listed across versions, 0 affecting 6.19.0), all from parsing untrusted PDFs in-process. Not adopted: the check proposed here does not parse a PDF at all.jobs/pages.py:85-101andcore/pictures.py:240-244. It applies for four types and does not for ODT, DOCX and DOC, whose evidence is inside a ZIP or an OLE2 container.Storage:
StorageAPI is built in and already whatFileFielduses;STORAGES["default"]can be any backend. It is the boundary to adopt rather than a new one.django-storages(https://github.com/jschneier/django-storages): BSD-3-Clause; 1.14.6, released 2025-04-02; 0 advisories on OSV; requires Django 3.2 or later (Postulo is on 6.1); backends for S3, Azure, Google Cloud, SFTP, Dropbox and others, each behind an extra. Not a dependency today. It would be what a storage plugin installs, not something core needs, so it is not adopted now; the point of part 5 is that it could work, which it cannot whilecore/files.pyand the other four places read the directory.Builds on
#217 (closed), #13 (closed), #355, #466, #241, #325.
Needed by
#669 (diplomas and certificates are uploads whose damage matters most), #666 (a received reference letter is an upload), #670 (a credential file is verified as it is read).
Done when
tests/test_documents.py: a file namedcv.pdfthat is not a PDF is refused with the reason; so is an ODT with nomimetypefirst entry and a DOCX with no[Content_Types].xml; a real file of each of the nine types is accepted (new tests; the first three fail today).tests/test_document_record.py: a request that fails after the file is written leaves no file behind (besidetest_a_rolled_back_delete_keeps_the_file); the sweep removes an old file with no row, leaves a recent one, leaves one a row names, and leaves a liveExportArchive(shared with #355's test);verify_mediareports a missing file, a changed byte and a stray file, and deletes nothing.tests/test_export.pyandtests/security/test_files.py: an archive whose upload is truncated or changed is reported and the row restored without the file; one whose sent-document checksum does not match its bytes is reported; a failure half way throughimporter.loadleaves no file underMEDIA_ROOT.tests/test_stores.py: a copy whose bytes do not match the recorded checksum is not sent and the copy row says why.serve_private_filerefuses a stored name outsidedocuments/<owner pk>/(tests/security/test_files.py).pathlibagainstMEDIA_ROOTonce part 5 is done.core/export.pyFORMAT_VERSION, 29 onf09b6010b; write one more than whatever it is when the commit lands) for the size and checksum of uploads; older archives still import. Candidate format (4) unchanged. API: the document serializer gains the integrity state if it lists uploads. Search unchanged. Processing record (core/gdpr.py) unchanged: the same files, kept the same way.tests/test_page_coverage.pyandtests/security/test_isolation_sweep.pyif the Files page gains a view; strings in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and Weblate (#349).From the list of improvements of 2026-10-02.
Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#663).