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,32 @@
/*
* 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 {GuildID, UserID} from '~/BrandedTypes';
import type {PackType} from './PackRepository';
export type PackExpressionAccessResolution = 'accessible' | 'not-accessible' | 'not-pack';
export interface PackExpressionAccessResolver {
resolve(packId: GuildID): Promise<PackExpressionAccessResolution>;
}
export interface PackExpressionAccessResolverParams {
userId: UserID | null;
type: PackType;
}

View File

@@ -0,0 +1,60 @@
/*
* 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 {ExpressionPack} from '~/models/ExpressionPack';
import type {PackType} from './PackRepository';
export interface PackSummary {
id: string;
name: string;
description: string | null;
type: PackType;
creator_id: string;
created_at: string;
updated_at: string;
installed_at?: string;
}
export interface PackDashboardSection {
installed_limit: number;
created_limit: number;
installed: Array<PackSummary>;
created: Array<PackSummary>;
}
export interface PackDashboardResponse {
emoji: PackDashboardSection;
sticker: PackDashboardSection;
}
export const mapPackToSummary = (pack: ExpressionPack, installedAt?: Date | null): PackSummary => {
const summary: PackSummary = {
id: pack.id.toString(),
name: pack.name,
description: pack.description,
type: pack.type,
creator_id: pack.creatorId.toString(),
created_at: pack.createdAt.toISOString(),
updated_at: pack.updatedAt.toISOString(),
};
if (installedAt) {
summary.installed_at = installedAt.toISOString();
}
return summary;
};

View File

@@ -0,0 +1,138 @@
/*
* 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 {GuildID, UserID} from '~/BrandedTypes';
import {BatchBuilder, buildPatchFromData, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import {EXPRESSION_PACK_COLUMNS, type ExpressionPackRow, type PackInstallationRow} from '~/database/CassandraTypes';
import {ExpressionPack} from '~/models/ExpressionPack';
import {ExpressionPacks, ExpressionPacksByCreator, PackInstallations} from '~/Tables';
export type PackType = ExpressionPack['type'];
const FETCH_EXPRESSION_PACK_BY_ID_QUERY = ExpressionPacks.selectCql({
where: ExpressionPacks.where.eq('pack_id'),
limit: 1,
});
const FETCH_EXPRESSION_PACKS_BY_CREATOR_QUERY = ExpressionPacksByCreator.selectCql({
where: ExpressionPacksByCreator.where.eq('creator_id'),
});
const FETCH_PACK_INSTALLATIONS_BY_USER_QUERY = PackInstallations.selectCql({
where: PackInstallations.where.eq('user_id'),
});
const FETCH_PACK_INSTALLATION_QUERY = PackInstallations.selectCql({
where: [PackInstallations.where.eq('user_id'), PackInstallations.where.eq('pack_id')],
limit: 1,
});
export class PackRepository {
async getPack(packId: GuildID): Promise<ExpressionPack | null> {
const row = await fetchOne<ExpressionPackRow>(FETCH_EXPRESSION_PACK_BY_ID_QUERY, {pack_id: packId});
return row ? new ExpressionPack(row) : null;
}
async listPacksByCreator(creatorId: UserID, packType?: PackType): Promise<Array<ExpressionPack>> {
const rows = await fetchMany<ExpressionPackRow>(FETCH_EXPRESSION_PACKS_BY_CREATOR_QUERY, {creator_id: creatorId});
return rows.filter((row) => (packType ? row.pack_type === packType : true)).map((row) => new ExpressionPack(row));
}
async countPacksByCreator(creatorId: UserID, packType: PackType): Promise<number> {
const rows = await fetchMany<ExpressionPackRow>(FETCH_EXPRESSION_PACKS_BY_CREATOR_QUERY, {creator_id: creatorId});
return rows.filter((row) => row.pack_type === packType).length;
}
async upsertPack(data: ExpressionPackRow): Promise<ExpressionPack> {
const packId = data.pack_id;
const previousPack = await this.getPack(packId);
const result = await executeVersionedUpdate<ExpressionPackRow, 'pack_id'>(
async () => {
const existing = await fetchOne<ExpressionPackRow>(FETCH_EXPRESSION_PACK_BY_ID_QUERY, {
pack_id: packId,
});
return existing ?? null;
},
(current) => ({
pk: {pack_id: packId},
patch: buildPatchFromData(data, current, EXPRESSION_PACK_COLUMNS, ['pack_id']),
}),
ExpressionPacks,
{onFailure: 'log'},
);
const batch = new BatchBuilder();
if (previousPack && previousPack.creatorId !== data.creator_id) {
batch.addPrepared(
ExpressionPacksByCreator.deleteByPk({
creator_id: previousPack.creatorId,
pack_id: packId,
}),
);
}
const finalPack = await this.getPack(packId);
if (finalPack) {
const finalRow = finalPack.toRow();
finalRow.version = result.finalVersion ?? finalRow.version;
batch.addPrepared(ExpressionPacksByCreator.insert(finalRow));
}
await batch.execute();
return finalPack ?? new ExpressionPack({...data, version: result.finalVersion ?? 1});
}
async deletePack(packId: GuildID): Promise<void> {
const pack = await this.getPack(packId);
const batch = new BatchBuilder().addPrepared(ExpressionPacks.deleteByPk({pack_id: packId}));
if (pack) {
batch.addPrepared(
ExpressionPacksByCreator.deleteByPk({
creator_id: pack.creatorId,
pack_id: packId,
}),
);
}
await batch.execute();
}
async listInstallations(userId: UserID): Promise<Array<PackInstallationRow>> {
return await fetchMany<PackInstallationRow>(FETCH_PACK_INSTALLATIONS_BY_USER_QUERY, {user_id: userId});
}
async addInstallation(data: PackInstallationRow): Promise<void> {
await fetchOne(PackInstallations.insert(data));
}
async removeInstallation(userId: UserID, packId: GuildID): Promise<void> {
await PackInstallations.deleteByPk({
user_id: userId,
pack_id: packId,
});
}
async hasInstallation(userId: UserID, packId: GuildID): Promise<boolean> {
const row = await fetchOne<PackInstallationRow>(FETCH_PACK_INSTALLATION_QUERY, {
user_id: userId,
pack_id: packId,
});
return row !== null;
}
}

View File

@@ -0,0 +1,630 @@
/*
* 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 {EmojiID, GuildID, StickerID, UserID} from '~/BrandedTypes';
import {createEmojiID, createGuildID, createStickerID} from '~/BrandedTypes';
import {
MAX_CREATED_PACKS_NON_PREMIUM,
MAX_CREATED_PACKS_PREMIUM,
MAX_INSTALLED_PACKS_NON_PREMIUM,
MAX_INSTALLED_PACKS_PREMIUM,
MAX_PACK_EXPRESSIONS,
} from '~/Constants';
import {FeatureFlags} from '~/constants/FeatureFlags';
import {FeatureTemporarilyDisabledError} from '~/errors/FeatureTemporarilyDisabledError';
import {InvalidPackTypeError} from '~/errors/InvalidPackTypeError';
import {MaxPackExpressionsError} from '~/errors/MaxPackExpressionsError';
import {MaxPackLimitError} from '~/errors/MaxPackLimitError';
import {PackAccessDeniedError} from '~/errors/PackAccessDeniedError';
import {PremiumRequiredError} from '~/errors/PremiumRequiredError';
import {UnknownGuildEmojiError} from '~/errors/UnknownGuildEmojiError';
import {UnknownGuildStickerError} from '~/errors/UnknownGuildStickerError';
import {UnknownPackError} from '~/errors/UnknownPackError';
import type {FeatureFlagService} from '~/feature_flag/FeatureFlagService';
import {
type GuildEmojiResponse,
type GuildEmojiWithUserResponse,
type GuildStickerResponse,
type GuildStickerWithUserResponse,
mapGuildEmojisWithUsersToResponse,
mapGuildEmojiToResponse,
mapGuildStickersWithUsersToResponse,
mapGuildStickerToResponse,
} from '~/guild/GuildModel';
import type {IGuildRepository} from '~/guild/IGuildRepository';
import type {ExpressionAssetPurger} from '~/guild/services/content/ExpressionAssetPurger';
import type {AvatarService} from '~/infrastructure/AvatarService';
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {User} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {ExpressionPack} from '~/models/ExpressionPack';
import type {IUserRepository} from '~/user/IUserRepository';
import type {
PackExpressionAccessResolution,
PackExpressionAccessResolver,
PackExpressionAccessResolverParams,
} from './PackExpressionAccessResolver';
import {mapPackToSummary, type PackDashboardResponse, type PackDashboardSection} from './PackModel';
import type {PackRepository, PackType} from './PackRepository';
export class PackService {
constructor(
private readonly packRepository: PackRepository,
private readonly guildRepository: IGuildRepository,
private readonly avatarService: AvatarService,
private readonly snowflakeService: SnowflakeService,
private readonly assetPurger: ExpressionAssetPurger,
private readonly userRepository: IUserRepository,
private readonly userCacheService: UserCacheService,
private readonly featureFlagService?: FeatureFlagService,
) {}
private async requireExpressionPackAccess(userId: UserID): Promise<void> {
if (!this.featureFlagService) {
return;
}
const hasAccess = await this.featureFlagService.isFeatureEnabledForUser(FeatureFlags.EXPRESSION_PACKS, userId, () =>
this.userRepository.getUserGuildIds(userId),
);
if (!hasAccess) {
throw new FeatureTemporarilyDisabledError();
}
}
private async isPremium(userId: UserID): Promise<boolean> {
const user = await this.userRepository.findUnique(userId);
return user?.canUseGlobalExpressions() ?? false;
}
private getCreatedLimit(isPremium: boolean): number {
return isPremium ? MAX_CREATED_PACKS_PREMIUM : MAX_CREATED_PACKS_NON_PREMIUM;
}
private getInstalledLimit(isPremium: boolean): number {
return isPremium ? MAX_INSTALLED_PACKS_PREMIUM : MAX_INSTALLED_PACKS_NON_PREMIUM;
}
private async ensurePackOwner(userId: UserID, packId: GuildID): Promise<ExpressionPack> {
const pack = await this.packRepository.getPack(packId);
if (!pack) {
throw new UnknownPackError();
}
if (pack.creatorId !== userId) {
throw new PackAccessDeniedError();
}
return pack;
}
private async collectInstalledPacks(userId: UserID): Promise<Array<{pack: ExpressionPack; installedAt: Date}>> {
const installations = await this.packRepository.listInstallations(userId);
const results: Array<{pack: ExpressionPack; installedAt: Date}> = [];
for (const row of installations) {
const pack = await this.packRepository.getPack(row.pack_id);
if (!pack) {
await this.packRepository.removeInstallation(userId, row.pack_id);
continue;
}
results.push({pack, installedAt: row.installed_at});
}
return results;
}
private async requirePremium(userId: UserID, message: string): Promise<void> {
if (!(await this.isPremium(userId))) {
throw new PremiumRequiredError(message);
}
}
private async getInstalledPackIdsByType(userId: UserID, packType: PackType): Promise<Set<GuildID>> {
const installations = await this.packRepository.listInstallations(userId);
return new Set(installations.filter((row) => row.pack_type === packType).map((row) => row.pack_id));
}
private buildPackExpressionAccessResolution(
userId: UserID,
packType: PackType,
pack: ExpressionPack | null,
): PackExpressionAccessResolution {
if (!pack) {
return 'not-pack';
}
if (pack.type !== packType) {
return 'not-pack';
}
return pack.creatorId === userId ? 'accessible' : 'not-accessible';
}
async createPackExpressionAccessResolver(
params: PackExpressionAccessResolverParams,
): Promise<PackExpressionAccessResolver> {
const {userId, type} = params;
if (!userId) {
return {
resolve: async () => 'not-pack',
};
}
const installedPackIds = await this.getInstalledPackIdsByType(userId, type);
const resolutionCache = new Map<GuildID, PackExpressionAccessResolution>();
return {
resolve: async (packId: GuildID) => {
if (installedPackIds.has(packId)) {
resolutionCache.set(packId, 'accessible');
return 'accessible';
}
const cached = resolutionCache.get(packId);
if (cached) {
return cached;
}
const pack = await this.packRepository.getPack(packId);
const resolution = this.buildPackExpressionAccessResolution(userId, type, pack);
resolutionCache.set(packId, resolution);
return resolution;
},
};
}
async listUserPacks(userId: UserID): Promise<PackDashboardResponse> {
await this.requireExpressionPackAccess(userId);
const isPremium = await this.isPremium(userId);
const createdEmoji = await this.packRepository.listPacksByCreator(userId, 'emoji');
const createdSticker = await this.packRepository.listPacksByCreator(userId, 'sticker');
const installations = await this.collectInstalledPacks(userId);
const installedEmoji = installations
.filter((entry) => entry.pack.type === 'emoji')
.map((entry) => mapPackToSummary(entry.pack, entry.installedAt));
const installedSticker = installations
.filter((entry) => entry.pack.type === 'sticker')
.map((entry) => mapPackToSummary(entry.pack, entry.installedAt));
const emojiSection: PackDashboardSection = {
installed_limit: this.getInstalledLimit(isPremium),
created_limit: this.getCreatedLimit(isPremium),
installed: installedEmoji,
created: createdEmoji.map((pack) => mapPackToSummary(pack)),
};
const stickerSection: PackDashboardSection = {
installed_limit: this.getInstalledLimit(isPremium),
created_limit: this.getCreatedLimit(isPremium),
installed: installedSticker,
created: createdSticker.map((pack) => mapPackToSummary(pack)),
};
return {
emoji: emojiSection,
sticker: stickerSection,
};
}
async createPack(params: {
user: User;
type: PackType;
name: string;
description?: string | null;
}): Promise<ExpressionPack> {
await this.requireExpressionPackAccess(params.user.id);
await this.requirePremium(params.user.id, 'Premium required to create emoji or sticker packs');
const createdCount = await this.packRepository.countPacksByCreator(params.user.id, params.type);
const limit = this.getCreatedLimit(true);
if (createdCount >= limit) {
throw new MaxPackLimitError(params.type, limit, 'create');
}
const now = new Date();
const packId = createGuildID(this.snowflakeService.generate());
return await this.packRepository.upsertPack({
pack_id: packId,
pack_type: params.type,
creator_id: params.user.id,
name: params.name,
description: params.description ?? null,
created_at: now,
updated_at: now,
version: 1,
});
}
async updatePack(params: {
userId: UserID;
packId: GuildID;
name?: string;
description?: string | null;
}): Promise<ExpressionPack> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
const now = new Date();
const updatedPack = new ExpressionPack({
...pack.toRow(),
name: params.name ?? pack.name,
description: params.description === undefined ? pack.description : params.description,
updated_at: now,
});
return await this.packRepository.upsertPack(updatedPack.toRow());
}
async deletePack(userId: UserID, packId: GuildID): Promise<void> {
await this.requireExpressionPackAccess(userId);
await this.ensurePackOwner(userId, packId);
await this.packRepository.deletePack(packId);
}
async installPack(userId: UserID, packId: GuildID): Promise<void> {
await this.requireExpressionPackAccess(userId);
const pack = await this.packRepository.getPack(packId);
if (!pack) {
throw new UnknownPackError();
}
const alreadyInstalled = await this.packRepository.hasInstallation(userId, packId);
if (alreadyInstalled) {
return;
}
await this.requirePremium(userId, 'Premium required to install emoji or sticker packs');
const limit = this.getInstalledLimit(true);
const installations = await this.collectInstalledPacks(userId);
const typeCount = installations.filter((entry) => entry.pack.type === pack.type).length;
if (typeCount >= limit) {
throw new MaxPackLimitError(pack.type, limit, 'install');
}
await this.packRepository.addInstallation({
user_id: userId,
pack_id: packId,
pack_type: pack.type,
installed_at: new Date(),
});
}
async uninstallPack(userId: UserID, packId: GuildID): Promise<void> {
await this.requireExpressionPackAccess(userId);
await this.packRepository.removeInstallation(userId, packId);
}
async getInstalledPackIds(userId: UserID): Promise<Set<GuildID>> {
await this.requireExpressionPackAccess(userId);
const installations = await this.collectInstalledPacks(userId);
return new Set(installations.map((entry) => entry.pack.id));
}
async getPackEmojis(params: {
userId: UserID;
packId: GuildID;
requestCache: RequestCache;
}): Promise<Array<GuildEmojiWithUserResponse>> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
const emojis = await this.guildRepository.listEmojis(pack.id);
return await mapGuildEmojisWithUsersToResponse(emojis, this.userCacheService, params.requestCache);
}
async getPackStickers(params: {
userId: UserID;
packId: GuildID;
requestCache: RequestCache;
}): Promise<Array<GuildStickerWithUserResponse>> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
const stickers = await this.guildRepository.listStickers(pack.id);
return await mapGuildStickersWithUsersToResponse(stickers, this.userCacheService, params.requestCache);
}
async createPackEmoji(params: {
user: User;
packId: GuildID;
name: string;
image: string;
}): Promise<GuildEmojiResponse> {
await this.requireExpressionPackAccess(params.user.id);
await this.requirePremium(params.user.id, 'Premium required to add emojis to packs');
const pack = await this.ensurePackOwner(params.user.id, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
const emojiCount = await this.guildRepository.countEmojis(pack.id);
if (emojiCount >= MAX_PACK_EXPRESSIONS) {
throw new MaxPackExpressionsError(MAX_PACK_EXPRESSIONS);
}
const {animated, imageBuffer} = await this.avatarService.processEmoji({
errorPath: 'image',
base64Image: params.image,
});
const emojiId = createEmojiID(this.snowflakeService.generate());
await this.avatarService.uploadEmoji({prefix: 'emojis', emojiId, imageBuffer});
const emoji = await this.guildRepository.upsertEmoji({
guild_id: pack.id,
emoji_id: emojiId,
name: params.name,
creator_id: params.user.id,
animated,
version: 1,
});
return mapGuildEmojiToResponse(emoji);
}
async bulkCreatePackEmojis(params: {
user: User;
packId: GuildID;
emojis: Array<{name: string; image: string}>;
}): Promise<{success: Array<GuildEmojiResponse>; failed: Array<{name: string; error: string}>}> {
await this.requireExpressionPackAccess(params.user.id);
await this.requirePremium(params.user.id, 'Premium required to add emojis to packs');
const pack = await this.ensurePackOwner(params.user.id, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
let emojiCount = await this.guildRepository.countEmojis(pack.id);
const success: Array<GuildEmojiResponse> = [];
const failed: Array<{name: string; error: string}> = [];
for (const emojiData of params.emojis) {
if (emojiCount >= MAX_PACK_EXPRESSIONS) {
failed.push({name: emojiData.name, error: 'Pack expression limit reached'});
continue;
}
try {
const {animated, imageBuffer} = await this.avatarService.processEmoji({
errorPath: `emojis[${success.length + failed.length}].image`,
base64Image: emojiData.image,
});
const emojiId = createEmojiID(this.snowflakeService.generate());
await this.avatarService.uploadEmoji({prefix: 'emojis', emojiId, imageBuffer});
const emoji = await this.guildRepository.upsertEmoji({
guild_id: pack.id,
emoji_id: emojiId,
name: emojiData.name,
creator_id: params.user.id,
animated,
version: 1,
});
success.push(mapGuildEmojiToResponse(emoji));
emojiCount += 1;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
failed.push({name: emojiData.name, error: message});
}
}
return {success, failed};
}
async updatePackEmoji(params: {
userId: UserID;
packId: GuildID;
emojiId: EmojiID;
name: string;
}): Promise<GuildEmojiResponse> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
const emojis = await this.guildRepository.listEmojis(pack.id);
const emoji = emojis.find((entry) => entry.id === params.emojiId);
if (!emoji) {
throw new UnknownGuildEmojiError();
}
const updatedEmoji = await this.guildRepository.upsertEmoji({
...emoji.toRow(),
name: params.name,
});
return mapGuildEmojiToResponse(updatedEmoji);
}
async deletePackEmoji(params: {userId: UserID; packId: GuildID; emojiId: EmojiID; purge?: boolean}): Promise<void> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
const emojis = await this.guildRepository.listEmojis(pack.id);
const emoji = emojis.find((entry) => entry.id === params.emojiId);
if (!emoji) {
throw new UnknownGuildEmojiError();
}
await this.guildRepository.deleteEmoji(pack.id, params.emojiId);
if (params.purge) {
await this.assetPurger.purgeEmoji(emoji.id.toString());
}
}
async createPackSticker(params: {
user: User;
packId: GuildID;
name: string;
description?: string | null;
tags: Array<string>;
image: string;
}): Promise<GuildStickerResponse> {
await this.requireExpressionPackAccess(params.user.id);
await this.requirePremium(params.user.id, 'Premium required to add stickers to packs');
const pack = await this.ensurePackOwner(params.user.id, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
const stickerCount = await this.guildRepository.countStickers(pack.id);
if (stickerCount >= MAX_PACK_EXPRESSIONS) {
throw new MaxPackExpressionsError(MAX_PACK_EXPRESSIONS);
}
const {formatType, imageBuffer} = await this.avatarService.processSticker({
errorPath: 'image',
base64Image: params.image,
});
const stickerId = createStickerID(this.snowflakeService.generate());
await this.avatarService.uploadSticker({prefix: 'stickers', stickerId, imageBuffer});
const sticker = await this.guildRepository.upsertSticker({
guild_id: pack.id,
sticker_id: stickerId,
name: params.name,
description: params.description ?? null,
tags: params.tags,
format_type: formatType,
creator_id: params.user.id,
version: 1,
});
return mapGuildStickerToResponse(sticker);
}
async bulkCreatePackStickers(params: {
user: User;
packId: GuildID;
stickers: Array<{name: string; description?: string | null; tags: Array<string>; image: string}>;
}): Promise<{success: Array<GuildStickerResponse>; failed: Array<{name: string; error: string}>}> {
await this.requireExpressionPackAccess(params.user.id);
await this.requirePremium(params.user.id, 'Premium required to add stickers to packs');
const pack = await this.ensurePackOwner(params.user.id, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
let stickerCount = await this.guildRepository.countStickers(pack.id);
const success: Array<GuildStickerResponse> = [];
const failed: Array<{name: string; error: string}> = [];
for (const stickerData of params.stickers) {
if (stickerCount >= MAX_PACK_EXPRESSIONS) {
failed.push({name: stickerData.name, error: 'Pack expression limit reached'});
continue;
}
try {
const {formatType, imageBuffer} = await this.avatarService.processSticker({
errorPath: `stickers[${success.length + failed.length}].image`,
base64Image: stickerData.image,
});
const stickerId = createStickerID(this.snowflakeService.generate());
await this.avatarService.uploadSticker({prefix: 'stickers', stickerId, imageBuffer});
const sticker = await this.guildRepository.upsertSticker({
guild_id: pack.id,
sticker_id: stickerId,
name: stickerData.name,
description: stickerData.description ?? null,
tags: stickerData.tags,
format_type: formatType,
creator_id: params.user.id,
version: 1,
});
success.push(mapGuildStickerToResponse(sticker));
stickerCount += 1;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
failed.push({name: stickerData.name, error: message});
}
}
return {success, failed};
}
async updatePackSticker(params: {
userId: UserID;
packId: GuildID;
stickerId: StickerID;
name: string;
description?: string | null;
tags: Array<string>;
}): Promise<GuildStickerResponse> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
const stickers = await this.guildRepository.listStickers(pack.id);
const sticker = stickers.find((entry) => entry.id === params.stickerId);
if (!sticker) {
throw new UnknownGuildStickerError();
}
const updatedSticker = await this.guildRepository.upsertSticker({
...sticker.toRow(),
name: params.name,
description: params.description ?? null,
tags: params.tags,
});
return mapGuildStickerToResponse(updatedSticker);
}
async deletePackSticker(params: {
userId: UserID;
packId: GuildID;
stickerId: StickerID;
purge?: boolean;
}): Promise<void> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
const stickers = await this.guildRepository.listStickers(pack.id);
const sticker = stickers.find((entry) => entry.id === params.stickerId);
if (!sticker) {
throw new UnknownGuildStickerError();
}
await this.guildRepository.deleteSticker(pack.id, params.stickerId);
if (params.purge) {
await this.assetPurger.purgeSticker(sticker.id.toString());
}
}
}

View File

@@ -0,0 +1,128 @@
/*
* 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 {createGuildID} from '~/BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {createStringType, Int64Type, z} from '~/Schema';
import {Validator} from '~/Validator';
import {mapPackToSummary, type PackDashboardResponse} from '../PackModel';
import type {PackType} from '../PackRepository';
const PACK_TYPE_PARAM_SCHEMA = z.object({pack_type: z.enum(['emoji', 'sticker'])});
const PackCreateRequest = z.object({
name: createStringType(1, 64),
description: createStringType(1, 256).nullish(),
});
const PackUpdateRequest = z.object({
name: createStringType(1, 64).optional(),
description: createStringType(1, 256).nullish().optional(),
});
export const PackController = (app: HonoApp) => {
app.get('/packs', RateLimitMiddleware(RateLimitConfigs.PACKS_LIST), LoginRequired, DefaultUserOnly, async (ctx) => {
const userId = ctx.get('user').id;
const response: PackDashboardResponse = await ctx.get('packService').listUserPacks(userId);
return ctx.json(response);
});
app.post(
'/packs/:pack_type',
RateLimitMiddleware(RateLimitConfigs.PACKS_CREATE),
LoginRequired,
DefaultUserOnly,
Validator('param', PACK_TYPE_PARAM_SCHEMA),
Validator('json', PackCreateRequest),
async (ctx) => {
const {pack_type} = ctx.req.valid('param');
const {name, description} = ctx.req.valid('json');
const user = ctx.get('user');
const pack = await ctx.get('packService').createPack({
user,
type: pack_type as PackType,
name,
description: description ?? null,
});
return ctx.json(mapPackToSummary(pack));
},
);
app.patch(
'/packs/:pack_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_UPDATE),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({pack_id: Int64Type})),
Validator('json', PackUpdateRequest),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
const {name, description} = ctx.req.valid('json');
const updated = await ctx.get('packService').updatePack({
userId: ctx.get('user').id,
packId,
name,
description,
});
return ctx.json(mapPackToSummary(updated));
},
);
app.delete(
'/packs/:pack_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_DELETE),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({pack_id: Int64Type})),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
await ctx.get('packService').deletePack(ctx.get('user').id, packId);
return ctx.body(null, 204);
},
);
app.post(
'/packs/:pack_id/install',
RateLimitMiddleware(RateLimitConfigs.PACKS_INSTALL),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({pack_id: Int64Type})),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
await ctx.get('packService').installPack(ctx.get('user').id, packId);
return ctx.body(null, 204);
},
);
app.delete(
'/packs/:pack_id/install',
RateLimitMiddleware(RateLimitConfigs.PACKS_INSTALL),
LoginRequired,
DefaultUserOnly,
Validator('param', z.object({pack_id: Int64Type})),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
await ctx.get('packService').uninstallPack(ctx.get('user').id, packId);
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,108 @@
/*
* 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 {createEmojiID, createGuildID} from '~/BrandedTypes';
import {GuildEmojiBulkCreateRequest, GuildEmojiCreateRequest, GuildEmojiUpdateRequest} from '~/guild/GuildModel';
import {LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, QueryBooleanType, z} from '~/Schema';
import {Validator} from '~/Validator';
export const PackEmojiController = (app: HonoApp) => {
app.post(
'/packs/emojis/:pack_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_EMOJI_CREATE),
LoginRequired,
Validator('param', z.object({pack_id: Int64Type})),
Validator('json', GuildEmojiCreateRequest),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
const {name, image} = ctx.req.valid('json');
const user = ctx.get('user');
return ctx.json(await ctx.get('packService').createPackEmoji({user, packId, name, image}));
},
);
app.post(
'/packs/emojis/:pack_id/bulk',
RateLimitMiddleware(RateLimitConfigs.PACKS_EMOJI_BULK_CREATE),
LoginRequired,
Validator('param', z.object({pack_id: Int64Type})),
Validator('json', GuildEmojiBulkCreateRequest),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
const {emojis} = ctx.req.valid('json');
const user = ctx.get('user');
return ctx.json(await ctx.get('packService').bulkCreatePackEmojis({user, packId, emojis}));
},
);
app.get(
'/packs/emojis/:pack_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_EMOJIS_LIST),
LoginRequired,
Validator('param', z.object({pack_id: Int64Type})),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
const userId = ctx.get('user').id;
const requestCache = ctx.get('requestCache');
return ctx.json(await ctx.get('packService').getPackEmojis({userId, packId, requestCache}));
},
);
app.patch(
'/packs/emojis/:pack_id/:emoji_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_EMOJI_UPDATE),
LoginRequired,
Validator('param', z.object({pack_id: Int64Type, emoji_id: Int64Type})),
Validator('json', GuildEmojiUpdateRequest),
async (ctx) => {
const {pack_id, emoji_id} = ctx.req.valid('param');
const packId = createGuildID(pack_id);
const emojiId = createEmojiID(emoji_id);
const {name} = ctx.req.valid('json');
return ctx.json(
await ctx.get('packService').updatePackEmoji({userId: ctx.get('user').id, packId, emojiId, name}),
);
},
);
app.delete(
'/packs/emojis/:pack_id/:emoji_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_EMOJI_DELETE),
LoginRequired,
Validator('param', z.object({pack_id: Int64Type, emoji_id: Int64Type})),
Validator('query', z.object({purge: QueryBooleanType.optional()})),
async (ctx) => {
const {pack_id, emoji_id} = ctx.req.valid('param');
const packId = createGuildID(pack_id);
const emojiId = createEmojiID(emoji_id);
const purge = ctx.req.valid('query').purge ?? false;
await ctx.get('packService').deletePackEmoji({
userId: ctx.get('user').id,
packId,
emojiId,
purge,
});
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,110 @@
/*
* 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 {createGuildID, createStickerID} from '~/BrandedTypes';
import {GuildStickerBulkCreateRequest, GuildStickerCreateRequest, GuildStickerUpdateRequest} from '~/guild/GuildModel';
import {LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type, QueryBooleanType, z} from '~/Schema';
import {Validator} from '~/Validator';
export const PackStickerController = (app: HonoApp) => {
app.post(
'/packs/stickers/:pack_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_STICKER_CREATE),
LoginRequired,
Validator('param', z.object({pack_id: Int64Type})),
Validator('json', GuildStickerCreateRequest),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
const {name, description, tags, image} = ctx.req.valid('json');
const user = ctx.get('user');
return ctx.json(await ctx.get('packService').createPackSticker({user, packId, name, description, tags, image}));
},
);
app.post(
'/packs/stickers/:pack_id/bulk',
RateLimitMiddleware(RateLimitConfigs.PACKS_STICKER_BULK_CREATE),
LoginRequired,
Validator('param', z.object({pack_id: Int64Type})),
Validator('json', GuildStickerBulkCreateRequest),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
const {stickers} = ctx.req.valid('json');
const user = ctx.get('user');
return ctx.json(await ctx.get('packService').bulkCreatePackStickers({user, packId, stickers}));
},
);
app.get(
'/packs/stickers/:pack_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_STICKERS_LIST),
LoginRequired,
Validator('param', z.object({pack_id: Int64Type})),
async (ctx) => {
const packId = createGuildID(ctx.req.valid('param').pack_id);
const userId = ctx.get('user').id;
const requestCache = ctx.get('requestCache');
return ctx.json(await ctx.get('packService').getPackStickers({userId, packId, requestCache}));
},
);
app.patch(
'/packs/stickers/:pack_id/:sticker_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_STICKER_UPDATE),
LoginRequired,
Validator('param', z.object({pack_id: Int64Type, sticker_id: Int64Type})),
Validator('json', GuildStickerUpdateRequest),
async (ctx) => {
const {pack_id, sticker_id} = ctx.req.valid('param');
const packId = createGuildID(pack_id);
const stickerId = createStickerID(sticker_id);
const {name, description, tags} = ctx.req.valid('json');
return ctx.json(
await ctx
.get('packService')
.updatePackSticker({userId: ctx.get('user').id, packId, stickerId, name, description, tags}),
);
},
);
app.delete(
'/packs/stickers/:pack_id/:sticker_id',
RateLimitMiddleware(RateLimitConfigs.PACKS_STICKER_DELETE),
LoginRequired,
Validator('param', z.object({pack_id: Int64Type, sticker_id: Int64Type})),
Validator('query', z.object({purge: QueryBooleanType.optional()})),
async (ctx) => {
const {pack_id, sticker_id} = ctx.req.valid('param');
const packId = createGuildID(pack_id);
const stickerId = createStickerID(sticker_id);
const {purge = false} = ctx.req.valid('query');
await ctx.get('packService').deletePackSticker({
userId: ctx.get('user').id,
packId,
stickerId,
purge,
});
return ctx.body(null, 204);
},
);
};

View File

@@ -0,0 +1,29 @@
/*
* 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 {PackController} from './PackController';
import {PackEmojiController} from './PackEmojiController';
import {PackStickerController} from './PackStickerController';
export const registerPackControllers = (app: HonoApp) => {
PackController(app);
PackEmojiController(app);
PackStickerController(app);
};