Merge pull request #7437 from pixelfed/staging

Staging
pull/7442/head^2
dansup 11 hours ago committed by GitHub
commit 40829a417b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -24,6 +24,8 @@ IMAGE_QUALITY="80"
MAX_PHOTO_SIZE="15000"
MAX_CAPTION_LENGTH="500"
MAX_ALBUM_LENGTH="4"
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

@ -75,6 +75,95 @@ 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
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.
# - 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
# 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 \
&& /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 +184,8 @@ RUN apt-get update && apt-get install -y \
optipng \
pngquant \
gifsicle \
libvips42 \
git \
curl \
libaom-dev \
libdav1d-dev \
libmp3lame0 \
libnuma1 \
libopus0 \
@ -110,8 +196,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 +230,9 @@ 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.
RUN tee /usr/local/etc/php/conf.d/zz-pixelfed.ini > /dev/null <<'EOF'
ffi.enable=true
EOF
@ -148,6 +254,15 @@ RUN chown -R www-data:www-data /var/www/html \
RUN composer install --no-ansi --no-interaction --optimize-autoloader
# Smoke check 2
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

@ -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);

@ -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<string, mixed>|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<int, array<string, mixed>>, descendants: array<int, array<string, mixed>>}|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<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $accounts
* @param array<int, array<string, mixed>> $participants Everyone in the conversation, as account entities
* @return array<string, mixed>
*/
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'],

@ -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.
*

@ -49,11 +49,11 @@ return [
'permissions' => [
'file' => [
'public' => 0644,
'private' => 0600,
'private' => 0640,
],
'dir' => [
'public' => 0755,
'private' => 0711,
'private' => 0750,
],
],
'serve' => true,

@ -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,
]);
});
@ -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', '<p>hello</p>')
->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', '<p>hello</p>')
->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();

Loading…
Cancel
Save