8 October 2026¶
5 changes:
- The administration API answers to administrators, and the search box to everybody
- A profile picture a user can set
- A profile picture is always stored as JPEG, and WebP is accepted
- A profile picture from Gravatar, on the user's request
- A phone photo is turned upright
The administration API answers to administrators, and the search box to everybody¶
Reported by a consuming project: with OrganizationScope set, GET /admin/search answered
403 to everybody. Looking at why showed something worse. ApiAdmin used the rule that
generated CRUD endpoints use: no grant is no opinion, and no opinion lets the call through.
So in an installation without organisations, any signed-in account could read
/admin/users (the user list, with addresses), /admin/logs and /admin/summary. With an
organisation in scope, nobody could, administrators included.
Fixed¶
users,logsandsummaryare decided like the screens they serve.AdminAccesschecksadmin.users,admin.logsandadmin.dashboardat the administration floor, or by grant underadmin_access = permissions. A permission-store grant for the action itself still opens it.searchneeds only a signed-in user, in an organisation too. A search source with nopermissionshows only to administrators. It used to show to everybody the endpoint let in, which once anyone may ask would have included the scaffoldedUserssource. The MCPsearchtool reads the same registry and gets the same rule.- The scaffolded
app/search.phpsays how to open a source to everybody.
Documentation¶
- Search Guide: Permissions. API Guide: who may call the administration endpoints.
- Upgrade Guide: two rows.
A profile picture a user can set¶
User had $photo (a media usage) and $avatarurl, and /me returned avatarurl. But no
screen set either one, nothing turned photo into a URL, and avatarurl is not a column, so
it was always the defaultAvatarUrl setting. That setting is empty unless set. Asked for by
a consuming project.
Added¶
- A Profile Picture card on Account → Profile, in all three themes. It uploads a JPEG,
PNG or GIF, and Remove takes the picture away.
Account::profilephoto()is a write action, so it needs a POST with the session's token. Pramnos\User\ProfilePhoto.set()stores the upload through Media as a 256-pixel square crop, records the usage inusers.photo, and releases the picture it replaces.remove()takes the picture away, andurl()gives its absolute URL.adoptFromProvider()copies an SSO provider's picture for a user who has none, whenprofile_photo_from_provideris on. The copy goes throughOutboundUrl.release()gives a picture's usage back.User::load()setsavatarurlfrom the picture, so/me, the admin user list and every screen that reads it show the picture.defaultAvatarUrlapplies only when there is no picture.- The OIDC
pictureclaim carries the picture. It used to read anavatarurlcolumn that does not exist, so it was always null. - Erasing an account releases its picture, and the data export includes its URL.
Testing\Schemarecipes formediaandmediause.
Fixed¶
- Uploading an image that Media already has no longer leaves the new upload on disk.
The record points at the existing copy, and the second file was never deleted, so every
repeated upload left one more orphan under
www/uploads.
Documentation¶
- Account Guide: "Profile picture". OAuth Client Guide: copying the provider's picture in the callback.
A profile picture is always stored as JPEG, and WebP is accepted¶
Following the entry above: the picture kept the upload's format, so a PNG stayed a PNG and a GIF became a PNG. WebP was refused, because Media's image engine does not read it.
Changed¶
ProfilePhotoconverts every picture to JPEG before Media sees it:- JPEG, PNG, GIF or WebP are accepted, judged by the file's bytes and not by the name or the type the browser sent;
- transparency is flattened onto white;
- the longer side is at most 1024 pixels;
- the 256-pixel square is cut from that JPEG.
- A provider's picture goes through the same conversion.
adoptFromProvider()takes an optional fetcher in place of the media object it took before. The default fetcher isOutboundUrl. - A file over 10 MB, or one whose header declares more than 40 million pixels, is refused before it is decoded. A few bytes can announce an image that would take gigabytes to decode.
- Uploads are read only from a file PHP received in the request (
is_uploaded_file()), so a forgedtmp_namecannot read another file on the server. - The upload field accepts
image/webpin all three themes.
Documentation¶
- Account Guide: the formats, the limits and the orientation caveat.
A profile picture from Gravatar, on the user's request¶
Gravatar was left out of the profile picture because a lookup sends a hash of the address to a third party. As a button the user presses, it sends only that person's hash, only when they choose to.
Added¶
- A Use my Gravatar button on the Profile Picture card, in all three themes, with a line
that says what it does.
Account::profilephoto()takes it asgravatar. ProfilePhoto::fromGravatar($user).- It asks
https://www.gravatar.com/avatar/<sha256 of the trimmed, lowercased address>withd=404, fetched by the server throughOutboundUrl. - It stores the result as JPEG, like an upload.
- A 404 is reported as "no Gravatar picture", not stored as the placeholder.
profile_photo_gravatar = 0hides the button. It is shown unless set to off.
Documentation¶
- Account Guide: "From Gravatar, when the user asks".
A phone photo is turned upright¶
A phone stores the sensor's pixels and records the turn in an EXIF tag. Re-encoding to JPEG dropped the tag, so a portrait photo could show sideways.
Fixed¶
ProfilePhotoreads the orientation of a JPEG and turns the image upright before storing it, when PHP has theexifextension. Without the extension the picture is stored as the camera wrote it, as before. The turn itself isProfilePhoto::orient($image, $orientation)and covers the eight cases of the specification.
Documentation¶
- Account Guide: the orientation sentence.