Pixelfed fork (hever.de and scaffold.social instance) — based on upstream v0.12.7
  • PHP 68%
  • Vue 22.5%
  • Blade 9.4%
  • HTML 0.1%
Find a file
vinz 0b8a4ab29b
Some checks are pending
Build Tagged Release Image / prepare (push) Waiting to run
Build Tagged Release Image / build (linux/amd64, ubuntu-26.04) (push) Blocked by required conditions
Build Tagged Release Image / build (linux/arm64, ubuntu-26.04-arm) (push) Blocked by required conditions
Build Tagged Release Image / merge (push) Blocked by required conditions
Merge feature/upstream-0.14.2 (v0.14.2, upstream c002e6d46) into hever-next
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:13:14 +02:00
.ddev Update DDEV PHP, MariaDB and Redis versions 2025-09-19 00:34:55 +01:00
.github Update php-laravel-tests.yml 2026-09-21 03:24:44 -06:00
.vscode many small fixes and improvements 2024-02-10 20:03:04 +00:00
app Merge upstream v0.14.2 (c002e6d46) into feature/upstream-0.14.2 2026-09-21 19:11:51 +02:00
bootstrap Merge upstream v0.12.12 (e4de80944) into feature/upstream-0.12.12 2026-09-17 10:38:15 +02:00
config Bump version 2026-09-21 08:03:24 -06:00
database Merge upstream v0.14.2 (c002e6d46) into feature/upstream-0.14.2 2026-09-21 19:11:51 +02:00
lang Merge upstream v0.14.0 (478332a13) into feature/upstream-0.14.0 2026-09-19 12:16:12 +02:00
notes Update DeduplicationChanges.md with service reference 2026-08-29 12:39:47 +09:30
public Update compiled assets 2026-09-20 05:30:53 -06:00
resources Merge upstream v0.14.2 (c002e6d46) into feature/upstream-0.14.2 2026-09-21 19:11:51 +02:00
routes Merge upstream v0.14.2 (c002e6d46) into feature/upstream-0.14.2 2026-09-21 19:11:51 +02:00
storage Add Autospam Advanced Detection 2023-05-17 04:00:03 -06:00
tests Fix AccountDelete federation 2026-09-21 07:43:03 -06:00
.dockerignore Update .dockerignore 2026-09-11 14:52:16 +09:30
.editorconfig fix spacing 2024-02-22 14:56:33 +00:00
.env.captcha Set CAPTCHA_CAP_ENDPOINT to example.com 2026-09-15 01:20:45 +09:30
.env.docker.example Modify SESSION_DOMAIN in .env.docker.example 2026-09-19 16:27:58 +09:30
.env.example Merge upstream v0.14.0 (478332a13) into feature/upstream-0.14.0 2026-09-19 12:16:12 +02:00
.env.testing Modify SESSION_DOMAIN and add explanatory comment 2026-09-19 16:28:18 +09:30
.gitattributes Update .gitattributes to collapse diffs on generated files 2024-02-16 17:56:13 +01:00
.gitignore ExplicitReturnNullRector 2026-09-18 13:19:48 +09:30
.markdownlint.json migration docs 2024-01-15 23:50:16 +00:00
.node-version chore: adds node version 2022-10-23 15:10:18 +02:00
.npmrc Configure npm to allow legacy peer dependencies 2026-09-16 11:33:59 +09:30
.shellcheckrc tweaking configs 2024-01-15 20:42:11 +00:00
artisan chore: uplift framework skeleton toward Laravel 12 defaults 2026-08-30 22:59:46 +09:30
CHANGELOG.md Bump version 2026-09-21 08:03:24 -06:00
CODE_OF_CONDUCT.md Merge pull request #1793 from FiXato/patch-2 2019-10-18 13:01:18 -06:00
CODEOWNERS add CODEOWNERS 2024-02-11 02:00:09 +00:00
composer.json Add ide helper 2026-09-20 01:05:47 -06:00
composer.lock Merge pull request #7405 from pixelfed/dependabot/composer/staging/larastan/larastan-3.12.1 2026-09-21 04:22:25 -06:00
CONTRIBUTING.md Update CONTRIBUTING.md 2025-02-16 18:03:47 -08:00
crowdin.yml chore: rename Pirate English locale to BCP-47 en-x-pirate 2026-09-15 14:26:44 +09:30
docker-compose.test.yml ci: refactor GitHub Actions with Redis service and best practices 2026-08-27 16:19:44 +09:30
docker-compose.yml Update docker-compose.yml 2026-09-14 22:00:25 +09:30
DOCKER_COMPOSE_SETUP.md Fix duplicate command in Docker Compose setup 2026-08-27 19:39:50 +09:30
Dockerfile Allow RUNTIME_UID and RUNTIME_GID build customize www-data 2026-09-20 20:53:14 -07:00
funding.json Update funding.json 2024-12-02 16:19:48 -07:00
LICENSE Replace to perfect license 2018-06-05 13:33:38 +09:00
package-lock.json Fix typo in blurhash lang key 2026-09-17 03:18:40 -06:00
package.json chore(deps)(deps): bump blurhash from 1.1.5 to 2.0.5 2026-09-02 08:16:12 +00:00
phpstan.neon larastan level 2 2026-09-18 18:14:25 +09:30
phpunit.xml Shift ENV variables 2026-09-03 14:07:29 +09:30
pint.json Create pint.json 2026-08-25 15:24:21 +09:30
psalm-baseline.xml chore: add Psalm static analysis (plugin-laravel, baseline, CI) 2026-08-29 21:09:26 +09:30
psalm.xml chore: target PHP 8.4 in psalm config 2026-08-29 23:58:40 +09:30
README.md Update README 2026-09-19 04:41:06 -06:00
rector.php RemoveAlwaysElseRector 2026-09-18 12:51:48 +09:30
SECURITY.md Create SECURITY.md 2021-10-13 01:07:01 -04:00
server.php Apply fixes from StyleCI 2018-08-28 03:07:36 +00:00
webpack.mix.js translate custom filter 2025-04-18 22:09:52 -03:00

