Normalize JSON-LD compacted single attachments (a bare object instead of a
one-item array) in getAttachments(), and route verifyAttachments() through it
so validation and import share one normalization path.
Includes PR #6589's tests plus additional edge-case coverage: list-form
preservation, bare-input normalization, and guards for missing/empty/scalar
attachments.
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.
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.
status:dedup and fix:avatars address historical data states that can no
longer occur (unique statuses.uri index since 2019; SVG identicon avatars
no longer generated). Move both to a Deprecated/ folder and update the
README audit accordingly. media:fix stays in FixBugs/ since image filters
are still an active feature.
Group console commands into Admin, Dev, FixBugs, Install, Internal, and
User subfolders (matching the earlier reorganization), and add a new
Status subfolder for the status:user, status:profile, and status:post
debug commands. Namespaces updated to match; command signatures and the
total command count are unchanged.
Rename user:status, profile:status, and post:status console commands
to status:user, status:profile, and status:post. Rename the command
files and classes to match (StatusUser, StatusProfile, StatusPost) and
update the cross-reference tip in StatusProfile.
Helpers::importNoteAttachment unconditionally inserted a new Media row per
attachment, so re-importing a remote status (an Announce racing another
inbox job, a re-fetch, or a duplicate url within one activity) hit the
media_status_id_media_path_unique constraint and crashed the queue job with
a 1062 UniqueConstraintViolationException, dropping the boost/import.
Make createMediaAttachment idempotent on (status_id, media_path): skip when
a row already exists, and catch the unique-constraint violation as a
lost-race no-op, returning null so the caller skips re-dispatching storage.
Adds regression tests (re-import no-op, distinct urls still stored,
concurrent-insert returns null).
Cold-migrate existing media from an old S3 bucket to the current cloud
bucket, one media row at a time (like MigrateLocalS3MediaURL):
- Source = --sourceDisk (default s3-old, reads AWS_OLD_*); destination = the
current cloud disk (config filesystems.cloud). No .env editing: operators
point AWS_* at the new bucket first (restarting workers as usual) so new
uploads/downloads land on the new bucket, then run this to backfill old data.
- Copies media (+thumbnail) source->destination, verifies by size and by
sha256 of the freshly-written destination object (against original_sha256),
rewrites cdn_url/optimized_url/thumbnail_url to the destination host, and
GCs the source objects (unless --keep-source). Busts caches.
- Only touches rows whose cdn_url still points at the source host; idempotent.
- --sourceDisk / --limit / --dry-run / --force.
- Adds the s3-old disk (AWS_OLD_*) to config/filesystems.php and feature tests.
Add admin:MediaMoveStorageLocalToCloud and admin:MediaMoveStorageCloudToLocal:
- Copy media (+thumbnail) between local and cloud disks, verify by size (and
sha256 against original_sha256 when present) before deleting the source.
- Integrated GC: delete the verified source copy (local on upload, cloud on
download), set version=4 / reset to 3, and bust MediaService/StatusService
caches. --keep-local / --keep-cloud opt out.
- Manage PF_ENABLE_CLOUD in .env AND the live runtime + config cache so new
uploads route to the correct backend mid-migration on a hot server. Uses the
installer's atomic .env writer (shared ManagesMediaStorageEnv trait).
- --limit / --dry-run / --force.
Replaces media:migrate2cloud (CloudMediaMigrate) and media:s3gc
(MediaS3GarbageCollector); scheduler now runs MediaMoveStorageLocalToCloud
hourly for straggler upload + GC. Keeps media:fix-nonlocal-driver.
Adds feature tests (download+GC, --keep-cloud, dry-run, env-flag flip both
directions, unknown-disk guard).
Rename the command (and test) to admin:MigrateLocalS3MediaURL to reflect its
scope: rewriting stale S3/cloud media URLs only. Remove avatar handling and
the --avatars option; the command now focuses solely on status media
(cdn_url, thumbnail_url, optimized_url).
Rebuilds stale local media URLs (cdn_url, thumbnail_url, optimized_url) and
avatar cdn_urls from their storage paths via the configured cloud disk.
- Default target host comes from the configured cloud disk (AWS_URL);
requires confirmation (or --force) and can be overridden with --newDomain.
- Optional --oldDomain filters to a single old backend host; by default all
stale hosts are rewritten.
- Refuses to run when PF_ENABLE_CLOUD is false (local storage) and, when
auto-detecting, refuses a target equal to the app domain — so local-storage
instances are never rewritten.
- Single status id / post URL, --all, --avatars; --dry-run; busts
MediaService/StatusService caches for affected statuses.
- Removes the superseded media:cloud-url-rewrite command.
- Adds feature tests covering rewrite/skip/dry-run/oldDomain/newDomain/
remote-skip/local-storage-refusal.
Dumps a Status and its media for debugging. Accepts a post id or URL
(/p/username/ID). Shows status columns, author, every media row's storage
fields (media_path, thumbnail_path, cdn_url, thumbnail_url, optimized_url,
remote_url, etc.), computed url()/thumbnailUrl()/expected-from-path, a URL
health check comparing stored URL hosts against the configured cloud disk
host (flags stale hosts), and the cached MediaService media_attachments
actually served to clients.
Bulk --all reconciliation previously scanned both local and remote profiles
implicitly. Now --all requires an explicit --scope of local, remote, or
both. --active stays local-only and rejects a non-local --scope. Adds the
BelongsTo return type to Profile::user() so the whereHas('user') scope
filter passes Larastan, and adds tests for scope requirement/validation and
local/remote filtering.
- Rename command signature fix:profilecounts -> admin:fixProfileCounts.
- --active is now its own bulk mode (recently-active local accounts),
mutually exclusive with --all and a single id.
- Add --type=followers|following|statuses to restrict reconciliation to a
single metric (validated).
- Update/extend tests for the new name, --type restriction and invalid-type
rejection.
- Add --force flag to fix:profilecounts for unattended runs and schedule
'fix:profilecounts --all --force' weekly (Sun 03:37) as a safety-net
reconcile. Kept as a low-frequency full scan rather than a new event-driven
dirty-set; it only writes profiles that actually drifted.
- Add Feature tests for AccountStatService recompute helpers and
reconcileProfileCounts (media-type status_count semantics, follower/
following counts, drift/no-drift/no-write, metric restriction, missing
profile) plus fix:profilecounts command behavior (silent-when-synced,
dry-run makes no changes).
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.
Consolidate cached-count reconciliation into a single fix:profilecounts
command that resyncs followers_count, following_count and status_count
from the source-of-truth tables for one profile or --all. Only reports
profiles with actual drift (silent when in sync); supports --dry-run and
--dispatch (queues FollowServiceWarmCache and rebuilds Redis sets).
Removes the superseded manual commands fix:followercount, fix:statuscount
and fix:rpc. Keeps app:account-post-count-stat-update, which is scheduled
(runs every 6 hours) and queue-driven.
profiles.followers_count/following_count are cached columns reconciled
lazily by FollowServiceWarmCache (throttled up to 7 days), so they can
drift from the followers table. This command recomputes them from the
source-of-truth table for a single profile or --all drifted local
profiles, with --dry-run to report and --dispatch to queue the warm-cache
job (which also rebuilds the Redis sets). Mirrors the existing
fix:statuscount convention.
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).
Unlike user:status (local users only, keyed on the users table),
profile:status keys on the profiles table so it works for remote/
federated actors too. Resolves id, username, user@domain, @user@domain,
webfinger, or remote_url. Shows full column dump, derived/federation
metadata, linked local user (local) or Instance row (remote), and
health checks for orphans, missing keys, and count desync.
Dump every profiles column dynamically (keys redacted, long text trimmed),
add derived metadata (local/remote type, urls, live vs cached follower/
following/status counts, avatar, federation fields), and profile health
checks (soft-delete, id mismatches, missing crypto keys, count desync).
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