initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {HonoApp} from '~/App';
import {createChannelID, createMemeID, createMessageID} from '~/BrandedTypes';
import {UnknownFavoriteMemeError} from '~/Errors';
import {
CreateFavoriteMemeBodySchema,
CreateFavoriteMemeFromUrlBodySchema,
mapFavoriteMemeToResponse,
UpdateFavoriteMemeBodySchema,
} from '~/favorite_meme/FavoriteMemeModel';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, z} from '~/Schema';
import {Validator} from '~/Validator';
const channelIdParamSchema = z.object({channel_id: Int64Type});
const messageIdParamSchema = z.object({message_id: Int64Type});
const memeIdParamSchema = z.object({meme_id: Int64Type});
export const FavoriteMemeController = (app: HonoApp) => {
app.get(
'/users/@me/memes',
RateLimitMiddleware(RateLimitConfigs.FAVORITE_MEME_LIST),
LoginRequired,
DefaultUserOnly,
async (ctx) => {
const user = ctx.get('user');
const memes = await ctx.get('favoriteMemeService').listFavoriteMemes(user.id);
return ctx.json(memes.map((meme) => mapFavoriteMemeToResponse(meme)));
},
);
app.post(
'/users/@me/memes',
RateLimitMiddleware(RateLimitConfigs.FAVORITE_MEME_CREATE_FROM_URL),
LoginRequired,
DefaultUserOnly,
Validator('json', CreateFavoriteMemeFromUrlBodySchema),
async (ctx) => {
const user = ctx.get('user');
const {url, name, alt_text, tags, tenor_id} = ctx.req.valid('json');
const meme = await ctx.get('favoriteMemeService').createFromUrl({
user,
url,
name,
altText: alt_text ?? undefined,
tags: tags ?? undefined,
tenorId: tenor_id ?? undefined,
});
return ctx.json(mapFavoriteMemeToResponse(meme), 201);
},
);
app.post(
'/channels/:channel_id/messages/:message_id/memes',
RateLimitMiddleware(RateLimitConfigs.FAVORITE_MEME_CREATE_FROM_MESSAGE),
LoginRequired,
DefaultUserOnly,
Validator('param', channelIdParamSchema.merge(messageIdParamSchema)),
Validator('json', CreateFavoriteMemeBodySchema),
async (ctx) => {
const user = ctx.get('user');
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const messageId = createMessageID(ctx.req.valid('param').message_id);
const {attachment_id, embed_index, name, alt_text, tags} = ctx.req.valid('json');
const meme = await ctx.get('favoriteMemeService').createFromMessage({
user,
channelId,
messageId,
attachmentId: attachment_id?.toString(),
embedIndex: embed_index ?? undefined,
name,
altText: alt_text ?? undefined,
tags: tags ?? undefined,
});
return ctx.json(mapFavoriteMemeToResponse(meme), 201);
},
);
app.get(
'/users/@me/memes/:meme_id',
RateLimitMiddleware(RateLimitConfigs.FAVORITE_MEME_GET),
LoginRequired,
DefaultUserOnly,
Validator('param', memeIdParamSchema),
async (ctx) => {
const user = ctx.get('user');
const memeId = createMemeID(ctx.req.valid('param').meme_id);
const meme = await ctx.get('favoriteMemeService').getFavoriteMeme(user.id, memeId);
if (!meme) {
throw new UnknownFavoriteMemeError();
}
return ctx.json(mapFavoriteMemeToResponse(meme));
},
);
app.patch(
'/users/@me/memes/:meme_id',
RateLimitMiddleware(RateLimitConfigs.FAVORITE_MEME_UPDATE),
LoginRequired,
DefaultUserOnly,
Validator('param', memeIdParamSchema),
Validator('json', UpdateFavoriteMemeBodySchema),
async (ctx) => {
const user = ctx.get('user');
const memeId = createMemeID(ctx.req.valid('param').meme_id);
const {name, alt_text, tags} = ctx.req.valid('json');
const meme = await ctx.get('favoriteMemeService').update({
user,
memeId,
name: name ?? undefined,
altText: alt_text === undefined ? undefined : alt_text,
tags: tags ?? undefined,
});
return ctx.json(mapFavoriteMemeToResponse(meme));
},
);
app.delete(
'/users/@me/memes/:meme_id',
RateLimitMiddleware(RateLimitConfigs.FAVORITE_MEME_DELETE),
LoginRequired,
DefaultUserOnly,
Validator('param', memeIdParamSchema),
async (ctx) => {
const user = ctx.get('user');
const memeId = createMemeID(ctx.req.valid('param').meme_id);
await ctx.get('favoriteMemeService').delete(user.id, memeId);
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,109 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {userIdToChannelId} from '~/BrandedTypes';
import {MAX_FAVORITE_MEME_TAGS} from '~/Constants';
import {makeAttachmentCdnUrl} from '~/channel/services/message/MessageHelpers';
import type {FavoriteMeme} from '~/Models';
import {createStringType, Int64Type, z} from '~/Schema';
export const FavoriteMemeResponse = z.object({
id: z.string(),
user_id: z.string(),
name: z.string(),
alt_text: z.string().nullish(),
tags: z.array(z.string()),
attachment_id: z.string(),
filename: z.string(),
content_type: z.string(),
content_hash: z.string().nullish(),
size: z.number(),
width: z.number().int().nullish(),
height: z.number().int().nullish(),
duration: z.number().nullish(),
url: z.string(),
is_gifv: z.boolean().default(false),
tenor_id: z.string().nullish(),
});
export type FavoriteMemeResponse = z.infer<typeof FavoriteMemeResponse>;
export const CreateFavoriteMemeBodySchema = z
.object({
attachment_id: Int64Type.nullish(),
embed_index: z.number().int().min(0).nullish(),
name: createStringType(1, 100),
alt_text: createStringType(0, 500).nullish(),
tags: z
.array(createStringType(1, 30))
.max(MAX_FAVORITE_MEME_TAGS, `Maximum ${MAX_FAVORITE_MEME_TAGS} tags allowed`)
.nullish()
.default([])
.transform((tags) => (tags || []).filter((t) => t.trim().length > 0)),
})
.refine((data) => data.attachment_id !== undefined || data.embed_index !== undefined, {
message: 'Either attachment_id or embed_index must be provided',
});
export type CreateFavoriteMemeBodySchema = z.infer<typeof CreateFavoriteMemeBodySchema>;
export const CreateFavoriteMemeFromUrlBodySchema = z.object({
url: z.url(),
name: createStringType(1, 100).nullish(),
alt_text: createStringType(0, 500).nullish(),
tags: z
.array(createStringType(1, 30))
.max(MAX_FAVORITE_MEME_TAGS, `Maximum ${MAX_FAVORITE_MEME_TAGS} tags allowed`)
.nullish()
.default([])
.transform((tags) => (tags || []).filter((t) => t.trim().length > 0)),
tenor_id: createStringType(1, 100).nullish(),
});
export type CreateFavoriteMemeFromUrlBodySchema = z.infer<typeof CreateFavoriteMemeFromUrlBodySchema>;
export const UpdateFavoriteMemeBodySchema = z.object({
name: createStringType(1, 100).nullish(),
alt_text: createStringType(0, 500).nullish().or(z.null()),
tags: z
.array(createStringType(1, 30))
.max(MAX_FAVORITE_MEME_TAGS, `Maximum ${MAX_FAVORITE_MEME_TAGS} tags allowed`)
.nullish()
.transform((tags) => (tags ? tags.filter((t) => t.trim().length > 0) : undefined)),
});
export type UpdateFavoriteMemeBodySchema = z.infer<typeof UpdateFavoriteMemeBodySchema>;
export const mapFavoriteMemeToResponse = (meme: FavoriteMeme): FavoriteMemeResponse => {
const url = makeAttachmentCdnUrl(userIdToChannelId(meme.userId), meme.attachmentId, meme.filename);
return {
id: meme.id.toString(),
user_id: meme.userId.toString(),
name: meme.name,
alt_text: meme.altText ?? null,
tags: meme.tags || [],
attachment_id: meme.attachmentId.toString(),
filename: meme.filename,
content_type: meme.contentType,
content_hash: meme.contentHash ?? null,
size: Number(meme.size),
width: meme.width ?? null,
height: meme.height ?? null,
duration: meme.duration ?? null,
url,
is_gifv: meme.isGifv ?? false,
tenor_id: meme.tenorId ?? null,
};
};

View File

@@ -0,0 +1,129 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MemeID, UserID} from '~/BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {FavoriteMemeRow} from '~/database/CassandraTypes';
import {FavoriteMeme} from '~/Models';
import {FavoriteMemes, FavoriteMemesByMemeId} from '~/Tables';
import {type CreateFavoriteMemeParams, IFavoriteMemeRepository} from './IFavoriteMemeRepository';
const FETCH_FAVORITE_MEME_CQL = FavoriteMemes.selectCql({
where: [FavoriteMemes.where.eq('user_id'), FavoriteMemes.where.eq('meme_id')],
limit: 1,
});
const FETCH_FAVORITE_MEMES_BY_USER_CQL = FavoriteMemes.selectCql({
where: FavoriteMemes.where.eq('user_id'),
});
const COUNT_FAVORITE_MEMES_CQL = FavoriteMemes.selectCountCql({
where: FavoriteMemes.where.eq('user_id'),
});
export class FavoriteMemeRepository extends IFavoriteMemeRepository {
async findById(userId: UserID, memeId: MemeID): Promise<FavoriteMeme | null> {
const meme = await fetchOne<FavoriteMemeRow>(FETCH_FAVORITE_MEME_CQL, {
user_id: userId,
meme_id: memeId,
});
return meme ? new FavoriteMeme(meme) : null;
}
async findByUserId(userId: UserID): Promise<Array<FavoriteMeme>> {
const memes = await fetchMany<FavoriteMemeRow>(FETCH_FAVORITE_MEMES_BY_USER_CQL, {user_id: userId});
return memes.map((meme) => new FavoriteMeme(meme));
}
async count(userId: UserID): Promise<number> {
const result = await fetchOne<{count: bigint}>(COUNT_FAVORITE_MEMES_CQL, {user_id: userId});
return result ? Number(result.count) : 0;
}
async create(data: CreateFavoriteMemeParams): Promise<FavoriteMeme> {
const memeRow: FavoriteMemeRow = {
user_id: data.user_id,
meme_id: data.meme_id,
name: data.name,
alt_text: data.alt_text ?? null,
tags: data.tags ?? [],
attachment_id: data.attachment_id,
filename: data.filename,
content_type: data.content_type,
content_hash: data.content_hash ?? null,
size: data.size,
width: data.width ?? null,
height: data.height ?? null,
duration: data.duration ?? null,
is_gifv: data.is_gifv ?? false,
tenor_id_str: data.tenor_id ?? null,
version: 1,
};
const batch = new BatchBuilder();
batch.addPrepared(FavoriteMemes.upsertAll(memeRow));
batch.addPrepared(
FavoriteMemesByMemeId.upsertAll({
meme_id: memeRow.meme_id,
user_id: memeRow.user_id,
}),
);
await batch.execute();
return new FavoriteMeme(memeRow);
}
async update(userId: UserID, memeId: MemeID, data: CreateFavoriteMemeParams): Promise<void> {
const memeRow: FavoriteMemeRow = {
user_id: userId,
meme_id: memeId,
name: data.name,
alt_text: data.alt_text ?? null,
tags: data.tags ?? [],
attachment_id: data.attachment_id,
filename: data.filename,
content_type: data.content_type,
content_hash: data.content_hash ?? null,
size: data.size,
width: data.width ?? null,
height: data.height ?? null,
duration: data.duration ?? null,
is_gifv: data.is_gifv ?? false,
tenor_id_str: data.tenor_id ?? null,
version: 1,
};
await upsertOne(FavoriteMemes.upsertAll(memeRow));
}
async delete(userId: UserID, memeId: MemeID): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(FavoriteMemes.deleteByPk({user_id: userId, meme_id: memeId}));
batch.addPrepared(FavoriteMemesByMemeId.deleteByPk({meme_id: memeId, user_id: userId}));
await batch.execute();
}
async deleteAllByUserId(userId: UserID): Promise<void> {
const memes = await this.findByUserId(userId);
for (const meme of memes) {
await this.delete(userId, meme.id);
}
}
}

View File

@@ -0,0 +1,562 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import mime from 'mime';
import {
type ChannelID,
createAttachmentID,
createMemeID,
type MemeID,
type MessageID,
type UserID,
userIdToChannelId,
} from '~/BrandedTypes';
import {Config} from '~/Config';
import {MAX_FAVORITE_MEMES_NON_PREMIUM, MAX_FAVORITE_MEMES_PREMIUM} from '~/Constants';
import type {ChannelService} from '~/channel/services/ChannelService';
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '~/channel/services/message/MessageHelpers';
import {
InputValidationError,
MaxFavoriteMemesError,
MediaMetadataError,
UnknownFavoriteMemeError,
UnknownMessageError,
} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import type {IStorageService} from '~/infrastructure/IStorageService';
import type {IUnfurlerService} from '~/infrastructure/IUnfurlerService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import {Logger} from '~/Logger';
import {FavoriteMeme, type Message, type User} from '~/Models';
import {mapFavoriteMemeToResponse} from './FavoriteMemeModel';
import type {IFavoriteMemeRepository} from './IFavoriteMemeRepository';
export class FavoriteMemeService {
constructor(
private readonly favoriteMemeRepository: IFavoriteMemeRepository,
private readonly channelService: ChannelService,
private readonly storageService: IStorageService,
private readonly mediaService: IMediaService,
private readonly snowflakeService: SnowflakeService,
private readonly gatewayService: IGatewayService,
private readonly unfurlerService: IUnfurlerService,
) {}
async createFromMessage({
user,
channelId,
messageId,
attachmentId,
embedIndex,
name,
altText,
tags,
}: {
user: User;
channelId: ChannelID;
messageId: MessageID;
attachmentId?: string;
embedIndex?: number;
name: string;
altText?: string;
tags?: Array<string>;
}): Promise<FavoriteMeme> {
const count = await this.favoriteMemeRepository.count(user.id);
const maxMemes = user.isPremium() ? MAX_FAVORITE_MEMES_PREMIUM : MAX_FAVORITE_MEMES_NON_PREMIUM;
if (count >= maxMemes) {
throw new MaxFavoriteMemesError(user.isPremium());
}
await this.channelService.getChannelAuthenticated({userId: user.id, channelId});
const message = await this.channelService.getMessage({userId: user.id, channelId, messageId});
if (!message) {
throw new UnknownMessageError();
}
const media = this.findMediaInMessage(message, attachmentId, embedIndex);
if (!media) {
throw InputValidationError.create('media', 'No valid media found in message');
}
const existingMemes = await this.favoriteMemeRepository.findByUserId(user.id);
if (media.contentHash) {
const duplicate = existingMemes.find((meme) => meme.contentHash === media.contentHash);
Logger.debug(
{
userId: user.id.toString(),
contentHash: media.contentHash,
source: 'pre-metadata',
duplicate: Boolean(duplicate),
channelId: channelId.toString(),
messageId: messageId.toString(),
},
'Favorite meme duplicate check (pre-metadata)',
);
if (duplicate) {
throw InputValidationError.create('media', 'This media is already in your favorite memes');
}
}
const metadata = await this.mediaService.getMetadata(
media.isExternal
? {
type: 'external',
url: media.url,
with_base64: true,
isNSFWAllowed: true,
}
: {
type: 's3',
bucket: Config.s3.buckets.cdn,
key: media.sourceKey,
with_base64: true,
isNSFWAllowed: true,
},
);
if (!metadata) {
throw new MediaMetadataError(media.isExternal ? 'external URL' : 'CDN');
}
const contentHash = media.contentHash ?? metadata.content_hash;
Logger.debug(
{
userId: user.id.toString(),
contentHash,
url: media.url,
source: 'post-metadata',
duplicate: existingMemes.some((meme) => meme.contentHash === contentHash),
channelId: channelId.toString(),
messageId: messageId.toString(),
},
'Favorite meme duplicate check (post-metadata)',
);
const fileData = Buffer.from(metadata.base64 ?? '', 'base64');
const updatedMetadata = media.isExternal
? {
contentType: metadata.content_type,
size: metadata.size,
width: metadata.width,
height: metadata.height,
duration: metadata.duration && metadata.duration > 0 ? metadata.duration : null,
}
: null;
const duplicate = existingMemes.find((meme) => meme.contentHash === contentHash);
if (duplicate) {
throw InputValidationError.create('media', 'This media is already in your favorite memes');
}
const memeId = createMemeID(this.snowflakeService.generate());
const userChannelId = userIdToChannelId(user.id);
const newAttachmentId = createAttachmentID(this.snowflakeService.generate());
const storageKey = makeAttachmentCdnKey(userChannelId, newAttachmentId, media.filename);
await this.storageService.uploadObject({
bucket: Config.s3.buckets.cdn,
key: storageKey,
body: fileData,
contentType: media.contentType,
});
const favoriteMeme = await this.favoriteMemeRepository.create({
user_id: user.id,
meme_id: memeId,
name: name.trim(),
alt_text: altText?.trim() || media.altText || null,
tags: tags || [],
attachment_id: newAttachmentId,
filename: media.filename,
content_type: updatedMetadata?.contentType ?? media.contentType,
content_hash: contentHash,
size: updatedMetadata ? BigInt(updatedMetadata.size) : media.size,
width: updatedMetadata?.width ?? media.width,
height: updatedMetadata?.height ?? media.height,
duration: updatedMetadata?.duration ?? media.duration,
is_gifv: media.isGifv,
tenor_id: null,
});
const responseData = mapFavoriteMemeToResponse(favoriteMeme);
await this.gatewayService.dispatchPresence({
userId: user.id,
event: 'FAVORITE_MEME_CREATE',
data: responseData,
});
Logger.debug({userId: user.id, memeId}, 'Created favorite meme');
return favoriteMeme;
}
async createFromUrl({
user,
url,
name,
altText,
tags,
isGifv = false,
tenorId,
}: {
user: User;
url: string;
name?: string | null;
altText?: string;
tags?: Array<string>;
isGifv?: boolean;
tenorId?: string;
}): Promise<FavoriteMeme> {
const count = await this.favoriteMemeRepository.count(user.id);
const maxMemes = user.isPremium() ? MAX_FAVORITE_MEMES_PREMIUM : MAX_FAVORITE_MEMES_NON_PREMIUM;
if (count >= maxMemes) {
throw new MaxFavoriteMemesError(user.isPremium());
}
const metadata = await this.mediaService.getMetadata({
type: 'external',
url,
with_base64: true,
isNSFWAllowed: true,
});
if (!metadata) {
throw new MediaMetadataError('URL');
}
let contentHash = metadata.content_hash;
const fileData = Buffer.from(metadata.base64 ?? '', 'base64');
if (tenorId) {
try {
const tenorUrl = `https://tenor.com/view/${tenorId}`;
const unfurled = await this.unfurlerService.unfurl(tenorUrl, true);
if (unfurled.length > 0 && unfurled[0].video?.content_hash) {
contentHash = unfurled[0].video.content_hash;
Logger.debug({tenorId, contentHash}, 'Using unfurled video content_hash for Tenor GIF');
}
} catch (error) {
Logger.warn({error, tenorId}, 'Failed to unfurl Tenor URL, using original content_hash');
}
}
const existingMemes = await this.favoriteMemeRepository.findByUserId(user.id);
const duplicate = existingMemes.find((meme) => meme.contentHash === contentHash);
if (duplicate) {
throw InputValidationError.create('media', 'This media is already in your favorite memes');
}
const filename = this.buildFilenameFromUrl(url, metadata.content_type);
const finalName = this.resolveFavoriteMemeName(name, filename);
const memeId = createMemeID(this.snowflakeService.generate());
const userChannelId = userIdToChannelId(user.id);
const newAttachmentId = createAttachmentID(this.snowflakeService.generate());
const storageKey = makeAttachmentCdnKey(userChannelId, newAttachmentId, filename);
await this.storageService.uploadObject({
bucket: Config.s3.buckets.cdn,
key: storageKey,
body: fileData,
contentType: metadata.content_type,
});
const favoriteMeme = await this.favoriteMemeRepository.create({
user_id: user.id,
meme_id: memeId,
name: finalName,
alt_text: altText?.trim() || null,
tags: tags || [],
attachment_id: newAttachmentId,
filename,
content_type: metadata.content_type,
content_hash: contentHash,
size: BigInt(metadata.size),
width: metadata.width || null,
height: metadata.height || null,
duration: metadata.duration && metadata.duration > 0 ? metadata.duration : null,
is_gifv: isGifv,
tenor_id: tenorId ?? null,
});
const responseData = mapFavoriteMemeToResponse(favoriteMeme);
await this.gatewayService.dispatchPresence({
userId: user.id,
event: 'FAVORITE_MEME_CREATE',
data: responseData,
});
Logger.debug({userId: user.id, memeId, url}, 'Created favorite meme from URL');
return favoriteMeme;
}
async update({
user,
memeId,
name,
altText,
tags,
}: {
user: User;
memeId: MemeID;
name?: string;
altText?: string | null;
tags?: Array<string>;
}): Promise<FavoriteMeme> {
const existingMeme = await this.favoriteMemeRepository.findById(user.id, memeId);
if (!existingMeme) {
throw new UnknownFavoriteMemeError();
}
const updatedRow = {
user_id: user.id,
meme_id: memeId,
name: name ?? existingMeme.name,
alt_text: altText !== undefined ? altText : existingMeme.altText,
tags: tags ?? existingMeme.tags,
attachment_id: existingMeme.attachmentId,
filename: existingMeme.filename,
content_type: existingMeme.contentType,
content_hash: existingMeme.contentHash,
size: existingMeme.size,
width: existingMeme.width,
height: existingMeme.height,
duration: existingMeme.duration,
is_gifv: existingMeme.isGifv,
tenor_id_str: existingMeme.tenorId,
version: existingMeme.version,
};
await this.favoriteMemeRepository.update(user.id, memeId, updatedRow);
const updatedMeme = new FavoriteMeme(updatedRow);
const responseData = mapFavoriteMemeToResponse(updatedMeme);
await this.gatewayService.dispatchPresence({
userId: user.id,
event: 'FAVORITE_MEME_UPDATE',
data: responseData,
});
Logger.debug({userId: user.id, memeId}, 'Updated favorite meme');
return updatedMeme;
}
async delete(userId: UserID, memeId: MemeID): Promise<void> {
const meme = await this.favoriteMemeRepository.findById(userId, memeId);
if (!meme) {
return;
}
try {
await this.storageService.deleteObject(Config.s3.buckets.cdn, meme.storageKey);
} catch (error) {
Logger.error({error, userId, memeId}, 'Failed to delete meme from storage');
}
await this.favoriteMemeRepository.delete(userId, memeId);
await this.gatewayService.dispatchPresence({
userId,
event: 'FAVORITE_MEME_DELETE',
data: {meme_id: memeId.toString()},
});
Logger.debug({userId, memeId}, 'Deleted favorite meme');
}
async getFavoriteMeme(userId: UserID, memeId: MemeID): Promise<FavoriteMeme | null> {
return this.favoriteMemeRepository.findById(userId, memeId);
}
async listFavoriteMemes(userId: UserID): Promise<Array<FavoriteMeme>> {
return this.favoriteMemeRepository.findByUserId(userId);
}
private buildFilenameFromUrl(url: string, contentType: string): string {
const extension = mime.getExtension(contentType) || 'bin';
try {
const urlPath = new URL(url).pathname;
const urlFilename = urlPath.split('/').pop() || 'media';
return urlFilename.includes('.') ? urlFilename : `${urlFilename}.${extension}`;
} catch {
return `media.${extension}`;
}
}
private resolveFavoriteMemeName(name: string | undefined | null, fallbackFilename: string): string {
const normalizedInput = typeof name === 'string' ? name.trim() : '';
const fallbackName = fallbackFilename.trim() || 'favorite meme';
const candidate = normalizedInput.length > 0 ? normalizedInput : fallbackName;
const finalName = candidate.slice(0, 100);
if (finalName.length === 0) {
throw InputValidationError.create('name', 'Favorite meme name is required');
}
return finalName;
}
private findMediaInMessage(
message: Message,
preferredAttachmentId?: string,
preferredEmbedIndex?: number,
): {
isExternal: boolean;
url: string;
sourceKey: string;
filename: string;
contentType: string;
size: bigint;
width: number | null;
height: number | null;
duration: number | null;
altText: string | null;
isGifv: boolean;
contentHash: string | null;
} | null {
if (preferredEmbedIndex !== undefined) {
if (preferredEmbedIndex < 0 || preferredEmbedIndex >= message.embeds.length) {
throw InputValidationError.create(
'embed_index',
`Embed index ${preferredEmbedIndex} is out of bounds (message has ${message.embeds.length} embed(s))`,
);
}
const embed = message.embeds[preferredEmbedIndex];
const media = embed.image || embed.video || embed.thumbnail;
if (media?.url) {
const filename = this.extractFilenameFromUrl(media.url) || `embed_${preferredEmbedIndex}`;
const contentType = media.contentType ?? mime.getType(filename) ?? 'application/octet-stream';
if (this.isValidMediaType(contentType)) {
const isExternal = !this.isInternalCDNUrl(media.url);
const isGifv = embed.type === 'gifv';
return {
isExternal,
url: media.url,
sourceKey: isExternal ? '' : this.extractStorageKeyFromUrl(media.url) || '',
filename,
contentType,
size: BigInt(0),
width: media.width ?? null,
height: media.height ?? null,
duration: null,
altText: null,
isGifv,
contentHash: media.contentHash ?? null,
};
}
}
return null;
}
if (message.attachments.length > 0) {
let attachment: (typeof message.attachments)[0] | undefined;
if (preferredAttachmentId) {
attachment = message.attachments.find((a) => a.id.toString() === preferredAttachmentId);
if (!attachment) {
throw InputValidationError.create(
'preferred_attachment_id',
`Attachment with ID ${preferredAttachmentId} not found in message`,
);
}
} else {
attachment = message.attachments[0];
}
if (attachment && this.isValidMediaType(attachment.contentType)) {
const isGifv = attachment.contentType === 'image/gif';
return {
isExternal: false,
url: makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename),
sourceKey: makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename),
filename: attachment.filename,
contentType: attachment.contentType,
size: attachment.size,
width: attachment.width ?? null,
height: attachment.height ?? null,
duration: attachment.duration ?? null,
altText: attachment.description ?? null,
isGifv,
contentHash: attachment.contentHash ?? null,
};
}
}
for (const embed of message.embeds) {
const media = embed.image || embed.video || embed.thumbnail;
if (media?.url) {
const filename = this.extractFilenameFromUrl(media.url) || 'media';
const contentType = media.contentType ?? mime.getType(filename) ?? 'application/octet-stream';
if (this.isValidMediaType(contentType)) {
const isExternal = !this.isInternalCDNUrl(media.url);
const isGifv = embed.type === 'gifv';
return {
isExternal,
url: media.url,
sourceKey: isExternal ? '' : this.extractStorageKeyFromUrl(media.url) || '',
filename,
contentType,
size: BigInt(0),
width: media.width ?? null,
height: media.height ?? null,
duration: null,
altText: null,
isGifv,
contentHash: media.contentHash ?? null,
};
}
}
}
return null;
}
private isInternalCDNUrl(url: string): boolean {
return url.startsWith(`${Config.endpoints.media}/`);
}
private isValidMediaType(contentType: string): boolean {
return contentType.startsWith('image/') || contentType.startsWith('video/') || contentType.startsWith('audio/');
}
private extractFilenameFromUrl(url: string): string | null {
try {
const urlObj = new URL(url);
const parts = urlObj.pathname.split('/');
return parts[parts.length - 1] || null;
} catch {
return null;
}
}
private extractStorageKeyFromUrl(url: string): string | null {
try {
const urlObj = new URL(url);
return urlObj.pathname.substring(1);
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {AttachmentID, MemeID, UserID} from '~/BrandedTypes';
import type {FavoriteMeme} from '~/Models';
export interface CreateFavoriteMemeParams {
user_id: UserID;
meme_id: MemeID;
name: string;
alt_text?: string | null;
tags?: Array<string>;
attachment_id: AttachmentID;
filename: string;
content_type: string;
content_hash?: string | null;
size: bigint;
width?: number | null;
height?: number | null;
duration?: number | null;
is_gifv?: boolean;
tenor_id?: string | null;
}
export abstract class IFavoriteMemeRepository {
abstract create(data: CreateFavoriteMemeParams): Promise<FavoriteMeme>;
abstract findById(userId: UserID, memeId: MemeID): Promise<FavoriteMeme | null>;
abstract findByUserId(userId: UserID): Promise<Array<FavoriteMeme>>;
abstract update(userId: UserID, memeId: MemeID, data: CreateFavoriteMemeParams): Promise<void>;
abstract delete(userId: UserID, memeId: MemeID): Promise<void>;
abstract deleteAllByUserId(userId: UserID): Promise<void>;
abstract count(userId: UserID): Promise<number>;
}