- Relocate translation files from resources/lang to lang/ via git mv
- Update PHP references to use the lang_path() helper
- Update crowdin.yml source/translation paths
- Update phpstan.neon translationDirectories
The Cache::remember('profile:following:'.$pid, ...) block that plucks
following_id and appends the caller's own id was copy-pasted across four
call sites, with inconsistent TTLs (1440 minutes vs 1209600 seconds).
Add FollowerService::getFollowingIds($pid), which owns the cache key that
add()/remove() already invalidate, and use it from InternalApiController,
PublicApiController, ApiV1Controller and HashtagUnfollowPipeline. Removes
the now-unused Follower/Cache imports left behind.
Adds a test covering the followed-ids-plus-self result and the
follows-nobody case.
Two query patterns were copy-pasted across several call sites:
- The 'users who blocked me, plus myself' list used to filter profile
search (UserFilter::whereFilterableId($pid)->pluck('user_id')->push($pid))
appeared in ComposeController (x2) and DirectMessageController. Extracted
to UserFilterService::searchExcludedProfileIds(). Note this is the
inverse of blocks() (who I blocked), so it is a distinct method.
- CustomEmoji duplicate detection (groupBy('shortcode')->havingRaw(
'count(*) > 1')) appeared three times in AdminController. Extracted to a
CustomEmoji::duplicateShortcodes() query scope.
Adds tests for both. No behaviour change.
RemoteAuthService::getVerifyCredentials, getFollowing and getToken made
outbound HTTP requests to a user-controlled remote instance during the
Mastodon login flow with no timeout, no retry and no exception handling.
A slow or hostile instance could hang the request or surface an uncaught
exception.
Wrap all three in timeout(20)->retry(3, 750) with try/catch that returns
false on failure, matching the existing pattern in isDomainCompatible().
Callers already treat a falsy return as a failure; add the missing guard
at the one verify_credentials call site that accessed the result array
without checking it first.
Adds RemoteAuthServiceTest covering connection failure, server error and
success paths.
Adds CustomEmojiService::resync() which re-fetches a remote custom emoji's
media from its origin (image_remote_url) via the SSRF-hardened
SecureMediaFetchService and stores it locally under public/{media_path},
reusing the existing headCheck validation and cache busting.
The admin:resyncemoji command takes a comma-separated list of emoji
filenames, looks each up by media_path, and resyncs remote ones. Supports
--missingonly, --dry-run and --force.
Performance:
- TrendingHashtagService: batch-load hashtags with whereIn/keyBy instead
of Hashtag::find() per trending row.
- DirectMessageController: eager-load status.media and read the in-memory
collection instead of firstMedia() issuing a query per DM message.
- GroupsSearchController: batch Profile/Follower/GroupInvitation lookups
with whereIn instead of per-invitee queries.
Lint/tests:
- ComposeController: whitespace formatting (Pint).
- ComposeControllerTest: correct App\User to App\Models\User, fixing the
larastan class.notFound error and import ordering.
Full suite: 547 passed. Pint and PHPStan clean.
Back out all emoji cloud-storage work from staging so it can be reworked and
re-landed separately (the URL resolution flips to cloud on a global config
flag, which created a broken-URL window, and the migration approach needs
revisiting).
Reverts to pre-emoji state:
- CustomEmoji model URL/storage helpers (urlForPath, storageTarget, storeMedia,
storeMediaFromFile, deleteMedia, url) and callers in ImportEmojis,
CustomEmojiService, AdminController
- admin custom-emoji blade views back to local /storage URLs
- Remove admin:EmojiMoveStorageLocalToCloud command
- Remove the deploy migration and its scheduler entry
Media (and the already-reverted story) scheduler entries are untouched.
Custom emoji were always written locally and served via hardcoded /storage
URLs, so they never used S3 even on cloud instances.
- CustomEmoji: centralize URL + storage on the active disk (cloud when
pixelfed.cloud_storage is enabled, else local public/ disk) via
urlForPath/url/storageTarget/storeMedia/storeMediaFromFile/deleteMedia
- Route emoji writes/deletes and URL generation (scan, CustomEmojiService::all)
through those helpers in ImportEmojis, CustomEmojiService::import and
AdminController
- Add admin:EmojiMoveStorageLocalToCloud to migrate existing local emoji to
cloud: copy, verify by size, delete local, bust caches
- Schedule it daily when cloud storage is enabled
The remote avatar/media fetchers wrote temp files to storage/app/remcache/
and only unlinked them on the happy path. Any exception between the write
and the unlink (e.g. a cloud upload failure) leaked the file, and nothing
swept the directory.
- Wrap post-write logic in fetchAvatar() and remoteToCloud() in try/finally
so the temp file is always removed, even on failure
- Add gc:remcache command to delete stale remcache files (default >24h old,
preserves .gitignore, supports --hours and --dry-run)
- Schedule gc:remcache daily to clean up any stragglers
StoryFetch already handled cleanup via try/catch and was left unchanged.
Add a FixPostCounts command mirroring admin:fixProfileCounts (single-id,
--all --scope, --active, --type, --dry-run, --force). It reconciles the
statuses likes_count, reblogs_count, and reply_count columns against
source-of-truth tables.
Add canonical recompute helpers and reconcileStatusCounts() to
StatusService (mirroring AccountStatService), busting the status cache
only when a column actually drifted.
Extract canonical source-of-truth count logic into AccountStatService:
recalculateStatusCount/FollowerCount/FollowingCount and a
reconcileProfileCounts() that fixes only drifted columns and busts caches.
Both the scheduled app:account-post-count-stat-update (status-only, its
correct scope) and fix:profilecounts now use these instead of duplicating
the SQL. Also corrects the status_count definition to match the actual
increment logic in StatusEntityLexer/StatusDelete (media post types only:
photo/video albums), rather than the previous inconsistent all-statuses /
scoped counts that could themselves cause drift.
The scheduled updater keeps its incremental, dirty-set design and remains
status-only; follower/following stay owned by FollowServiceWarmCache.
Add image/gif and image/webp to the accepted custom emoji image types via
a shared CustomEmojiService::ALLOWED_MIME_TYPES constant used by both the
ActivityPub mediaType check and the response-content headCheck, so the
allowlist stays in sync. File extension derives from the mime type.
The remote media path validated URLs only as strings (Helpers::validateUrl
normalizes the host + checks a ban list) and then downloaded them with
Http::head + file_get_contents($url), which resolve DNS themselves and
follow redirects with no private-IP checks and no address pinning. A remote
actor whose icon.url redirected to an internal address (e.g. 172.18.0.1 or
169.254.169.254) made the queue worker fetch internal content and, for
image responses, republish it at a public avatar URL. No account required.
Fixes:
- Add SecureMediaFetchService: validates URL, resolves + rejects non-global
IPs (fail-closed), pins the connection to the validated IP via
CURLOPT_RESOLVE, disables auto-redirects with per-hop re-validation, and
enforces https-only + a byte cap. Mirrors the ActivityPubFetchService
hardening from CVE-2026-71246.
- Route MediaStorageService head()/fetchAvatar()/remoteToCloud() through it,
removing the bare Http::head and file_get_contents($url) sinks.
- validateUrl(): when DNS verification is enabled, reject hosts that resolve
into reserved ranges, closing the metadata.google.internal bypass.
- Harden adjacent same-class sinks: CustomEmojiService (emoji doc + image +
head), FetchCacheService/webfinger, and DiscoverActor.
- Add regression tests (tests/Unit/ActivityPub/SsrfUrlValidationTest.php).
Caching an Eloquent model in a Cache::remember closure could deserialize
into a __PHP_Incomplete_Class on read, throwing 'attempt to access a
property on an incomplete object' and returning a 500. This surfaced on
guest profile pages (ProfileController::buildProfile reading
$user->user->settings) and affected several other latent call sites.
Changes:
- ProfileController: cache a plain settings array instead of the
UserSetting model; fall back to defaults when the settings row is missing
- StoryService::getById: fetch a live model instead of caching it
- InstanceService::getByDomain, CustomEmoji::scan: cache arrays
- Site/MobileController: cache Page data as an array via a shared
ManagesCachedPages trait; update blade views to array access
- Add public-route smoke/regression tests covering the cache-read path
1. Add FractalService with static item() and collection() helpers
replacing 22 call sites that repeated the 4-line Fractal Manager
+ ArraySerializer boilerplate.
2. Add AccountInterstitial::createFromStatus() factory method
consolidating 4 identical 15-line blocks that create interstitials
with status metadata.
3. Add NotificationService::createNotification() to handle the
repeated pattern of creating, caching, and registering a
notification in the recipient's feed.
4. Add NotificationService::firstOrCreateNotification() for
idempotent notifications (share/boost, mention) that should
only notify once per actor+action+item combination.
Add ActivityPubDeliveryService::pool() using Laravel's Http::pool() to
consolidate the duplicated delivery pattern found across 10 jobs.
Updated jobs:
- StatusActivityPubDeliver
- StatusDelete
- StatusLocalUpdateActivityPubDeliverPipeline
- FanoutDeletePipeline
- SharePipeline
- UndoSharePipeline
- StoryFanout
- StoryExpire
- StoryDelete
- ProfileMigrationDeliverMoveActivityPipeline
The shared method accepts a Profile (sender), audience (inbox URLs),
and activity (payload array), handling signing, user-agent, timeout,
and concurrency in one place. No direct Guzzle usage remains in
app/Jobs/.
Move all Eloquent models from the app/ root directory to app/Models/
for consistency with modern Laravel conventions. The project already had
54 models in App\Models; this migrates the remaining 52 legacy models.
Changes:
- Move 52 model files from app/ to app/Models/
- Update namespace declarations in each model
- Update all ~1000 import references across the codebase
- Add Relation::morphMap() in AppServiceProvider for backward
compatibility with existing polymorphic database records
- Add missing HasSnowflakePrimary imports for models that relied
on same-namespace resolution
Replace \Cache::, \Log::, \DB:: calls with their imported facade
equivalents. The backslash-prefix relies on global aliases which
PHPStan cannot resolve, causing class.notFound errors.
The storyPoll() method was never implemented on PollService.
Replace with null to fix phpstan staticMethod.notFound.
Note: Passport::personalAccessClientId() is also flagged but deferred
to a separate PAT refactoring effort.
Convert all 273 short facade alias imports (e.g. 'use Cache;') to their
fully-qualified class names (e.g. 'use Illuminate\Support\Facades\Cache;')
across 193 files.
This resolves 643 PHPStan 'class.notFound' errors caused by the static
analyzer being unable to resolve global aliases, and aligns with modern
Laravel conventions. It also unblocks removing the aliases array from
config/app.php in a future change.
All 107 tests pass.
Replace all deprecated helper function calls:
- str_slug() → Str::slug()
- starts_with() → str_starts_with()
- ends_with() → str_ends_with()
- array_first() → Arr::first()
- array_last() → Arr::last()
- array_flatten() → Arr::flatten()
Remove laravel/helpers package from composer.json as it is no longer
needed and will not be maintained for Laravel 13.
PHP 5.5.9 adds the new static `class` property which provides the fully qualified class name. This is preferred over using strings for class names since the `class` property references are checked by PHP.
- Remove unmaintained jenssegers/agent package (no releases since 2021)
- Add matomo/device-detector v6.5 as actively maintained replacement
- Create App\Services\UserAgentService wrapper for drop-in compatibility
- Update UserDevice model and ApiV1Dot1Controller to use new service