From 1c9cae3989654c41c9ea005039521ec7f659eff9 Mon Sep 17 00:00:00 2001 From: Shlee Date: Tue, 22 Sep 2026 23:29:34 +0930 Subject: [PATCH 1/7] Update filesystems.php --- config/filesystems.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/filesystems.php b/config/filesystems.php index dedafb3e1..8d9df3113 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -49,11 +49,11 @@ return [ 'permissions' => [ 'file' => [ 'public' => 0644, - 'private' => 0600, + 'private' => 0640, ], 'dir' => [ 'public' => 0755, - 'private' => 0711, + 'private' => 0750, ], ], 'serve' => true, From b02748026ee2250bc0124697f2e6c623d8516762 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 23 Sep 2026 00:30:50 +0930 Subject: [PATCH 2/7] Compile libvips from source with AVIF/HEIC + JXL support - Add a vips builder stage that compiles libvips 8.18.6 from source (pinned tarball + sha256), linked against distro libheif/aom/dav1d (AVIF/HEIC) and libjxl (JPEG XL). - Trim delegates to the formats Pixelfed uses: jpeg, png, gif, webp, avif/heic, jxl; disable tiff, pdf, svg, openexr, magick, etc. to keep the library small and reduce attack surface on untrusted uploads. - Drop the ext-vips C extension (incompatible with libvips 8.18 and unused: Pixelfed uses jcupitt/vips via FFI) and keep ffi enabled. - Update runtime deps to match the compiled library. - Add MEDIA_TYPES (webp/avif/jxl) to .env.example and .env.docker.example. --- .env.docker.example | 2 + .env.example | 2 + Dockerfile | 141 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 141 insertions(+), 4 deletions(-) diff --git a/.env.docker.example b/.env.docker.example index 675016cba..83590f27e 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -24,6 +24,8 @@ IMAGE_QUALITY="80" MAX_PHOTO_SIZE="15000" MAX_CAPTION_LENGTH="500" MAX_ALBUM_LENGTH="4" +# Accepted upload mime types. webp/avif/jxl require the vips (or imagick) driver. +MEDIA_TYPES="image/jpeg,image/jpg,image/png,image/gif,image/webp,image/avif,image/jxl" # Instance URL Configuration # IMPORTANT: Update these with your actual domain diff --git a/.env.example b/.env.example index 072798731..890787e6f 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,8 @@ IMAGE_QUALITY="80" MAX_PHOTO_SIZE="15000" MAX_CAPTION_LENGTH="500" MAX_ALBUM_LENGTH="4" +# Accepted upload mime types. webp/avif/jxl require the vips (or imagick) driver. +MEDIA_TYPES="image/jpeg,image/jpg,image/png,image/gif,image/webp,image/avif,image/jxl" # Instance URL Configuration APP_URL="http://localhost" diff --git a/Dockerfile b/Dockerfile index 23b6b058f..af624327e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,6 +75,102 @@ RUN ./configure \ make -j"$(nproc)"; \ make install +# libvips builder — compile from source for a current release with full +# AVIF/HEIC support. +FROM serversideup/php:8.5-frankenphp AS vips + +# libvips version to compile, change with [--build-arg VIPS_VERSION="8.18.6"] +ARG VIPS_VERSION=8.18.6 +ARG VIPS_URL=https://github.com/libvips/libvips/releases/download +# sha256 of vips-${VIPS_VERSION}.tar.xz (from the release .sha256sum asset) +ARG VIPS_SHA256=3c41e1d5458081bfa4a5bc54e116c46259c75c6760a18027764555632b9dda3e + +USER root +SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-c"] + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + meson \ + ninja-build \ + pkg-config \ + wget \ + xz-utils \ + libglib2.0-dev \ + libexpat1-dev \ + libjpeg-dev \ + libpng-dev \ + libwebp-dev \ + libexif-dev \ + liblcms2-dev \ + libheif-dev \ + libaom-dev \ + libdav1d-dev \ + libjxl-dev \ + liborc-0.4-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/local/vips/src +RUN wget -q "${VIPS_URL}/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz" \ + && echo "${VIPS_SHA256} vips-${VIPS_VERSION}.tar.xz" | sha256sum -c - \ + && tar xf "vips-${VIPS_VERSION}.tar.xz" + +WORKDIR /usr/local/vips/src/vips-${VIPS_VERSION} +# Pixelfed only handles common web formats (jpeg/png/gif/webp) plus modern +# avif/heic and jpeg-xl. We enable exactly those delegates and explicitly +# disable every other loader (tiff, pdf, svg, openexr, fits, magick, ...) to +# keep the library small and reduce the attack surface for untrusted uploads. +# - gif : load uses libvips' bundled libnsgif, save uses bundled cgif, +# so no giflib dev package is required. +# - heif : AVIF/HEIC read+write via distro libheif -> aom/dav1d. +# - jpeg-xl : JXL read+write via distro libjxl. +# -Ddebug : off, and we strip for a lean runtime library. +RUN meson setup build \ + --prefix=/usr/local/vips \ + --libdir=lib \ + --buildtype=release \ + -Ddeprecated=false \ + -Dexamples=false \ + -Dcplusplus=false \ + -Djpeg=enabled \ + -Dpng=enabled \ + -Dwebp=enabled \ + -Dheif=enabled \ + -Djpeg-xl=enabled \ + -Dlcms=enabled \ + -Dexif=enabled \ + -Dtiff=disabled \ + -Dopenjpeg=disabled \ + -Dpdfium=disabled \ + -Dpoppler=disabled \ + -Drsvg=disabled \ + -Dopenexr=disabled \ + -Dopenslide=disabled \ + -Dmatio=disabled \ + -Dnifti=disabled \ + -Dcfitsio=disabled \ + -Dmagick=disabled \ + -Draw=disabled \ + -Duhdr=disabled \ + -Dfftw=disabled \ + -Dfontconfig=disabled \ + -Dpangocairo=disabled \ + -Darchive=disabled \ + -Dppm=false \ + -Danalyze=false \ + -Dradiance=false \ + && meson compile -C build \ + && meson install -C build \ + && strip --strip-unneeded /usr/local/vips/lib/libvips.so.* || true + +# Confirm the formats we care about made it into the build. Register the lib +# with the loader first so the vips CLI can dlopen libvips.so.42 and its +# delegates. Fails the build if AVIF/HEIC or JXL support is missing. +RUN echo "/usr/local/vips/lib" > /etc/ld.so.conf.d/vips.conf && ldconfig \ + && /usr/local/vips/bin/vips --vips-version \ + && /usr/local/vips/bin/vips list | grep -i heif \ + && /usr/local/vips/bin/vips list | grep -i jxl + # PHP base image — FrankenPHP (includes Caddy built-in) FROM serversideup/php:8.5-frankenphp @@ -95,11 +191,8 @@ RUN apt-get update && apt-get install -y \ optipng \ pngquant \ gifsicle \ - libvips42 \ git \ curl \ - libaom-dev \ - libdav1d-dev \ libmp3lame0 \ libnuma1 \ libopus0 \ @@ -110,8 +203,28 @@ RUN apt-get update && apt-get install -y \ libwebpmux3 \ libx264-dev \ libx265-dev \ + libglib2.0-0t64 \ + libexpat1 \ + libjpeg62-turbo \ + libpng16-16t64 \ + libexif12 \ + liblcms2-2 \ + liborc-0.4-0t64 \ + libheif1 \ + libaom3 \ + libdav1d7 \ + libjxl0.11 \ + libhwy1t64 \ && rm -rf /var/lib/apt/lists/* +# Bring in the libvips we compiled (shared lib + headers + pkg-config + tools), +# then refresh the linker cache so the PHP vips extension links against it. +COPY --from=vips /usr/local/vips /usr/local/vips +RUN echo "/usr/local/vips/lib" > /etc/ld.so.conf.d/vips.conf && ldconfig + +ENV PKG_CONFIG_PATH=/usr/local/vips/lib/pkgconfig \ + PATH=/usr/local/vips/bin:$PATH + RUN install-php-extensions \ bcmath \ curl \ @@ -124,9 +237,12 @@ RUN install-php-extensions \ zip \ pdo_mysql \ redis \ - vips \ ffi +# Pixelfed talks to libvips through jcupitt/vips (via intervention/image-driver-vips), +# which is an FFI binding — it dlopens libvips.so at runtime and does NOT need the +# ext-vips C extension. So we only enable ffi here. (The old php-vips C extension +# also fails to compile against libvips 8.18 due to removed public symbols.) RUN tee /usr/local/etc/php/conf.d/zz-pixelfed.ini > /dev/null <<'EOF' ffi.enable=true EOF @@ -139,6 +255,13 @@ RUN ldconfig \ && /usr/bin/ffmpeg -version \ && /usr/bin/ffprobe -version +# Sanity-check the compiled libvips and that PHP FFI is enabled (php-vips +# needs FFI, not the ext-vips extension). Confirm avif + jxl are available. +RUN php -r 'exit(ini_get("ffi.enable") ? 0 : 1);' \ + && vips --vips-version \ + && vips list | grep -i heif \ + && vips list | grep -i jxl + COPY --chown=www-data:www-data . /var/www/html RUN chown -R www-data:www-data /var/www/html \ @@ -148,6 +271,16 @@ RUN chown -R www-data:www-data /var/www/html \ RUN composer install --no-ansi --no-interaction --optimize-autoloader +# End-to-end check: php-vips (FFI) opens our compiled libvips and can round-trip +# an image through the AVIF and JXL savers. Fails the build if wiring is broken. +RUN php -r '\ + require "vendor/autoload.php"; \ + $im = Jcupitt\Vips\Image::black(16, 16); \ + $im->writeToBuffer(".avif"); \ + $im->writeToBuffer(".jxl"); \ + echo "php-vips FFI OK: libvips " . Jcupitt\Vips\Config::version() . "\n"; \ + ' + USER www-data EXPOSE 8080 From 0dce5ab34657305aee40fa97b62a0e7d64c02b29 Mon Sep 17 00:00:00 2001 From: Shlee Date: Wed, 23 Sep 2026 00:46:43 +0930 Subject: [PATCH 3/7] Update Dockerfile --- Dockerfile | 32 +++++++------------------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/Dockerfile b/Dockerfile index af624327e..a9d619150 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,14 +75,12 @@ RUN ./configure \ make -j"$(nproc)"; \ make install -# libvips builder — compile from source for a current release with full -# AVIF/HEIC support. +# libvips builder — compile from source for a current release with full AVIF/HEIC support. FROM serversideup/php:8.5-frankenphp AS vips # libvips version to compile, change with [--build-arg VIPS_VERSION="8.18.6"] ARG VIPS_VERSION=8.18.6 ARG VIPS_URL=https://github.com/libvips/libvips/releases/download -# sha256 of vips-${VIPS_VERSION}.tar.xz (from the release .sha256sum asset) ARG VIPS_SHA256=3c41e1d5458081bfa4a5bc54e116c46259c75c6760a18027764555632b9dda3e USER root @@ -116,12 +114,9 @@ RUN wget -q "${VIPS_URL}/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz" \ && tar xf "vips-${VIPS_VERSION}.tar.xz" WORKDIR /usr/local/vips/src/vips-${VIPS_VERSION} -# Pixelfed only handles common web formats (jpeg/png/gif/webp) plus modern -# avif/heic and jpeg-xl. We enable exactly those delegates and explicitly -# disable every other loader (tiff, pdf, svg, openexr, fits, magick, ...) to -# keep the library small and reduce the attack surface for untrusted uploads. -# - gif : load uses libvips' bundled libnsgif, save uses bundled cgif, -# so no giflib dev package is required. +# Pixelfed only handles common web formats (jpeg/png/gif/webp) plus modern avif/heic and jpeg-xl. +# We enable exactly those delegates and explicitly disable every other loader. +# - gif : load uses libvips' bundled libnsgif, save uses bundled cgif, so no giflib dev package is required. # - heif : AVIF/HEIC read+write via distro libheif -> aom/dav1d. # - jpeg-xl : JXL read+write via distro libjxl. # -Ddebug : off, and we strip for a lean runtime library. @@ -163,9 +158,7 @@ RUN meson setup build \ && meson install -C build \ && strip --strip-unneeded /usr/local/vips/lib/libvips.so.* || true -# Confirm the formats we care about made it into the build. Register the lib -# with the loader first so the vips CLI can dlopen libvips.so.42 and its -# delegates. Fails the build if AVIF/HEIC or JXL support is missing. +# Smoke Test RUN echo "/usr/local/vips/lib" > /etc/ld.so.conf.d/vips.conf && ldconfig \ && /usr/local/vips/bin/vips --vips-version \ && /usr/local/vips/bin/vips list | grep -i heif \ @@ -239,10 +232,7 @@ RUN install-php-extensions \ redis \ ffi -# Pixelfed talks to libvips through jcupitt/vips (via intervention/image-driver-vips), -# which is an FFI binding — it dlopens libvips.so at runtime and does NOT need the -# ext-vips C extension. So we only enable ffi here. (The old php-vips C extension -# also fails to compile against libvips 8.18 due to removed public symbols.) +# Pixelfed talks to libvips through jcupitt/vips (via intervention/image-driver-vips) which is an FFI binding. RUN tee /usr/local/etc/php/conf.d/zz-pixelfed.ini > /dev/null <<'EOF' ffi.enable=true EOF @@ -255,13 +245,6 @@ RUN ldconfig \ && /usr/bin/ffmpeg -version \ && /usr/bin/ffprobe -version -# Sanity-check the compiled libvips and that PHP FFI is enabled (php-vips -# needs FFI, not the ext-vips extension). Confirm avif + jxl are available. -RUN php -r 'exit(ini_get("ffi.enable") ? 0 : 1);' \ - && vips --vips-version \ - && vips list | grep -i heif \ - && vips list | grep -i jxl - COPY --chown=www-data:www-data . /var/www/html RUN chown -R www-data:www-data /var/www/html \ @@ -271,8 +254,7 @@ RUN chown -R www-data:www-data /var/www/html \ RUN composer install --no-ansi --no-interaction --optimize-autoloader -# End-to-end check: php-vips (FFI) opens our compiled libvips and can round-trip -# an image through the AVIF and JXL savers. Fails the build if wiring is broken. +# Smoke check 2 RUN php -r '\ require "vendor/autoload.php"; \ $im = Jcupitt\Vips\Image::black(16, 16); \ From 961ef37fe9b7b28fa992030a031789a848e81a91 Mon Sep 17 00:00:00 2001 From: Shlee Date: Wed, 23 Sep 2026 00:50:19 +0930 Subject: [PATCH 4/7] Modify MEDIA_TYPES and add MODERN_MEDIA_TYPES Updated accepted upload mime types and added modern media types. --- .env.docker.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.docker.example b/.env.docker.example index 83590f27e..75b14692e 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -24,8 +24,8 @@ IMAGE_QUALITY="80" MAX_PHOTO_SIZE="15000" MAX_CAPTION_LENGTH="500" MAX_ALBUM_LENGTH="4" -# Accepted upload mime types. webp/avif/jxl require the vips (or imagick) driver. -MEDIA_TYPES="image/jpeg,image/jpg,image/png,image/gif,image/webp,image/avif,image/jxl" +MEDIA_TYPES="image/jpeg,image/jpg,image/png,image/gif" # Accepted upload mime types. +MODERN_MEDIA_TYPES="image/avif,image/jxl,image/webp" # webp/avif/jxl require the vips driver. # Instance URL Configuration # IMPORTANT: Update these with your actual domain From 413f606b2017f562523ee006a2f573c2a9c011d1 Mon Sep 17 00:00:00 2001 From: Shlee Date: Wed, 23 Sep 2026 00:50:38 +0930 Subject: [PATCH 5/7] Remove MEDIA_TYPES from .env.example Removed MEDIA_TYPES from the environment example file. --- .env.example | 2 -- 1 file changed, 2 deletions(-) diff --git a/.env.example b/.env.example index 890787e6f..072798731 100644 --- a/.env.example +++ b/.env.example @@ -20,8 +20,6 @@ IMAGE_QUALITY="80" MAX_PHOTO_SIZE="15000" MAX_CAPTION_LENGTH="500" MAX_ALBUM_LENGTH="4" -# Accepted upload mime types. webp/avif/jxl require the vips (or imagick) driver. -MEDIA_TYPES="image/jpeg,image/jpg,image/png,image/gif,image/webp,image/avif,image/jxl" # Instance URL Configuration APP_URL="http://localhost" From 91f54c0d0ff0c3e0538f88c33e770e12eb5e113e Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Tue, 22 Sep 2026 19:56:20 -0600 Subject: [PATCH 6/7] Fix Direct Message shims. Fixes #7441 --- app/Http/Controllers/Api/ApiV1Controller.php | 38 ++++- app/Services/DirectMessagePayloadService.php | 105 ++++++++++++-- app/Services/DirectMessageService.php | 22 +++ .../DirectMessageLegacyApiTest.php | 132 ++++++++++++++++-- 4 files changed, 272 insertions(+), 25 deletions(-) diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 100028459..f86e582d4 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -3370,7 +3370,7 @@ class ApiV1Controller extends Controller abort_unless($request->user()->tokenCan('write'), 403); $service = app(DirectMessageService::class); - $found = is_numeric($id) ? $service->conversationFor($id, $request->user()->profile_id) : null; + $found = is_numeric($id) ? $service->conversationForMastodonId($id, $request->user()->profile_id) : null; abort_if(! $found, 404); $service->setHidden($found[1], true); @@ -3390,7 +3390,7 @@ class ApiV1Controller extends Controller $payloads = app(DirectMessagePayloadService::class); $pid = $request->user()->profile_id; - $found = is_numeric($id) ? $service->conversationFor($id, $pid) : null; + $found = is_numeric($id) ? $service->conversationForMastodonId($id, $pid) : null; abort_if(! $found, 404); [$conversation, $participant] = $found; @@ -3425,6 +3425,14 @@ class ApiV1Controller extends Controller $res = $request->has(self::PF_API_ENTITY_KEY) ? StatusService::get($id, false) : StatusService::getMastodon($id, false); if (! $res || ! isset($res['visibility'])) { + // Direct messages are no longer statuses, but clients still take + // the id they got from /api/v1/conversations to this endpoint + $direct = app(DirectMessagePayloadService::class)->mastodonStatusById($id, $pid); + + if ($direct) { + return $this->json($direct); + } + abort(404); } @@ -3478,6 +3486,12 @@ class ApiV1Controller extends Controller ); if (! $status || ! isset($status['account'])) { + $direct = app(DirectMessagePayloadService::class)->mastodonContext($id, $pid); + + if ($direct) { + return $this->json($direct); + } + return response('', 404); } @@ -4040,8 +4054,24 @@ class ApiV1Controller extends Controller abort_unless($request->user()->tokenCan('write'), 403); AccountService::setLastActive($request->user()->id); - $status = Status::whereProfileId($request->user()->profile->id) - ->findOrFail($id); + $pid = $request->user()->profile_id; + $status = Status::whereProfileId($pid)->find($id); + + if (! $status) { + $message = DmMessage::where('profile_id', $pid)->find($id); + abort_if(! $message, 404); + + $payloads = app(DirectMessagePayloadService::class); + $res = $payloads->mastodonStatusById($message->id, $pid); + abort_if(! $res, 404); + + app(DirectMessageService::class)->deleteMessage($message); + + $res['text'] = $res['content_text']; + unset($res['content']); + + return $this->json($res); + } $resource = new Fractal\Resource\Item($status, new StatusTransformer); diff --git a/app/Services/DirectMessagePayloadService.php b/app/Services/DirectMessagePayloadService.php index 85e0a1653..aa321769a 100644 --- a/app/Services/DirectMessagePayloadService.php +++ b/app/Services/DirectMessagePayloadService.php @@ -186,10 +186,13 @@ class DirectMessagePayloadService */ public function mastodonConversation(DmConversation $conversation, DmConversationParticipant $viewer, Collection $members, ?DmMessage $last, int $viewerId): ?array { - $accounts = $members - ->filter(fn ($member) => (int) $member->profile_id !== $viewerId) + $everyone = $members ->map(fn ($member) => AccountService::getMastodon($member->profile_id, true)) ->filter(fn ($account) => $account && isset($account['id'])) + ->values(); + + $accounts = $everyone + ->filter(fn ($account) => (int) $account['id'] !== $viewerId) ->values() ->all(); @@ -197,22 +200,108 @@ class DirectMessagePayloadService return null; } + // The id is the viewer's participant row, not the conversation. It + // is an autoincrement, and the old endpoint handed out one of those + // too, so clients that store this as a 32-bit int (Pixelix does) + // keep working. It is only used for the DELETE and read calls, where + // it resolves back to the conversation for this viewer. return [ - 'id' => (string) $conversation->id, + 'id' => (string) $viewer->id, 'unread' => $viewer->unread_count > 0, 'accounts' => $accounts, - 'last_status' => $this->mastodonStatus($last, $accounts, $viewerId), + 'last_status' => $this->mastodonStatus($last, $everyone->all(), $viewerId), ]; } + /** + * The message as a status entity, for a viewer who is in its + * conversation. Mastodon clients take the `last_status` id from + * /api/v1/conversations straight to /api/v1/statuses/{id}. + * + * @return array|null + */ + public function mastodonStatusById(int|string $messageId, int $viewerId): ?array + { + $message = DmMessage::with('media')->find($messageId); + + if (! $message) { + return null; + } + + $found = app(DirectMessageService::class)->conversationFor($message->conversation_id, $viewerId); + + if (! $found || in_array((int) $message->profile_id, $this->blockedIds($viewerId), true)) { + return null; + } + + return $this->mastodonStatus($message, $this->mastodonParticipants($found[0]), $viewerId); + } + + /** + * The rest of the conversation around a message, in the shape of + * /api/v1/statuses/{id}/context: what came before as ancestors and what + * came after as descendants. + * + * @return array{ancestors: array>, descendants: array>}|null + */ + public function mastodonContext(int|string $messageId, int $viewerId, int $limit = 40): ?array + { + $message = DmMessage::find($messageId); + + if (! $message) { + return null; + } + + $found = app(DirectMessageService::class)->conversationFor($message->conversation_id, $viewerId); + + if (! $found) { + return null; + } + + $participants = $this->mastodonParticipants($found[0]); + $blocked = $this->blockedIds($viewerId) ?: [0]; + + $query = fn () => DmMessage::with('media') + ->where('conversation_id', $message->conversation_id) + ->whereNotIn('profile_id', $blocked); + + $ancestors = $query()->where('id', '<', $message->id)->orderByDesc('id')->limit($limit)->get()->reverse(); + $descendants = $query()->where('id', '>', $message->id)->orderBy('id')->limit($limit)->get(); + + $toStatus = fn (DmMessage $m) => $this->mastodonStatus($m, $participants, $viewerId); + + return [ + 'ancestors' => $ancestors->map($toStatus)->values()->all(), + 'descendants' => $descendants->map($toStatus)->values()->all(), + ]; + } + + /** + * Everyone in the conversation, as Mastodon account entities. + * + * @return array> + */ + public function mastodonParticipants(DmConversation $conversation): array + { + return DmConversationParticipant::where('conversation_id', $conversation->id) + ->orderBy('id') + ->pluck('profile_id') + ->map(fn ($id) => AccountService::getMastodon($id, true)) + ->filter(fn ($account) => $account && isset($account['id'])) + ->values() + ->all(); + } + /** * Messages are not statuses any more, but Mastodon clients expect one as - * `last_status`, so this builds the entity from the message. + * `last_status`, so this builds the entity from the message. Everyone in + * the conversation other than the author is a mention, the viewer + * included: that is how a client tells the message was addressed to them. * - * @param array> $accounts + * @param array> $participants Everyone in the conversation, as account entities * @return array */ - public function mastodonStatus(DmMessage $message, array $accounts, int $viewerId): array + public function mastodonStatus(DmMessage $message, array $participants, int $viewerId): array { $media = collect($this->media($message))->map(function (array $item) { $mime = $item['mime'] ?? null; @@ -259,7 +348,7 @@ class DirectMessagePayloadService 'visibility' => 'direct', 'application' => null, 'language' => null, - 'mentions' => collect($accounts) + 'mentions' => collect($participants) ->filter(fn ($account) => (string) $account['id'] !== (string) $message->profile_id) ->map(fn ($account) => [ 'id' => (string) $account['id'], diff --git a/app/Services/DirectMessageService.php b/app/Services/DirectMessageService.php index 24e483e2c..ddccd2dc6 100644 --- a/app/Services/DirectMessageService.php +++ b/app/Services/DirectMessageService.php @@ -232,6 +232,28 @@ class DirectMessageService return $conversation ? [$conversation, $participant] : null; } + /** + * The conversation behind an id from /api/v1/conversations, which is the + * viewer's participant row. A conversation id is accepted too. + * + * @return array{0: DmConversation, 1: DmConversationParticipant}|null + */ + public function conversationForMastodonId(int|string $id, int $profileId): ?array + { + $participant = DmConversationParticipant::where('id', $id) + ->where('profile_id', $profileId) + ->where('state', '!=', DmConversationParticipant::STATE_LEFT) + ->first(); + + if ($participant) { + $conversation = DmConversation::find($participant->conversation_id); + + return $conversation ? [$conversation, $participant] : null; + } + + return $this->conversationFor($id, $profileId); + } + /** * Profile ids of everyone in the conversation. * diff --git a/tests/Feature/DirectMessage/DirectMessageLegacyApiTest.php b/tests/Feature/DirectMessage/DirectMessageLegacyApiTest.php index 3accb761a..102788349 100644 --- a/tests/Feature/DirectMessage/DirectMessageLegacyApiTest.php +++ b/tests/Feature/DirectMessage/DirectMessageLegacyApiTest.php @@ -15,7 +15,7 @@ use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Storage; use Laravel\Passport\Passport; -require_once __DIR__.'/helpers.php'; +require_once __DIR__ . '/helpers.php'; uses(LazilyRefreshDatabase::class); @@ -35,13 +35,19 @@ beforeEach(function () { Queue::fake(); Http::fake(); + // Ids minted in the same millisecond only sort by creation order when the + // worker bits are fixed. Left unset they are random for every id. + config(['snowflake.datacenter_id' => 1, 'snowflake.worker_id' => 1]); + $this->withoutMiddleware(ThrottleRequests::class); + // Ids minted in the same millisecond only sort by creation order when the + // worker bits are fixed. Left unset they are random for every id. + config(['snowflake.datacenter_id' => 1, 'snowflake.worker_id' => 1]); + config([ 'instance.enable_cc' => false, 'federation.activitypub.enabled' => true, - 'snowflake.datacenter_id' => 1, - 'snowflake.worker_id' => 1, ]); }); @@ -68,7 +74,7 @@ describe('thread endpoints', function () { Passport::actingAs($bob, ['read', 'write']); - $thread = $this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id); + $thread = $this->getJson('/api/v1.1/direct/thread?pid=' . $alice->profile_id); $thread->assertOk() ->assertJsonPath('id', (string) $alice->profile_id) @@ -86,7 +92,7 @@ describe('thread endpoints', function () { Passport::actingAs($alice, ['read', 'write']); - $this->getJson('/api/v1.1/direct/thread?pid='.$bob->profile_id) + $this->getJson('/api/v1.1/direct/thread?pid=' . $bob->profile_id) ->assertOk() ->assertJsonCount(0, 'messages') ->assertJsonPath('conversation_id', null); @@ -115,12 +121,12 @@ describe('thread endpoints', function () { Passport::actingAs($bob, ['read', 'write']); - $this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id) + $this->getJson('/api/v1.1/direct/thread?pid=' . $alice->profile_id) ->assertJsonPath('messages.0.text', 'my cat') ->assertJsonPath('messages.0.type', 'photo') ->assertJsonCount(1, 'messages.0.carousel'); - expect($this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id)->json('messages.0.media'))->not->toBeNull(); + expect($this->getJson('/api/v1.1/direct/thread?pid=' . $alice->profile_id)->json('messages.0.media'))->not->toBeNull(); }); it('marks a thread read and reports it as seen to the sender', function () { @@ -138,7 +144,7 @@ describe('thread endpoints', function () { expect(DmConversationParticipant::where('profile_id', $bob->profile_id)->value('unread_count'))->toBe(0); Passport::actingAs($alice, ['read', 'write']); - $this->getJson('/api/v1.1/direct/thread?pid='.$bob->profile_id)->assertJsonPath('messages.0.seen', true); + $this->getJson('/api/v1.1/direct/thread?pid=' . $bob->profile_id)->assertJsonPath('messages.0.seen', true); }); it('deletes by the id the thread handed out', function () { @@ -195,22 +201,50 @@ describe('GET /api/v1/conversations', function () { Passport::actingAs($bob, ['read', 'write']); - $this->getJson('/api/v1/conversations') + $response = $this->getJson('/api/v1/conversations') ->assertOk() ->assertJsonCount(1) - ->assertJsonPath('0.id', (string) $conversation->id) ->assertJsonPath('0.unread', true) ->assertJsonPath('0.accounts.0.id', (string) $alice->profile_id) ->assertJsonPath('0.last_status.id', (string) $message->id) ->assertJsonPath('0.last_status.visibility', 'direct') ->assertJsonPath('0.last_status.content', '

hello

') - ->assertJsonPath('0.last_status.account.id', (string) $alice->profile_id); + ->assertJsonPath('0.last_status.account.id', (string) $alice->profile_id) + ->assertJsonPath('0.last_status.mentions.0.id', (string) $bob->profile_id); + + // Pixelix stores this id as a 32-bit int, as the old endpoint allowed + $id = $response->json('0.id'); + expect((int) $id)->toBeLessThan(2 ** 31) + ->and((int) $id)->toBeGreaterThan(0) + ->and((int) $id)->toBe(DmConversationParticipant::where('profile_id', $bob->profile_id)->value('id')); + + $this->postJson("/api/v1/conversations/{$id}/read")->assertOk()->assertJsonPath('unread', false); - $this->postJson("/api/v1/conversations/{$conversation->id}/read")->assertOk()->assertJsonPath('unread', false); - $this->deleteJson("/api/v1/conversations/{$conversation->id}")->assertOk(); + // The real conversation id still works for clients written against it + $this->postJson("/api/v1/conversations/{$conversation->id}/read")->assertOk(); + + $this->deleteJson("/api/v1/conversations/{$id}")->assertOk(); $this->getJson('/api/v1/conversations')->assertJsonCount(0); }); + it('does not let one person use another persons conversation id', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + $eve = dmLocalUser(); + + $service = app(DirectMessageService::class); + $conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob)); + $service->sendMessage($conversation, dmProfile($alice), ['body' => 'hello']); + + Passport::actingAs($bob, ['read', 'write']); + $id = $this->getJson('/api/v1/conversations')->json('0.id'); + + Passport::actingAs($eve, ['read', 'write']); + $this->postJson("/api/v1/conversations/{$id}/read")->assertNotFound(); + $this->deleteJson("/api/v1/conversations/{$id}")->assertNotFound(); + $this->deleteJson("/api/v1/conversations/{$conversation->id}")->assertNotFound(); + }); + it('only includes groups when asked', function () { $alice = dmLocalUser(); $bob = dmLocalUser(); @@ -243,6 +277,78 @@ describe('GET /api/v1/conversations', function () { }); }); +describe('mastodon status endpoints', function () { + it('serves a message as a status to people in the conversation and nobody else', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + $eve = dmLocalUser(); + + $service = app(DirectMessageService::class); + $conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob)); + $message = $service->sendMessage($conversation, dmProfile($alice), ['body' => 'hello']); + + Passport::actingAs($bob, ['read', 'write']); + + $this->getJson("/api/v1/statuses/{$message->id}") + ->assertOk() + ->assertJsonPath('id', (string) $message->id) + ->assertJsonPath('visibility', 'direct') + ->assertJsonPath('content', '

hello

') + ->assertJsonPath('account.id', (string) $alice->profile_id) + ->assertJsonPath('mentions.0.id', (string) $bob->profile_id); + + Passport::actingAs($eve, ['read', 'write']); + + $this->getJson("/api/v1/statuses/{$message->id}")->assertNotFound(); + }); + + it('returns the rest of the conversation as the context of a message', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + $service = app(DirectMessageService::class); + $conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob)); + $one = $service->sendMessage($conversation, dmProfile($alice), ['body' => 'one']); + $two = $service->sendMessage($conversation, dmProfile($bob), ['body' => 'two']); + $three = $service->sendMessage($conversation, dmProfile($alice), ['body' => 'three']); + + Passport::actingAs($bob, ['read', 'write']); + + $this->getJson("/api/v1/statuses/{$two->id}/context") + ->assertOk() + ->assertJsonCount(1, 'ancestors') + ->assertJsonPath('ancestors.0.id', (string) $one->id) + ->assertJsonCount(1, 'descendants') + ->assertJsonPath('descendants.0.id', (string) $three->id) + ->assertJsonPath('descendants.0.visibility', 'direct'); + + // The usual client flow: open the conversation from its last status + $this->getJson("/api/v1/statuses/{$three->id}/context") + ->assertOk() + ->assertJsonCount(2, 'ancestors') + ->assertJsonCount(0, 'descendants'); + }); + + it('lets the author delete a message through the status endpoint', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + $service = app(DirectMessageService::class); + $conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob)); + $message = $service->sendMessage($conversation, dmProfile($alice), ['body' => 'oops']); + + Passport::actingAs($bob, ['read', 'write']); + $this->deleteJson("/api/v1/statuses/{$message->id}")->assertNotFound(); + + Passport::actingAs($alice, ['read', 'write']); + $this->deleteJson("/api/v1/statuses/{$message->id}") + ->assertOk() + ->assertJsonPath('text', 'oops'); + + expect(DmMessage::count())->toBe(0); + }); +}); + describe('media housekeeping', function () { it('does not let direct message media be attached to a post', function () { $alice = dmLocalUser(); From f044d14c8d363036adf06affbb89e10a51c85c90 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Tue, 22 Sep 2026 19:58:22 -0600 Subject: [PATCH 7/7] Lint --- .../DirectMessage/DirectMessageLegacyApiTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Feature/DirectMessage/DirectMessageLegacyApiTest.php b/tests/Feature/DirectMessage/DirectMessageLegacyApiTest.php index 102788349..be7ad460c 100644 --- a/tests/Feature/DirectMessage/DirectMessageLegacyApiTest.php +++ b/tests/Feature/DirectMessage/DirectMessageLegacyApiTest.php @@ -15,7 +15,7 @@ use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Storage; use Laravel\Passport\Passport; -require_once __DIR__ . '/helpers.php'; +require_once __DIR__.'/helpers.php'; uses(LazilyRefreshDatabase::class); @@ -74,7 +74,7 @@ describe('thread endpoints', function () { Passport::actingAs($bob, ['read', 'write']); - $thread = $this->getJson('/api/v1.1/direct/thread?pid=' . $alice->profile_id); + $thread = $this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id); $thread->assertOk() ->assertJsonPath('id', (string) $alice->profile_id) @@ -92,7 +92,7 @@ describe('thread endpoints', function () { Passport::actingAs($alice, ['read', 'write']); - $this->getJson('/api/v1.1/direct/thread?pid=' . $bob->profile_id) + $this->getJson('/api/v1.1/direct/thread?pid='.$bob->profile_id) ->assertOk() ->assertJsonCount(0, 'messages') ->assertJsonPath('conversation_id', null); @@ -121,12 +121,12 @@ describe('thread endpoints', function () { Passport::actingAs($bob, ['read', 'write']); - $this->getJson('/api/v1.1/direct/thread?pid=' . $alice->profile_id) + $this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id) ->assertJsonPath('messages.0.text', 'my cat') ->assertJsonPath('messages.0.type', 'photo') ->assertJsonCount(1, 'messages.0.carousel'); - expect($this->getJson('/api/v1.1/direct/thread?pid=' . $alice->profile_id)->json('messages.0.media'))->not->toBeNull(); + expect($this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id)->json('messages.0.media'))->not->toBeNull(); }); it('marks a thread read and reports it as seen to the sender', function () { @@ -144,7 +144,7 @@ describe('thread endpoints', function () { expect(DmConversationParticipant::where('profile_id', $bob->profile_id)->value('unread_count'))->toBe(0); Passport::actingAs($alice, ['read', 'write']); - $this->getJson('/api/v1.1/direct/thread?pid=' . $bob->profile_id)->assertJsonPath('messages.0.seen', true); + $this->getJson('/api/v1.1/direct/thread?pid='.$bob->profile_id)->assertJsonPath('messages.0.seen', true); }); it('deletes by the id the thread handed out', function () {