Pixelfed logo

Latest Stable Version License Total Pixelfed users from FediDB

This Fork (git.hever.de/vinz/pixelfed-scatto)

This is Vinz's personal fork, running pix.hever.de and scatto.social. It tracks upstream Pixelfed, with the deliberate divergences documented below: one substantial added capability, plus targeted fixes to upstream bugs that broke third-party API clients.

Why use this fork?

TL;DR — run this if you want push notifications on a third-party client. Upstream Pixelfed's push system only serves Pixelfed's own app: it speaks Expo push tokens, which are only mintable against Pixelfed's Expo project, and its endpoints are gated behind a header only that app sends. This fork adds standards-based Web Push (RFC 8291) — the same thing Mastodon uses — so any Web-Push-capable client can subscribe. It was built for Scatto, a native iOS Pixelfed client, but nothing in it is Scatto-specific. It also pushes two events upstream pushes on no transport: replies and follow requests.

The second reason is the API. A handful of upstream bugs make Pixelfed's JSON API hard to build against — 500s where a client expects 401, HTML where it expects JSON, PUT /api/v1/statuses/{id} rejecting every Bearer token, 2FA accounts unable to complete /oauth/authorize — and each of those is fixed here. Alongside them are three server-side bugs that cost real data: videos silently never reaching cloud storage (upstream #2652, open since 2021), storage quotas rejecting uploads against a counter that only ever grew, and account deletion leaving orphaned statuses behind.

Everything is against v0.12.7 + upstream dev, and is written to stay mergeable: new files where possible, single-line hooks where not.

Push notifications

  • Any client can register for push, not just the official app. POST/DELETE /api/v1/push/subscription — a public, Mastodon-API-compatible endpoint with no X-PIXELFED-APP gate and no Expo token, backed by the laravel-notification-channels/webpush dependency already sitting unused in the tree. WebPushNotifyPipeline is dispatched alongside the existing Expo path, not inside it.
  • Registering an endpoint can't be turned into an SSRF probe. The endpoint is a URL the server will later request from inside the network, so WebPushEndpointGuard requires https and refuses private or reserved addresses — enforced twice, at registration and again at send time, where the connection is pinned to the addresses just validated so a hostile nameserver can't rebind between the two. Capped at 10 subscriptions per account, 10s send timeout, 410 Gone deletes rather than retries.
  • Replies and follow requests notify your device. Neither pushes anywhere in upstream Pixelfed — a reply reaches the in-app list only, and a follow request writes a follow_requests row and emits nothing at all. Added via a CommentPipeline hook and a FollowRequestObserver (observing the model rather than patching the two upstream files that create those rows). Accepting a request no longer double-notifies you about the follower you just approved.
  • Per-type notification preferences are honoured, the same switches the Expo path reads; types with no column of their own alias onto the nearest one that has it rather than diverging the schema. Payloads carry deep-link ids as strings — snowflake ids exceed what a JSON double holds exactly, so a client parsing them as numbers would open the wrong post. Registering a subscription also flips notify_enabled, which nothing but the official app's settings screen can otherwise reach.

