initial commit
This commit is contained in:
155
fluxer_api/src/favorite_meme/FavoriteMemeController.ts
Normal file
155
fluxer_api/src/favorite_meme/FavoriteMemeController.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
};
|
||||
109
fluxer_api/src/favorite_meme/FavoriteMemeModel.ts
Normal file
109
fluxer_api/src/favorite_meme/FavoriteMemeModel.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
129
fluxer_api/src/favorite_meme/FavoriteMemeRepository.ts
Normal file
129
fluxer_api/src/favorite_meme/FavoriteMemeRepository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
562
fluxer_api/src/favorite_meme/FavoriteMemeService.ts
Normal file
562
fluxer_api/src/favorite_meme/FavoriteMemeService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
49
fluxer_api/src/favorite_meme/IFavoriteMemeRepository.ts
Normal file
49
fluxer_api/src/favorite_meme/IFavoriteMemeRepository.ts
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user