- 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 media storage migration commands read/parsed the .env file directly to
check and flip PF_ENABLE_CLOUD. In containerized deploys there is no .env on
disk (config is injected via env vars), so updateEnvFile() threw
'file_get_contents(.env): Failed to open stream' and the scheduled command
exited 1.
- Check the live setting via config_cache('pixelfed.cloud_storage') like the
rest of the app, instead of parsing .env.
- Make the .env write best-effort in ManagesMediaStorageEnv: skip gracefully
when the file is missing or read-only, and still apply the runtime + DB
config-cache updates (the load-bearing changes on a hot server).
- Apply the same fix to the sibling unstable:MediaMoveStorageCloudToLocal.
- Add a regression test covering the no-.env container scenario.
Diagnostic commands mirroring status:statuses/status:media:
- status:instance {id|domain|url|@user@domain}: instance row, moderation
state (banned/unlisted/auto_cw), sync timestamps, local profile count.
- status:avatar {avatar_id|profile_id} [--check]: avatar row, storage state
(local/cloud existence), owning profile, optional live HEAD on remote_url.
- status:emoji {id|:shortcode:|filename} [--check]: emoji row, origin
(local/remote), local file existence, optional live HEAD on image_remote_url.
Also includes the status:post -> status:statuses rename.
Rename the command signature (media:maintenance -> media:filtercleanup), class
(MediaMaintenance -> MediaFilterCleanup), and file to match. Behavior
unchanged.
Orphaned media never references a live status, so --status only accepts soft
and hard. --profile still accepts live/soft/hard. Removes the now-redundant
live short-circuit and makes valid values per-option.
--status live|soft|hard and --profile live|soft|hard narrow orphaned media by
the lifecycle state of the referenced status/profile row. Filters are applied
at the SQL level (whereExists/whereNotExists on deleted_at) so they compose
correctly with --limit. --status=live short-circuits since orphaned media never
has a live status. Options are validated up front.
- TODO.md: capture follow-ups (centralized status media teardown, DM leak fix,
no-DB-cascade rationale, remote-edit orphaning, media:gc re-check).
- media:maintenance: add --server remote|local|both (default both) to filter
orphaned media by origin.
- Annotate each row with status state (live/soft-deleted/hard-deleted) next to
status_id and profile state (live/soft-deleted/hard-deleted) next to
profile_id, in both dry-run table and verbose run output. States are resolved
in batched, trashed-aware queries.
With -v, print per-row detail (media_id, original status_id, remote_media,
profile_id, mime, size, path) as each orphaned row is processed instead of the
progress bar, and expand the dry-run table with extra columns. Uses Laravel's
built-in verbosity flag.
media:maintenance --scope orphanedMedia cleans up media whose status_id
references a status that no longer exists (hard-deleted) or is soft-deleted.
These dangling references predate the delete-path fix and MediaDeletePipeline's
attached guard would otherwise refuse to delete them, leaking files.
Detaches (status_id = null) before dispatching deletion via MediaStorageService,
so the guard sees a genuinely orphaned row. Supports --limit (batched),
--dry-run, and --force. The --scope map is extensible for future routines.
MediaDeletePipeline skips deletion when media->status_id is set. status_id has
no FK/cascade, so deleting a status never clears it, and the delete jobs
dispatched by the status-delete paths were always skipped, leaking media files.
Detach media (status_id = null) before dispatching the delete in StatusDelete,
RemoteStatusDelete and DeleteRemoteStatusPipeline, so the row is genuinely
orphaned by the time the guard checks it and the deletion proceeds.
Also adds a status:media diagnostic command that dumps all metadata for a
media id (DB columns, computed URLs, attachment state, parent status including
the dangling status_id case, owner, metadata, and an optional live URL check).
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.
- Drop --except (--only already covers task selection)
- Quiet by default; per-root/per-item and summary lines now require -v/--verbose
- Errors are always shown regardless of verbosity
- Document the command in the console README
Replace the remcache GC (GarbageCollectorRemcache / gc:remcache) with a
broader storage:maintenance command that sweeps stale remcache temp files and
recursively removes the random empty directories accumulated under the media,
story, avatar and import trees (--hours/--only/--except/--dry-run), scheduled
daily.
Fix the root causes so flows clean up after themselves rather than relying on
the sweep:
- MediaDeletePipeline removes its own emptied m/_v2 leaf dir
- AvatarOptimize logs the previously-swallowed exception, still cleans up the
old avatar on failure, and removes the old file's now-empty splayed dir
- AvatarController::deleteAvatar removes the emptied splayed dir
- StoryExpire/StoryDelete remove the story's own emptied leaf dir
- TransformImports removes imports/{userId} once its files are moved out
- StoryFetch cleans up its remcache temp file in a finally block
MediaMoveStorageLocalToCloud is stable, so move it back out of the Unstable
namespace: restore App\Console\Commands\Admin\MediaMoveStorageLocalToCloud and
its admin:MediaMoveStorageLocalToCloud signature, and update the scheduler,
README, and feature test. CloudToLocal and CloudToCloud remain under unstable:.
- Rename App\Console\Commands\Admin\MigrateLocalS3MediaURL to MediaUpdateS3CDNUrl
(class + filename only; the admin:MigrateLocalS3MediaURL signature is unchanged)
- Move the three MediaMoveStorage{LocalToCloud,CloudToLocal,CloudToCloud} commands
into App\Console\Commands\Admin\Unstable and change their signatures from
admin: to unstable:
- Update the scheduler in bootstrap/app.php, the command README, the
config/filesystems.php reference comment, and the affected feature tests
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.
Process-level workers plateaued at ~7.5 uploads/sec against Fastly Object
Storage because each PUT is high-latency and only a handful ran concurrently.
Add --concurrency=N which uses the AWS SDK CommandPool to keep N PutObject
requests in flight from a single process. A successful PutObject response is
the confirmation (no separate HEAD verify), and the local file is deleted on
success. Commands are yielded lazily so memory stays flat over large runs.
--no-acl escape hatch for S3-compatible stores that reject the ACL header.
- Live 'up/s' rate shown on the progress bar during single-worker runs
- Final summary reports elapsed time and uploads/sec
- Parallel runs tally moved across workers and report aggregate uploads/sec
Makes it easy to compare --workers counts and decide whether async S3
(option 2) is worth pursuing.
The migration was ~1-2s/file due to sequential S3 round-trips (HEAD + PUT +
verify HEAD). Speed it up:
- --workers=N spawns N child processes, each handling a strided slice of the
files (index % N == shard) for real concurrency on the I/O-bound uploads
- --skip-cloud-check skips the upfront HEAD (always upload, idempotent)
- --skip-verify skips the post-upload size re-check
- --offset for manual chunking
Storage/Flysystem has no batch or async upload API, so process-level
concurrency is the pragmatic lever here.
The frontend renders a hardcoded /storage/emoji/missing.png local onerror
fallback for emoji, so that placeholder must stay on local disk. Skip it in
the migration so it is never moved to cloud or deleted locally.
The migration was DB-driven (whereNull('uri')), which excluded federated
emoji whose media is stored locally but have a uri set -> the disk was never
scanned, resulting in moved=0.
- Drive the migration by enumerating local files under public/emoji/ instead
of a DB query; the local file is the source of truth for what needs moving
- Add --debug to print config, custom_emoji table breakdown, local emoji dir
contents, and per-file decisions
Back out the story local->cloud migration so we can land and verify the
emoji cloud work first, one change at a time.
Reverts:
- 9f110bb74 feat: migrate local story media to cloud storage
- 842681b99 fix: schedule StoryMoveStorageLocalToCloud command
Removes StoryMoveStorageLocalToCloud command, its scheduler entry, and the
StoryExpire explicit-disk changes. Remcache and emoji work are untouched.
Will revisit story once emoji is confirmed.
Two issues prevented emoji from serving/migrating correctly on cloud:
- Admin custom-emoji views hardcoded url('storage/'.media_path), so they
always showed local URLs and bypassed cloud resolution. Use $emoji->url().
- The migration/command guard relied solely on config_cache('pixelfed.cloud_storage'),
which is DB/12h-cached and can read stale-false right after cloud is
enabled, causing the migration to silently no-op. Treat cloud as enabled
when either live config() or config_cache() is true.
Change --limit default to 0 (no limit) so the emoji migration processes
every local emoji in a single run instead of capping at 1000, and drop the
limit from the scheduled invocation. Avoids a multi-run window where
not-yet-migrated emoji resolve to missing cloud URLs.
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
Ensure story media lands on and stays on cloud storage for S3 instances.
- StoryExpire: archive expiring story media on the same explicit disk the
media lives on (S3 move is a server-side copy+delete), with error handling
- Add admin:StoryMoveStorageLocalToCloud to migrate local story media
(active + story_archives) to cloud: copy, verify by size, then delete local
- --orphans option relocates untracked story_archives/ files to cloud using
the same copy/verify/delete flow (media is moved, never discarded)
- Schedule it hourly alongside the media migration when cloud storage is on
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 return type hints (void) and final class markers
- Guard null returns from newestBackup() and putFileAs() in BackupToCloud
- Type ask() default values as strings
- Fix uses_left fallback condition for null/zero max_uses
- Annotate AdminInvite::whereInviteCode and cast Str::uuid() to string
- Ignore local redis-data and mysql-9-data dev directories
The resynced summary printed all three counts unconditionally, which
made an untouched metric (e.g. an already-correct comments count) look
like it had been resynced. Drive the summary from the drifted set and
show before->after values, so it matches the drift detection exactly.
reply_count is a nullable column, so NULL rendered as an empty string in
the resync summary. Cast the summary output to int so a null/absent
comment count prints as 0. No behavior change to the reconcile logic.
FixProfileCounts and FixPostCounts use the admin: signature prefix and
are operator-run maintenance tools, so move them from FixBugs/ to Admin/
(namespace updated) and refresh the README tables to match.