API fixes that unblock third-party clients

  • An expired token reads as an expired token. Unauthenticated JSON API requests returned 500 {"error":"Unauthenticated."} — right message, wrong status — because Handler.php short-circuited parent::render() and so never ran the step that maps exceptions onto statuses. Clients treated 5xx as "server broken, back off" instead of "re-authenticate". Now 401/403/404, and 5xx no longer echoes internal exception text back to callers.
  • api/* answers JSON even when the client sends no Accept header. Laravel renders exceptions from Accept, not from the route, so a validation failure became a 302 with an HTML body and a mobile app could only report Unexpected character '<' at line 1. ForceJsonOnApi sets the header on the request, registered ahead of ValidatePostSize so oversized uploads — the likeliest case — are covered too. Scoped to api/*, leaving ActivityPub content negotiation untouched.
  • Editing a post over the API works. StatusEditController applied the web auth guard, which no Bearer/OAuth client can satisfy, so PUT /api/v1/statuses/{id} was a 403 for every third-party app while create and delete worked. Switched to auth:api, matching the route group.
  • 2FA accounts can sign in to a third-party app. The twofactor middleware issued a bare redirect('/i/auth/checkpoint'), dropping the /oauth/authorize destination that had already been consumed one step earlier, so 2FA users landed on /i/web with no consent screen. Fixed with redirect()->guest() in the middleware and redirect()->intended() on both the TOTP and backup-code paths.

Server-side bugs that cost data

  • Videos actually reach cloud storage. Blurhash::generate() allocates a PHP array per pixel — 224 MB for a 720x1280 frame, measured. Video thumbnails are saved at source resolution with no cap, so the job died on a PHP fatal, which isn't an \Exception: nothing was caught, nothing reached failed_jobs, and the MediaStoragePipeline::dispatch() on the next line never ran. The video sat on local disk forever and no error appeared anywhere. Downscaling to 128px before sampling takes peak memory to 6 MB with no visible change to the 4x4-component output; the blurhash is also wrapped so it can never block replication again.
  • Storage quotas measure what the account currently holds. users.storage_used was write-only-upward — six endpoints added, nothing subtracted, and only the web UI ever recalculated. An API-only client drifted without bound: one scatto.social account read 236,098 KB against 6,826 KB of real media and had its uploads rejected with Account size limit reached. Recalculating in MediaDeletePipeline, the choke point every deletion path already goes through, keeps the counter honest.
  • Account deletion stops leaving statuses behind. StatusDelete::fanoutDelete() re-read $status->profile after its own guard had fetched it withTrashed(); since DeleteAccountPipeline soft-deletes the profile while the per-status jobs it queued are still running, that relation is normally already null — so the common path threw getAudienceInbox() on null, permanently failed, and left the statuses in the database with status_count decremented once per retry.
  • ENFORCE_EMAIL_VERIFICATION actually enforces. The middleware's body was commented out upstream and RegisterController never mailed anything, so an instance could set the flag and still hand a working account to anyone who typed an address they don't own — which matters the moment OPEN_REGISTRATION is on. Restored, with the mail sent at signup and a JSON 403 for api/* instead of an HTML redirect (likely why upstream disabled it rather than fixing it). Deliberately not on the hever branch, where enabling it would lock out existing unverified accounts.
  • Minor: the footer's legal-notice link is labelled Impressum, the word German users actually look for, with a de translation key that upstream never had.

Each of these is documented in full below — symptom, cause, fix, and what it means for merging upstream.

Added: generic Web Push notification support

Why. Pixelfed's built-in push notification system is not something a third-party client can use. It's built entirely around Expo push tokens — Pixelfed's official mobile app is built with Expo (React Native), and a push token is only mintable against Pixelfed's own Expo project, whose Firebase/APNs credentials are what actually deliver the push. A separately-built native app, with its own bundle ID and its own Apple/Google push credentials, cannot mint a token that routes anywhere useful through that system. On top of that, the relevant API routes (/api/v1.1/push/*getPushState, comparePush, updatePush, disablePush) are gated behind an X-PIXELFED-APP header check that only the official app sends:

abort_unless($request->hasHeader('X-PIXELFED-APP'), 404, 'Not found');

This isn't a bug or a missing config value — it's a deliberate closed door. Third-party apps get a 404, full stop.

This fork was created to support Scatto, a personal native iOS Pixelfed client, which needed real push notifications. Rather than trying to impersonate the official app, this fork adds the alternative that Mastodon and most of the rest of the fediverse already use: standards-based generic Web Push (RFC 8291). Any Web-Push-capable client can subscribe — not just Scatto.

How it works. laravel-notification-channels/webpush (which wraps minishlink/web-push, a real RFC 8291/8188 + VAPID implementation) was already a Composer dependency in this codebase, with its config already published, its migration already applied, and the HasPushSubscriptions trait already on App\User — but nothing else was ever wired up: no public subscribe endpoint, nothing actually sending through it. This fork finishes that wiring:

  • The push_subscriptions table already exists upstream, created by the package's own 2023_12_04_041631_create_push_subscriptions_table.php, so no table had to be created. (An earlier revision of this branch added a second, duplicate migration for the same table; it was dropped before merge. On a deployment with AUTORUN_LARAVEL_MIGRATION=true it would have aborted startup with a "table already exists" error.) One migration is added: 2026_07_30_120000_add_access_token_id_to_push_subscriptions_table.php, a nullable column — see the unsubscribe note below.

  • POST/DELETE /api/v1/push/subscription (PushSubscriptionController) — a genuinely public, Mastodon-API-compatible subscription endpoint under the real v1 route group. No app-specific header gate, no Expo token validation. Any client speaking the standard Web Push subscription protocol can register here.

  • WebPushNotifyPipeline job — dispatched from the call sites where Pixelfed already dispatches Expo push (LikePipeline, FollowPipeline, MentionPipeline, and the ActivityPub inbox's DM handler), plus CommentPipeline, which had no push of any kind before. It runs as an independent code path alongside the existing Expo dispatch — deliberately not nested inside NotificationAppGatewayService::enabled(), since that gate is specific to Pixelfed's own hosted Expo relay (which this instance may not even have configured) and has no bearing on a self-contained Web Push subscription. Each call site is a single added line calling WebPushNotifyPipeline::maybeDispatch(); every gate lives in that one method so the sites cannot drift apart, and no upstream line is restructured.

  • FollowRequestObserver — follow requests are the one notification with no call site to hook, because Pixelfed emits nothing for them at all: a request writes a follow_requests row and stops, with no Notification record and no Expo pipeline, which is why it appears in the web UI's Follow Requests list and nowhere else. The event is supplied here by observing the model rather than by editing the two places that create those rows (Inbox::handleFollowActivity for a federated request, ApiV1Controller::accountFollowById for a local one) — both are files upstream changes often, and created fires once per genuine insert, so one hook in our own tree covers both paths. Registration is one line in AppServiceProvider, alongside the eleven observe() calls already there.

    Not every follow_requests row is a request to follow us: accountFollowById writes one for our own pending follow of a remote account too. The observer pushes only when the target profile is local (domain === null), which separates the two — and a local target is always a private one, since that is the only case in which either site creates a request rather than a Follower.

    Accepting a request does not notify you a second time. Upstream's accept handlers create the Follower row and dispatch FollowPipeline, which cannot tell a direct follow from an accepted one and so pushes "X started following you" to the person who just pressed accept — about a follower they were already told about. Rather than patch the two upstream files that accept (accountFollowRequestAccept and AccountController, and any third added later), maybeDispatch marks the (target, actor) pair when it sends the request push and drops the follow push that consumes the mark. Because the mark predates the accept, the two cannot race whatever order the handler dispatches and deletes in. FollowRequestObserver::deleted() then shortens the mark on an accept and drops it on a reject or cancellation, so a person once refused still generates a real notification if they are later allowed to follow. The Notification row is untouched — only the push is suppressed, so the in-app list stays complete.

It honours the user's notification settings. maybeDispatch() checks notify_enabled and notify_{type} — the same switches the Expo path honours through PushNotificationService::check(). Being independent of the Expo gateway is the point; being independent of the user's own preferences would be a bug. An earlier revision checked only whether a subscription existed, so someone who had turned like notifications off still got them over Web Push.

Types without a preference column alias to one that has it. The notify_* columns come from Pixelfed's Expo push work and cover only the four types it sends (notify_like, notify_follow, notify_mention, notify_comment, under the notify_enabled master switch). A type with no matching column is not merely unconfigurable — Eloquent returns null for the missing attribute, so maybeDispatch drops the push at that guard with nothing logged. WebPushNotifyPipeline::PREFERENCE_ALIASES maps such a type onto the nearest column that exists; follow_request reads notify_follow, a follow request being a prospective follower.

Aliasing rather than adding a column is deliberate. A new column on users diverges the schema from upstream permanently, and buys nothing usable here: every endpoint that writes these preferences (ApiV1Dot1Controller::updatePush and friends) is gated on X-PIXELFED-APP plus the Expo gateway, so no third-party client can set them anyway. Worth knowing that upstream has the same bug in its own code — PushNotificationService::NOTIFY_TYPES lists share, and no notify_share column was ever migrated.

Registering an endpoint is a request the server will later make. The endpoint is supplied by the client and POSTed to from inside the network, so App\Services\WebPushEndpointGuard requires https, rejects embedded credentials, resolves the host and refuses private or reserved addresses. Without that, http://127.0.0.1:…, http://169.254.169.254/… and file:///etc/passwd are all accepted by a plain string rule, giving any account with the push scope a blind SSRF primitive. On an instance with open registration that means anyone at all, which is why this is enforced twice:

  • At registration, by App\Rules\WebPushEndpoint, so a bad endpoint is rejected with a useful validation error rather than failing silently later.
  • At send time, by WebPushNotifyPipeline, which re-inspects every endpoint and pins the connection to the addresses it just validated via CURLOPT_RESOLVE. Validating only at registration is not enough on its own: the host is resolved again when the request is made, so a hostile nameserver can answer with a public address then and a private one now (DNS rebinding). With the connection pinned, curl performs no lookup of its own. An endpoint that cannot be pinned is skipped rather than sent unpinned — failing closed, since an unpinned request is the exact case this exists to prevent. Redirects are disabled too; following one would hand back the DNS control pinning just took away.

A host resolving to a mix of public and private addresses is rejected outright rather than filtered down to the public ones — there is no legitimate reason for a push endpoint to do that.

Still worth knowing on an open-registration instance: nothing stops many accounts from registering endpoints pointing at the same third-party host, which makes the instance a modest traffic amplifier toward it. That is inherent to Web Push (Mastodon included) rather than specific to this fork, and is bounded here by the 10-subscription-per-account cap, but it is an argument for egress rate limiting if the instance grows.

Limits. A user may hold at most 10 subscriptions (oldest pruned first) — every one is another outbound request per notification. Sends use a 10s timeout rather than the library's 30s default, so a hung endpoint can't sit on a pushnotify worker. Transient failures retry up to 3 times with backoff; a 410 Gone/expired subscription is deleted instead, since retrying it is pointless.

Unsubscribing is per device. DELETE removes the subscription belonging to the calling access token, which is why access_token_id exists. Deleting all of the account's subscriptions — the obvious one-liner, and what an earlier revision did — means disabling push on a phone also silently kills it on a tablet. Rows predating the column have none recorded; a token that finds no subscription of its own clears those, so unsubscribe keeps working across the upgrade and they disappear once clients re-register.

  • A Web Push endpoint has to be a real HTTPS server that can receive the encrypted payload — browsers get one for free from their vendor (Chrome→FCM, Firefox→Mozilla's push service, etc). A native iOS app has no equivalent, since Apple's actual push transport (APNs) isn't Web-Push-compatible. Scatto solves that client-side with its own small relay, scatto-push-relay, which decrypts the RFC 8291 payload and forwards it to APNs — but that's purely a Scatto/iOS detail. This fork's server-side code has no knowledge of relays or APNs at all; it just speaks standard Web Push to whatever endpoint a client subscribes with.

Scope. This started out matching Pixelfed's existing push coverage exactly — like, follow, mention/DM — and has since gone past it. Comment/reply and follow requests have no push in upstream Pixelfed on either transport; both push here.

Event Notification row upstream Expo push upstream Web Push here
like yes yes yes
follow yes yes yes
mention yes yes yes
DM, federated yes yes yes (sent as mention)
comment / reply yes no yes
follow request no no yes
boost / share yes no no
DM, local → local yes no no
photo tag (tagged) yes no no
story:react, story:comment, group:* yes no no

The last four rows are gaps, not decisions. Two are worth knowing about:

  • Boosts push nowhere. SharePipeline writes a Notification and dispatches only FeedInsertPipeline; there is no Expo pipeline for it and no maybeDispatch. share appears in PushNotificationService::NOTIFY_TYPES and is exposed as a user preference, which makes it look wired when it is not.
  • Local DMs push nowhere. DirectMessageController writes a dm notification and stops; only the federated path in Inbox pushes. On a small instance that is the more common case of the two.

Every gap except follow requests already writes a Notification row, so the tidy way to close them is one more observer — on Notification::created, allowlisting verbs and deriving status_id from item_id/item_type. That would also let the five scattered maybeDispatch calls be deleted, taking Inbox.php back to pristine and reducing this fork's whole push feature to new files plus two observe() lines. Not done here; noted because it is the direction, and because the mod/admin verbs sharing that table (cw, unlist, spammer, …) make the allowlist the part to get right.

Payload. The encrypted body is JSON, and carries deep-link ids alongside the display text so a client can open the post or profile a notification refers to:

{
  "notification_type": "like",
  "title": "New Like",
  "body": "vinz liked your post",
  "account_id": "961576165188272129",
  "status_id": "988149955134320972"
}

The two ids are strings, not numbers. Pixelfed's snowflake ids exceed what a JSON double represents exactly, so a client parsing them as numbers would silently deep-link to the wrong post. status_id is null where no status exists — a follow, a follow request, and a DM, which has no public status object (those deep-link to the profile instead).

notification_type carries Pixelfed's own verb, including follow_request, which has no Mastodon-API equivalent in this codebase beyond appearing in NotificationService::MASTODON_TYPES as a filter value. A client that does not recognise a type should fall back to displaying title/body, both of which are always present.

Badge counts are deliberately not in here. They are tracked by the client's own push relay, which counts what it forwards and puts the total in the APNs aps.badge field; nothing about unread state is Pixelfed's concern. See scatto-push-relay.

Mastodon API compatibility. The subscription response mirrors Mastodon's entity — id, endpoint, standard, alerts, server_key. One divergence: Mastodon stores alerts per subscription and lets a client set them via data[alerts][…] on this endpoint. There is no per-subscription column for that here, so inbound data is ignored and the reported alerts are read from the account-wide notification preferences instead. Per-type opt-out works and is enforced on send — it is just set in Pixelfed's own notification settings rather than through this API.

Deploying this change. VAPID keys, plus php artisan migrate for the access_token_id column (autorun handles it if AUTORUN_LARAVEL_MIGRATION=true).

The package ships a php artisan webpush:vapid command, but it works by rewriting a .env file, so it is unusable on a container deployment that injects configuration through docker-compose env_file (there is no .env inside the container, and the command also refuses to run unprompted in production). Generate the keys through the underlying library instead and write them to whichever .env compose actually reads:

docker compose exec -T app php artisan tinker --execute='$k = Minishlink\WebPush\VAPID::createVapidKeys(); echo "VAPID_PUBLIC_KEY=".$k["publicKey"]."\nVAPID_PRIVATE_KEY=".$k["privateKey"]."\n";'

Add the two printed lines plus VAPID_SUBJECT=https://your.instance to the env file, then restart whatever consumes the pushnotify queue. Confirm the keys are live with:

docker compose exec -T app php artisan tinker --execute='echo config("webpush.vapid.public_key");'

Upstream tracking. Purely additive — new files, plus a handful of small, independent hook additions alongside existing code, nothing removed or restructured — so merging future upstream Pixelfed changes should stay conflict-free. Borne out in practice: the 2026-07-30 merge of upstream/dev (148 commits) conflicted in two files, neither of them push-related.

The rule this fork follows for new push work is to keep it in files upstream does not have, and to prefer a model observer over a call site when both would work. AppServiceProvider's list of observe() calls is a far cheaper place to carry a line than the interior of Inbox::handleFollowActivity or accountFollowById, which upstream edits regularly. Each hunk in a shared file is one to re-resolve at every merge, forever.

Worth reconsidering the whole arrangement if upstream Pixelfed ever ships real Web Push support itself.

Fixed: JSON API answered 500 where it should answer 401 / 403 / 404

Symptom. Every unauthenticated request to the JSON API returned 500 with a body of {"error":"Unauthenticated."} — the right message under the wrong status code.

Cause. app/Exceptions/Handler.php short-circuits parent::render() for any request where wantsJson() is true, and decided the status with:

method_exists($exception, 'getStatusCode') ? $exception->getStatusCode() : 500

Skipping the parent means Laravel's prepareException() / unauthenticated() never run, and those are what map framework exceptions onto HTTP statuses. Without that step the exception never gains a getStatusCode(), so it lands on the 500 fallback:

Exception Should be Was
AuthenticationException 401 500
AuthorizationException 403 500
ModelNotFoundException 404 500
NotFoundHttpException, ThrottleRequestsException 404 / 429 correct

Why it matters. Mastodon-compatible clients treat 401 as "token expired, re-authenticate" and 5xx as "server is broken, back off and retry". An expired or revoked token left clients retrying against an apparently-broken server instead of prompting for login. Secondarily, the generic arm passes $exception->getMessage() straight into the response body, so genuine 500s leaked internal exception text to any API caller.

Fix. Run prepareException() before deciding the status, with an explicit arm for AuthenticationException (Laravel routes that one through unauthenticated() rather than prepareException(), so it needs handling of its own).

The same arm also stopped returning $exception->getMessage() for 5xx responses. A 4xx message describes what the caller did wrong and is useful to them; a 5xx message is whatever the failure happened to say — a database error, a filesystem path, a library internal — and handing that to any API caller leaks the server's internals. 5xx now answers Server Error unless APP_DEBUG is on.

Note that upstream had already patched this exact method_exists pattern one branch up, in the ValidationException arm of the same method — but left the generic arm untouched.

Fixed: the 2FA checkpoint discarded the post-login destination

Symptom. Signing in to a third-party app via /oauth/authorize silently failed for accounts with 2FA enabled. After login the user landed on /i/web — no consent screen, no redirect back to the client's callback URL. Accounts without 2FA were unaffected, which made it look intermittent or client-specific.

Cause. Not, as first assumed, a failure to preserve the intended URL. That mechanism works: redirect()->guest() stores the full /oauth/authorize?… URL with its query string, and LoginController (via laravel/ui's AuthenticatesUsers) ends in redirect()->intended(). The destination was lost one step later:

  1. POST /loginredirect()->intended() pulls and consumes url.intended, redirecting to /oauth/authorize?…
  2. GET /oauth/authorize → the twofactor middleware sees no 2fa.session.active and issues a bare redirect('/i/auth/checkpoint')the destination is dropped here, and url.intended was already consumed in step 1
  3. 2FA accepted → AccountController::twoFactorVerify returned an unconditional redirect('/')

Fix. redirect()->guest('/i/auth/checkpoint') in the middleware, which re-stores the current URL as the intended destination, and redirect()->intended('/') on both the TOTP and backup-code success paths.

A native client can paper over this with a non-ephemeral web auth session, since a reused browser session already carries 2fa.session.active and never reaches the checkpoint — but that only works for someone who is already signed in to the instance in that browser, so fresh installs still fail. The fix is server-side and lets clients use ephemeral sessions.

Upstream tracking. Unlike the Web Push work, both fixes edit existing upstream files (app/Exceptions/Handler.php, app/Http/Middleware/TwoFactorAuth.php, app/Http/Controllers/AccountController.php), so they are the likely conflict points when merging upstream. Each is a few lines; re-applying by hand is straightforward. Drop either one if upstream fixes it first.

Introduction

Photo sharing the way it should be. Pixelfed lets your casual shots and creative photography find their audience naturally, without algorithmic barriers. Join millions of people sharing across the fediverse.

Pixelfed web user interface in light mode

Database Support (Please report any regressions)

  • MySQL 9+ is officially supported (Strict mode is not default).
  • MariaDB 11+ is officially supported (Strict mode is default).
  • PostgreSQL 14+ is best effort as of writing.

Notice: We need to improve the ecosystem to test/validate the codebase to be database agnostic, but this will require considerable engineering effort. Thank you for your understanding.

Official Documentation

Documentation for Pixelfed can be found on the Pixelfed documentation website.

License

Pixelfed is open-sourced software licensed under the AGPL license.