diff --git a/fluxer_api/src/api/channel/services/message/MessageOperationsHelpers.ts b/fluxer_api/src/api/channel/services/message/MessageOperationsHelpers.ts index f272f1557..08c08565a 100644 --- a/fluxer_api/src/api/channel/services/message/MessageOperationsHelpers.ts +++ b/fluxer_api/src/api/channel/services/message/MessageOperationsHelpers.ts @@ -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 { - 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 { + 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; + } } diff --git a/fluxer_api/src/api/favorite_meme/FavoriteMemeRepository.ts b/fluxer_api/src/api/favorite_meme/FavoriteMemeRepository.ts index bd41d2fbc..ad168784c 100644 --- a/fluxer_api/src/api/favorite_meme/FavoriteMemeRepository.ts +++ b/fluxer_api/src/api/favorite_meme/FavoriteMemeRepository.ts @@ -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 { + await fetchOne(FavoriteMemes.patchByPk({user_id: userId, meme_id: memeId}, {placeholder: Db.set(placeholder)})); + } + async delete(userId: UserID, memeId: MemeID): Promise { const batch = new BatchBuilder(); batch.addPrepared(FavoriteMemes.deleteByPk({user_id: userId, meme_id: memeId})); diff --git a/fluxer_api/src/api/favorite_meme/IFavoriteMemeRepository.ts b/fluxer_api/src/api/favorite_meme/IFavoriteMemeRepository.ts index 154faf2a7..209fe8d47 100644 --- a/fluxer_api/src/api/favorite_meme/IFavoriteMemeRepository.ts +++ b/fluxer_api/src/api/favorite_meme/IFavoriteMemeRepository.ts @@ -34,6 +34,8 @@ export abstract class IFavoriteMemeRepository { abstract update(userId: UserID, memeId: MemeID, data: CreateFavoriteMemeParams): Promise; + abstract updatePlaceholder(userId: UserID, memeId: MemeID, placeholder: string): Promise; + abstract delete(userId: UserID, memeId: MemeID): Promise; abstract deleteAllByUserId(userId: UserID): Promise; diff --git a/fluxer_api/src/api/test/TestMediaService.ts b/fluxer_api/src/api/test/TestMediaService.ts index 69d55951c..9ddf1a8a2 100644 --- a/fluxer_api/src/api/test/TestMediaService.ts +++ b/fluxer_api/src/api/test/TestMediaService.ts @@ -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'; } diff --git a/fluxer_api/src/api/user/tests/FavoriteMemeOperations.test.ts b/fluxer_api/src/api/user/tests/FavoriteMemeOperations.test.ts index 285b7c87c..64b27228d 100644 --- a/fluxer_api/src/api/user/tests/FavoriteMemeOperations.test.ts +++ b/fluxer_api/src/api/user/tests/FavoriteMemeOperations.test.ts @@ -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(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(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);