- PHP 68%
- Vue 22.5%
- Blade 9.4%
- HTML 0.1%
|
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
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .ddev | ||
| .github | ||
| .vscode | ||
| app | ||
| bootstrap | ||
| config | ||
| database | ||
| lang | ||
| notes | ||
| public | ||
| resources | ||
| routes | ||
| storage | ||
| tests | ||
| .dockerignore | ||
| .editorconfig | ||
| .env.captcha | ||
| .env.docker.example | ||
| .env.example | ||
| .env.testing | ||
| .gitattributes | ||
| .gitignore | ||
| .markdownlint.json | ||
| .node-version | ||
| .npmrc | ||
| .shellcheckrc | ||
| artisan | ||
| CHANGELOG.md | ||
| CODE_OF_CONDUCT.md | ||
| CODEOWNERS | ||
| composer.json | ||
| composer.lock | ||
| CONTRIBUTING.md | ||
| crowdin.yml | ||
| docker-compose.test.yml | ||
| docker-compose.yml | ||
| DOCKER_COMPOSE_SETUP.md | ||
| Dockerfile | ||
| funding.json | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| phpstan.neon | ||
| phpunit.xml | ||
| pint.json | ||
| psalm-baseline.xml | ||
| psalm.xml | ||
| README.md | ||
| rector.php | ||
| SECURITY.md | ||
| server.php | ||
| webpack.mix.js | ||
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 noX-PIXELFED-APPgate and no Expo token, backed by thelaravel-notification-channels/webpushdependency already sitting unused in the tree.WebPushNotifyPipelineis 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
WebPushEndpointGuardrequires 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 Gonedeletes 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_requestsrow and emits nothing at all. Added via aCommentPipelinehook and aFollowRequestObserver(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 — becauseHandler.phpshort-circuitedparent::render()and so never ran the step that maps exceptions onto statuses. Clients treated5xxas "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 noAcceptheader. Laravel renders exceptions fromAccept, not from the route, so a validation failure became a 302 with an HTML body and a mobile app could only reportUnexpected character '<' at line 1.ForceJsonOnApisets the header on the request, registered ahead ofValidatePostSizeso oversized uploads — the likeliest case — are covered too. Scoped toapi/*, leaving ActivityPub content negotiation untouched.- Editing a post over the API works.
StatusEditControllerapplied the webauthguard, which no Bearer/OAuth client can satisfy, soPUT /api/v1/statuses/{id}was a 403 for every third-party app while create and delete worked. Switched toauth:api, matching the route group. - 2FA accounts can sign in to a third-party app. The
twofactormiddleware issued a bareredirect('/i/auth/checkpoint'), dropping the/oauth/authorizedestination that had already been consumed one step earlier, so 2FA users landed on/i/webwith no consent screen. Fixed withredirect()->guest()in the middleware andredirect()->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 reachedfailed_jobs, and theMediaStoragePipeline::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_usedwas 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 withAccount size limit reached.Recalculating inMediaDeletePipeline, the choke point every deletion path already goes through, keeps the counter honest. - Account deletion stops leaving statuses behind.
StatusDelete::fanoutDelete()re-read$status->profileafter its own guard had fetched itwithTrashed(); sinceDeleteAccountPipelinesoft-deletes the profile while the per-status jobs it queued are still running, that relation is normally already null — so the common path threwgetAudienceInbox() on null, permanently failed, and left the statuses in the database withstatus_countdecremented once per retry. ENFORCE_EMAIL_VERIFICATIONactually enforces. The middleware's body was commented out upstream andRegisterControllernever 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 momentOPEN_REGISTRATIONis on. Restored, with the mail sent at signup and a JSON 403 forapi/*instead of an HTML redirect (likely why upstream disabled it rather than fixing it). Deliberately not on theheverbranch, 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
detranslation 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_subscriptionstable already exists upstream, created by the package's own2023_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 withAUTORUN_LARAVEL_MIGRATION=trueit 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 realv1route group. No app-specific header gate, no Expo token validation. Any client speaking the standard Web Push subscription protocol can register here. -
WebPushNotifyPipelinejob — dispatched from the call sites where Pixelfed already dispatches Expo push (LikePipeline,FollowPipeline,MentionPipeline, and the ActivityPub inbox's DM handler), plusCommentPipeline, which had no push of any kind before. It runs as an independent code path alongside the existing Expo dispatch — deliberately not nested insideNotificationAppGatewayService::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 callingWebPushNotifyPipeline::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 afollow_requestsrow and stops, with noNotificationrecord 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::handleFollowActivityfor a federated request,ApiV1Controller::accountFollowByIdfor a local one) — both are files upstream changes often, andcreatedfires once per genuine insert, so one hook in our own tree covers both paths. Registration is one line inAppServiceProvider, alongside the elevenobserve()calls already there.Not every
follow_requestsrow is a request to follow us:accountFollowByIdwrites 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 aFollower.Accepting a request does not notify you a second time. Upstream's accept handlers create the
Followerrow and dispatchFollowPipeline, 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 (accountFollowRequestAcceptandAccountController, and any third added later),maybeDispatchmarks the (target, actor) pair when it sends the request push and drops thefollowpush 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. TheNotificationrow 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 viaCURLOPT_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.
SharePipelinewrites aNotificationand dispatches onlyFeedInsertPipeline; there is no Expo pipeline for it and nomaybeDispatch.shareappears inPushNotificationService::NOTIFY_TYPESand is exposed as a user preference, which makes it look wired when it is not. - Local DMs push nowhere.
DirectMessageControllerwrites admnotification and stops; only the federated path inInboxpushes. 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:
POST /login→redirect()->intended()pulls and consumesurl.intended, redirecting to/oauth/authorize?…✓GET /oauth/authorize→ thetwofactormiddleware sees no2fa.session.activeand issues a bareredirect('/i/auth/checkpoint')— the destination is dropped here, andurl.intendedwas already consumed in step 1- 2FA accepted →
AccountController::twoFactorVerifyreturned an unconditionalredirect('/')
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.
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.