fix(api): keep the saved placeholder on sent memes (#2085)

This commit is contained in:
Hampus
2026-08-29 16:04:58 +02:00
committed by GitHub
parent 4d15c39cd7
commit 9f099a9127
5 changed files with 107 additions and 10 deletions
@@ -14,6 +14,7 @@ import type {IFavoriteMemeRepository} from '../../../favorite_meme/IFavoriteMeme
import type {IMediaService} from '../../../infrastructure/IMediaService';
import type {ISnowflakeService} from '../../../infrastructure/ISnowflakeService';
import type {IStorageService} from '../../../infrastructure/IStorageService';
import {Logger} from '../../../Logger';
import type {FavoriteMeme} from '../../../models/FavoriteMeme';
import type {Message} from '../../../models/Message';
import type {User} from '../../../models/User';
@@ -90,8 +91,21 @@ export class MessageOperationsHelpers {
}
throw error;
}
const needsAnimationProbe =
!favoriteMeme.isGifv &&
favoriteMeme.contentType !== 'image/gif' &&
favoriteMeme.contentType !== 'image/apng' &&
ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType);
const metadata =
needsAnimationProbe || favoriteMeme.placeholder == null
? await this.probeFavoriteMemeMetadata(favoriteMeme)
: null;
const placeholder = favoriteMeme.placeholder ?? metadata?.placeholder ?? null;
if (placeholder != null && favoriteMeme.placeholder == null) {
await this.repairFavoriteMemePlaceholder(favoriteMeme, placeholder);
}
let flags = 0;
if (await this.isFavoriteMemeAnimated(favoriteMeme)) {
if (this.isFavoriteMemeAnimated(favoriteMeme, metadata)) {
flags |= MessageAttachmentFlags.IS_ANIMATED;
}
return {
@@ -104,7 +118,7 @@ export class MessageOperationsHelpers {
height: favoriteMeme.height,
content_type: favoriteMeme.contentType,
content_hash: favoriteMeme.contentHash,
placeholder: null,
placeholder,
flags,
duration: favoriteMeme.duration,
nsfw: null,
@@ -112,20 +126,31 @@ export class MessageOperationsHelpers {
};
}
private async isFavoriteMemeAnimated(favoriteMeme: FavoriteMeme): Promise<boolean> {
if (favoriteMeme.isGifv) return true;
if (favoriteMeme.contentType === 'image/gif' || favoriteMeme.contentType === 'image/apng') return true;
if (!ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType)) return false;
private async probeFavoriteMemeMetadata(favoriteMeme: FavoriteMeme) {
try {
const metadata = await this.deps.mediaService.getMetadata({
return await this.deps.mediaService.getMetadata({
type: 's3',
bucket: Config.s3.buckets.cdn,
key: favoriteMeme.storageKey,
nsfw: 'allow',
});
return metadata?.animated === true;
} catch {
return false;
return null;
}
}
private async repairFavoriteMemePlaceholder(favoriteMeme: FavoriteMeme, placeholder: string): Promise<void> {
try {
await this.deps.favoriteMemeRepository.updatePlaceholder(favoriteMeme.userId, favoriteMeme.id, placeholder);
} catch (error) {
Logger.warn({error, memeId: favoriteMeme.id.toString()}, 'Failed to backfill favorite meme placeholder');
}
}
private isFavoriteMemeAnimated(favoriteMeme: FavoriteMeme, metadata: {animated?: boolean | null} | null): boolean {
if (favoriteMeme.isGifv) return true;
if (favoriteMeme.contentType === 'image/gif' || favoriteMeme.contentType === 'image/apng') return true;
if (!ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType)) return false;
return metadata?.animated === true;
}
}
@@ -2,6 +2,7 @@
import type {MemeID, UserID} from '../BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
import {Db} from '../database/CassandraTypes';
import type {FavoriteMemeRow} from '../database/types/UserTypes';
import {FavoriteMeme} from '../models/FavoriteMeme';
import {FavoriteMemes, FavoriteMemesByMemeId} from '../Tables';
@@ -99,6 +100,10 @@ export class FavoriteMemeRepository extends IFavoriteMemeRepository {
return new FavoriteMeme(memeRow);
}
async updatePlaceholder(userId: UserID, memeId: MemeID, placeholder: string): Promise<void> {
await fetchOne(FavoriteMemes.patchByPk({user_id: userId, meme_id: memeId}, {placeholder: Db.set(placeholder)}));
}
async delete(userId: UserID, memeId: MemeID): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(FavoriteMemes.deleteByPk({user_id: userId, meme_id: memeId}));
@@ -34,6 +34,8 @@ export abstract class IFavoriteMemeRepository {
abstract update(userId: UserID, memeId: MemeID, data: CreateFavoriteMemeParams): Promise<FavoriteMeme>;
abstract updatePlaceholder(userId: UserID, memeId: MemeID, placeholder: string): Promise<void>;
abstract delete(userId: UserID, memeId: MemeID): Promise<void>;
abstract deleteAllByUserId(userId: UserID): Promise<void>;
@@ -37,6 +37,7 @@ export class TestMediaService extends IMediaService {
height: 128,
animated: stored ? this.isAnimatedImage(stored, format) : format === 'gif',
nsfw: false,
placeholder: this.fakePlaceholder(request.key),
};
}
if (request.type === 'upload') {
@@ -55,6 +56,7 @@ export class TestMediaService extends IMediaService {
height: 128,
animated: format === 'gif',
nsfw: false,
placeholder: this.fakePlaceholder(request.upload_filename),
};
}
if (request.type === 'external') {
@@ -67,11 +69,16 @@ export class TestMediaService extends IMediaService {
height: 128,
animated: false,
nsfw: false,
placeholder: this.fakePlaceholder(request.url),
};
}
return null;
}
private fakePlaceholder(seed: string): string {
return crypto.createHash('sha256').update(seed).digest('base64').slice(0, 24);
}
getExternalMediaProxyURL(): string {
return 'https://media-proxy.test';
}
@@ -3,12 +3,15 @@
import {MessageAttachmentFlags} from '@fluxer/constants/src/ChannelConstants';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {AttachmentDecayRepository} from '../../attachment/AttachmentDecayRepository';
import {createAttachmentID} from '../../BrandedTypes';
import {createAttachmentID, createMemeID, createUserID} from '../../BrandedTypes';
import {
createTestAccountForAttachmentTests,
sendMessageWithAttachments,
setupTestGuildAndChannel,
} from '../../channel/tests/AttachmentTestUtils';
import {fetchOne} from '../../database/CassandraQueryExecution';
import {Db} from '../../database/CassandraTypes';
import {FavoriteMemes} from '../../Tables';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
@@ -42,6 +45,24 @@ interface MessageWithDecayAttachment {
}>;
}
interface MessageWithPlaceholderAttachment {
id: string;
attachments: Array<{
id: string;
filename: string;
placeholder?: string | null;
}>;
}
async function clearFavoriteMemePlaceholder(userId: string, memeId: string) {
await fetchOne(
FavoriteMemes.patchByPk(
{user_id: createUserID(BigInt(userId)), meme_id: createMemeID(BigInt(memeId))},
{placeholder: Db.set(null)},
),
);
}
async function fetchDecayRow(attachmentId: string) {
return new AttachmentDecayRepository().fetchById(createAttachmentID(BigInt(attachmentId)));
}
@@ -111,6 +132,43 @@ describe('Favorite Meme Operations', () => {
expect(sent.attachments[0].filename).toBe(filename);
expect(sent.attachments[0].flags & MessageAttachmentFlags.IS_ANIMATED).toBe(MessageAttachmentFlags.IS_ANIMATED);
});
test('should carry the saved placeholder onto the sent attachment', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Placeholder Meme',
});
expect(meme.placeholder).toBeTruthy();
const sent = await createBuilder<MessageWithPlaceholderAttachment>(harness, account.token)
.post(`/channels/${channel.id}/messages`)
.body({favorite_meme_id: meme.id})
.expect(HTTP_STATUS.OK)
.execute();
expect(sent.attachments[0].placeholder).toBe(meme.placeholder);
});
test('should read-repair a missing placeholder when sending a favorite meme', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
const meme = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message.id, {
attachment_id: message.attachments[0].id,
name: 'Repair Meme',
});
expect(meme.placeholder).toBeTruthy();
await clearFavoriteMemePlaceholder(account.userId, meme.id);
const stripped = await getFavoriteMeme(harness, account.token, meme.id);
expect(stripped.placeholder).toBeNull();
const sent = await createBuilder<MessageWithPlaceholderAttachment>(harness, account.token)
.post(`/channels/${channel.id}/messages`)
.body({favorite_meme_id: meme.id})
.expect(HTTP_STATUS.OK)
.execute();
expect(sent.attachments[0].placeholder).toBeTruthy();
const repaired = await getFavoriteMeme(harness, account.token, meme.id);
expect(repaired.placeholder).toBe(sent.attachments[0].placeholder);
});
test('should create decay metadata when sending favorite meme', async () => {
const account = await createTestAccountForAttachmentTests(harness);
const {channel} = await setupTestGuildAndChannel(harness, account);