Avatar and company logo: database storage that goes with the owner, or files with a guaranteed lifecycle #662
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#662
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 avatar photo is stored on the database. If the user is deleted, the uploaded avatar go with it." and "A company logo is stored on the database. If the company is deleted, the logo go with it." Today both are files under private media, not rows, so neither sentence is true yet. This issue records the rule, the facts, and the two ways to make it true.
What exists today
Profile.avataris anImageFieldstored underavatars/<user pk>/(src/postulo/accounts/models.py:152-154,:410). The Gravatar copy is a second file in the same directory (models.py:414-416);Profile.picturechooses between them (models.py:443-450). The picture is decoded, cropped square and re-encoded as PNG (accounts/avatars.py:67-80), with a 5 MB input cap (:48) and a 1 MB stored budget (:52).Company.logois aFileFieldunderlogos/<owner pk>/(jobs/models.py:204-206,:299). A raster is re-encoded as PNG within the same 1 MB budget (jobs/logos.py:57,:90-107); an SVG is sanitised and stays an SVG (core/pictures.py:290-315, at most 512 KiB,:244). So no stored picture weighs more than 1 MiB.AvatarView(accounts/views.py:297-315) andCompanyLogoView(jobs/views.py:412-435) callserve_private_file(core/files.py:65-117), which can hand the file toX-Accel-RedirectorX-Sendfileand otherwise streams it.{% company_logo %}draws one<img>per company, each a request to that view with a one-day cache and a?v=stamp (core/templatetags/postulo.py:231-248).accounts/deletion.py:61-81(files_of) names the avatar and the Gravatar copy but not the logo, andmedia_directories_of(:84-91) prunesavatars/<pk>but notlogos/<pk>. There is nopost_deletereceiver forProfileorCompany(the only receivers that remove files aredocuments/signals.py:38-39andjobs/signals.py:57). The result is the opposite of the maintainer's second sentence: a company deleted from its own page leaves its logo (jobs/views.py:574).prune_mediadeletes live exports. #525 (open, 0.6.0):logos.storeandlogos.cleardelete the old file before the row is saved (jobs/logos.py:214-244). #466 (open, 0.5.0): the archive importer stores an avatar or logo without runningavatars.processorlogos.process(core/importer.py:504-508,:666,:697). #482 (open, 0.6.0): re-encoding a phone photo holds a web worker for seconds.accounts/admin.py:14-15; the admin is mounted only when an operator setsPOSTULO_ADMIN_URL,config/urls.py:33-36) loses theProfilerow by cascade and keeps the avatar and the Gravatar copy on disk, because no receiver exists forProfile. A comment on #355 records it.core/backup.py:1-12,include_mediaat:328;manage.py backup --no-medialeaves the tree out), so "one thing to back up" is already nearly true for an operator who does not pass--no-media.media/prefix, named in the JSON byaccount.avatar_fileandcompanies[].logo_file(core/export.py:655-657,:693,:984-989); the importer saves them back (core/importer.py:504-508,:697).prune_medialists them as referenced (documents/management/commands/prune_media.py:29-31).docs/PLAN.mdsection 6 puts private media behind an ownership-checked view, and #217 settled that deleting a row deletes its file. Neither says why a picture of at most 1 MiB has to be a file rather than a column; the pictures were files because uploads were.accounts/forms.py:448,:513,:611,:649-651),profile.html:53-76, the template tag above, company merging (jobs/merging.py:401-407,:491-507), the company form (jobs/forms.py:256),accounts/deletion.py,prune_media, export and import.Checked against
mainatf09b6010b.Proposal
CONTRIBUTING.mdwhich pictures live where. This is the one decision that the rest depends on: see the first decision below.BinaryFieldplus its media type and the moment it was stored. A profile has up to two pictures (the upload and the Gravatar copy), a company has up to one. The side table isOneToOne/ForeignKeywithon_delete=CASCADE, so deleting the owner deletes the picture in the same statement, in the same transaction, with no receiver, noon_commit, nofiles_ofentry and no orphan sweep.core/pictures.py(or beside it) that takes the owner and the bytes afteras_storedorsanitise_svg, and is the only place a picture is written. The upload form, Find logo, the Gravatar fetch, company merging and the archive importer all go through it, which also puts the importer's picture through the same checks (#466).Cache-Control: private, max-age=86400,nosniffand theFILE_POLICYheader, asserve_private_textalready does for text (core/files.py:125-153).{% company_logo %}andAvatarViewkeep their addresses and their?v=stamp. The company list must not load the bytes: reading a company never touches the side table.prune_mediathen lists the old files as orphans for the operator to remove.avatarandgravatar_imagefromdeletion.files_of,avatars/<pk>frommedia_directories_of, and the two entries fromprune_media._referenced.Company.logoleavesmerging.py's file handling: a merge moves a row or deletes one.avatar_fileandlogo_fileas members of the zip so older archives still import and the format number does not move; only the name insidemedia/stops being a storage path.If the answer is files: do not build the table. Finish #355 (a
post_deletereceiver onCompanyand one onProfile, run intransaction.on_commit, andapps.get_models()walked for every file field so the next one is covered), #525 (write, save, then remove the old name on commit), and #466 (throughprocess). This issue then closes with a note in the wiki.Decisions
X-Accel-RedirectandX-Sendfileare no longer available for these two pictures, so a web worker answers each one. The image runs three synchronous workers (docker/Dockerfile:360-362), but a picture is at most 1 MiB, the answer is cached for a day, andFileResponsealready occupies a worker when no proxy offer is configured. (2) The database grows by at most 1 MiB per picture. SQLite's own article on this says the database is more efficient than files as blobs get smaller and files win as they get larger, and the default blob limit is 1,000,000,000 bytes (https://www.sqlite.org/fasterthanfs.html, https://www.sqlite.org/limits.html); the exact break-even is not quoted there and is not verified for this workload. (3) A write to the table holds the write lock for the time of one smallINSERT, which is whatlogos.storealready does for the row (jobs/logos.py:233-234). If a measurement on a real instance shows the list pages slower, the files option remains open and #355's receivers are the fix.Profile.picture) is the kind of split that produced the bugs above.ProfilePicture,CompanyLogo): a real foreign key is what makes the cascade a database fact and not an application habit, and a generic key is whatforget_the_source_of_a_renderhas to write out by hand (documents/signals.py:72-89).django-db-file-storage(MIT, latest release 2024-01-30, 0 advisories on OSV) stores files in the database behind Django's storage interface. Its classifiers list Django up to 5.0 and Postulo requires Django 6.1 (pyproject.toml); the repository was last pushed to on 2026-09-02, and whether it runs on Django 6.1 is not verified. Recommended: not adopted. It would give a storage facade, where the point here is an ownership relation, andBinaryFieldis in Django.docs/PLAN.mdsection 6 describe private media as files behind a view; this changes that for two pictures only and leaves documents, renders, kept pages and exports where they are. The comment onCompany.logo(jobs/models.py:291-298) and the docstrings ofaccounts/avatars.pyandjobs/logos.py("keeps it under private media") are updated with it. #663 (the storage boundary for uploaded documents) is unaffected: a storage plugin never sees these pictures.Builds on
#217 (closed), #355, #466, #525, #482, #265 (closed, why the stored size is not capped at 256).
Needed by
#663 states the storage rule for uploaded documents and refers here for the two pictures. #664 concerns kept pages, which are files too; it does not wait for this answer.
Done when
tests/test_account_deletion.py::test_deleting_removes_every_owned_row_and_every_file(extended) gives the person a company with a logo and a Gravatar copy and asserts, afterdelete_account(), that no picture row remains and that no file of theirs remains underMEDIA_ROOT. A new test intests/test_logos.pydeletes a company throughjobs:company_deleteand finds no picture row. A test deletes a user through the admin and finds none either.tests/test_merging.pyleaves the kept company with one logo row and the other's gone.tests/security/test_feeds_and_pictures.pystill passes unchanged for the two serving views (an owner, an administrator for avatars, nobody else, sign-in required), and a test reads a list page of companies and counts queries, to prove no picture bytes are loaded with a company.tests/test_logos.pyandtests/test_avatars.pyover a file, a missing file and an undecodable one;prune_media(tests/test_document_record.py) no longer names the two pictures and lists the old files once the fields are gone.tests/test_avatars.py:344andtests/test_logos.py:449-470pass unchanged; the importer's pictures go throughprocess(shared with #466).FORMAT_VERSION(29 onf09b6010b,core/export.py:80) stays unless the member naming changes; if it does, it goes up by one, to one more than whatever it is when the commit lands.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 (#662).