mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
585 lines
18 KiB
TypeScript
585 lines
18 KiB
TypeScript
/*
|
|
* 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 crypto from 'node:crypto';
|
|
import {type ChannelID, createBetaCode, type MessageID, type UserID} from '~/BrandedTypes';
|
|
import {Config} from '~/Config';
|
|
import {MAX_BOOKMARKS_NON_PREMIUM, MAX_BOOKMARKS_PREMIUM} from '~/Constants';
|
|
import {mapMessageToResponse} from '~/channel/ChannelModel';
|
|
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
|
import type {ChannelService} from '~/channel/services/ChannelService';
|
|
import type {PushSubscriptionRow} from '~/database/CassandraTypes';
|
|
import {
|
|
BetaCodeAllowanceExceededError,
|
|
BetaCodeMaxUnclaimedError,
|
|
HarvestExpiredError,
|
|
HarvestFailedError,
|
|
HarvestNotReadyError,
|
|
HarvestOnCooldownError,
|
|
MaxBookmarksError,
|
|
MissingPermissionsError,
|
|
UnclaimedAccountRestrictedError,
|
|
UnknownChannelError,
|
|
UnknownHarvestError,
|
|
UnknownMessageError,
|
|
UnknownUserError,
|
|
} from '~/Errors';
|
|
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
|
import type {IMediaService} from '~/infrastructure/IMediaService';
|
|
import type {IStorageService} from '~/infrastructure/IStorageService';
|
|
import type {RedisBulkMessageDeletionQueueService} from '~/infrastructure/RedisBulkMessageDeletionQueueService';
|
|
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
|
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
|
import {Logger} from '~/Logger';
|
|
import type {BetaCode, Message, PushSubscription} from '~/Models';
|
|
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
|
import * as RandomUtils from '~/utils/RandomUtils';
|
|
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
|
import type {IWorkerService} from '~/worker/IWorkerService';
|
|
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
|
|
import type {IUserContentRepository} from '../repositories/IUserContentRepository';
|
|
import {UserHarvest, type UserHarvestResponse} from '../UserHarvestModel';
|
|
import {UserHarvestRepository} from '../UserHarvestRepository';
|
|
import type {SavedMessageStatus} from '../UserTypes';
|
|
import {BaseUserUpdatePropagator} from './BaseUserUpdatePropagator';
|
|
|
|
export class UserContentService {
|
|
private readonly updatePropagator: BaseUserUpdatePropagator;
|
|
|
|
constructor(
|
|
private userAccountRepository: IUserAccountRepository,
|
|
private userContentRepository: IUserContentRepository,
|
|
userCacheService: UserCacheService,
|
|
private channelService: ChannelService,
|
|
private channelRepository: IChannelRepository,
|
|
private gatewayService: IGatewayService,
|
|
private mediaService: IMediaService,
|
|
private workerService: IWorkerService,
|
|
private snowflakeService: SnowflakeService,
|
|
private bulkMessageDeletionQueue: RedisBulkMessageDeletionQueueService,
|
|
) {
|
|
this.updatePropagator = new BaseUserUpdatePropagator({
|
|
userCacheService,
|
|
gatewayService: this.gatewayService,
|
|
});
|
|
}
|
|
|
|
async getBetaCodes(userId: UserID): Promise<Array<BetaCode>> {
|
|
const betaCodes = await this.userContentRepository.listBetaCodes(userId);
|
|
const now = new Date();
|
|
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
return betaCodes.filter((code) => {
|
|
if (!code.redeemedAt) {
|
|
return true;
|
|
}
|
|
return code.redeemedAt >= oneWeekAgo;
|
|
});
|
|
}
|
|
|
|
async getBetaCodeAllowanceInfo(userId: UserID): Promise<{allowance: number; nextResetAt: Date | null}> {
|
|
const user = await this.userAccountRepository.findUnique(userId);
|
|
if (!user) {
|
|
throw new UnknownUserError();
|
|
}
|
|
|
|
const now = new Date();
|
|
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
let allowance = user.betaCodeAllowance;
|
|
let lastResetAt = user.betaCodeLastResetAt;
|
|
|
|
if (!lastResetAt || lastResetAt < oneWeekAgo) {
|
|
allowance = 3;
|
|
lastResetAt = now;
|
|
}
|
|
|
|
const nextResetAt = lastResetAt ? new Date(lastResetAt.getTime() + 7 * 24 * 60 * 60 * 1000) : null;
|
|
|
|
return {allowance, nextResetAt};
|
|
}
|
|
|
|
async createBetaCode(userId: UserID): Promise<BetaCode> {
|
|
const user = await this.userAccountRepository.findUnique(userId);
|
|
if (!user) {
|
|
throw new UnknownUserError();
|
|
}
|
|
|
|
if (user.isUnclaimedAccount()) {
|
|
throw new UnclaimedAccountRestrictedError('create beta codes');
|
|
}
|
|
|
|
const existingBetaCodes = await this.userContentRepository.listBetaCodes(userId);
|
|
const unclaimedCount = existingBetaCodes.filter((code) => !code.redeemerId).length;
|
|
|
|
if (unclaimedCount >= 6) {
|
|
throw new BetaCodeMaxUnclaimedError();
|
|
}
|
|
|
|
const now = new Date();
|
|
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
let allowance = user.betaCodeAllowance;
|
|
let lastResetAt = user.betaCodeLastResetAt;
|
|
|
|
if (!lastResetAt || lastResetAt < oneWeekAgo) {
|
|
allowance = 3;
|
|
lastResetAt = now;
|
|
}
|
|
|
|
if (allowance <= 0) {
|
|
throw new BetaCodeAllowanceExceededError();
|
|
}
|
|
|
|
await this.userAccountRepository.patchUpsert(userId, {
|
|
beta_code_allowance: allowance - 1,
|
|
beta_code_last_reset_at: lastResetAt,
|
|
});
|
|
|
|
return await this.userContentRepository.upsertBetaCode({
|
|
code: createBetaCode(RandomUtils.randomString(32)),
|
|
created_at: now,
|
|
creator_id: userId,
|
|
redeemed_at: null,
|
|
redeemer_id: null,
|
|
version: 1,
|
|
});
|
|
}
|
|
|
|
async deleteBetaCode(params: {userId: UserID; code: string}): Promise<void> {
|
|
const {userId, code} = params;
|
|
const user = await this.userAccountRepository.findUnique(userId);
|
|
if (!user) {
|
|
throw new UnknownUserError();
|
|
}
|
|
|
|
const now = new Date();
|
|
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
let allowance = user.betaCodeAllowance;
|
|
let lastResetAt = user.betaCodeLastResetAt;
|
|
|
|
if (!lastResetAt || lastResetAt < oneWeekAgo) {
|
|
allowance = 3;
|
|
lastResetAt = now;
|
|
}
|
|
|
|
await this.userContentRepository.deleteBetaCode(code, userId);
|
|
|
|
await this.userAccountRepository.patchUpsert(userId, {
|
|
beta_code_allowance: Math.min(allowance + 1, 3),
|
|
beta_code_last_reset_at: lastResetAt,
|
|
});
|
|
}
|
|
|
|
async getRecentMentions(params: {
|
|
userId: UserID;
|
|
limit: number;
|
|
everyone: boolean;
|
|
roles: boolean;
|
|
guilds: boolean;
|
|
before?: MessageID;
|
|
}): Promise<Array<Message>> {
|
|
const {userId, limit, everyone, roles, guilds, before} = params;
|
|
const mentions = await this.userContentRepository.listRecentMentions(
|
|
userId,
|
|
everyone,
|
|
roles,
|
|
guilds,
|
|
limit,
|
|
before,
|
|
);
|
|
const messagePromises = mentions.map(async (mention) => {
|
|
try {
|
|
return await this.channelService.getMessage({
|
|
userId,
|
|
channelId: mention.channelId,
|
|
messageId: mention.messageId,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof UnknownMessageError) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
const messageResults = await Promise.all(messagePromises);
|
|
const messages = messageResults.filter((message): message is Message => message != null);
|
|
return messages.sort((a, b) => (b.id > a.id ? 1 : -1));
|
|
}
|
|
|
|
async deleteRecentMention({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
|
const recentMention = await this.userContentRepository.getRecentMention(userId, messageId);
|
|
if (!recentMention) return;
|
|
await this.userContentRepository.deleteRecentMention(recentMention);
|
|
await this.dispatchRecentMentionDelete({userId, messageId});
|
|
}
|
|
|
|
async getSavedMessages({userId, limit}: {userId: UserID; limit: number}): Promise<
|
|
Array<{
|
|
channelId: ChannelID;
|
|
messageId: MessageID;
|
|
status: SavedMessageStatus;
|
|
message: Message | null;
|
|
}>
|
|
> {
|
|
const savedMessages = await this.userContentRepository.listSavedMessages(userId, limit);
|
|
const results: Array<{
|
|
channelId: ChannelID;
|
|
messageId: MessageID;
|
|
status: SavedMessageStatus;
|
|
message: Message | null;
|
|
}> = [];
|
|
|
|
for (const savedMessage of savedMessages) {
|
|
let message: Message | null = null;
|
|
let status: SavedMessageStatus = 'available';
|
|
|
|
try {
|
|
message = await this.channelService.getMessage({
|
|
userId,
|
|
channelId: savedMessage.channelId,
|
|
messageId: savedMessage.messageId,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof UnknownMessageError) {
|
|
await this.userContentRepository.deleteSavedMessage(userId, savedMessage.messageId);
|
|
continue;
|
|
}
|
|
if (error instanceof MissingPermissionsError || error instanceof UnknownChannelError) {
|
|
status = 'missing_permissions';
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
results.push({
|
|
channelId: savedMessage.channelId,
|
|
messageId: savedMessage.messageId,
|
|
status,
|
|
message,
|
|
});
|
|
}
|
|
|
|
return results.sort((a, b) => (b.messageId > a.messageId ? 1 : a.messageId > b.messageId ? -1 : 0));
|
|
}
|
|
|
|
async saveMessage({
|
|
userId,
|
|
channelId,
|
|
messageId,
|
|
userCacheService,
|
|
requestCache,
|
|
}: {
|
|
userId: UserID;
|
|
channelId: ChannelID;
|
|
messageId: MessageID;
|
|
userCacheService: UserCacheService;
|
|
requestCache: RequestCache;
|
|
}): Promise<void> {
|
|
const user = await this.userAccountRepository.findUnique(userId);
|
|
if (!user) {
|
|
throw new UnknownUserError();
|
|
}
|
|
|
|
const savedMessages = await this.userContentRepository.listSavedMessages(userId, 1000);
|
|
const maxBookmarks = user.isPremium() ? MAX_BOOKMARKS_PREMIUM : MAX_BOOKMARKS_NON_PREMIUM;
|
|
|
|
if (savedMessages.length >= maxBookmarks) {
|
|
throw new MaxBookmarksError(user.isPremium());
|
|
}
|
|
|
|
await this.channelService.getChannelAuthenticated({userId, channelId});
|
|
const message = await this.channelService.getMessage({userId, channelId, messageId});
|
|
if (!message) {
|
|
throw new UnknownMessageError();
|
|
}
|
|
await this.userContentRepository.createSavedMessage(userId, channelId, messageId);
|
|
await this.dispatchSavedMessageCreate({userId, message, userCacheService, requestCache});
|
|
}
|
|
|
|
async unsaveMessage({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
|
await this.userContentRepository.deleteSavedMessage(userId, messageId);
|
|
await this.dispatchSavedMessageDelete({userId, messageId});
|
|
}
|
|
|
|
async registerPushSubscription(params: {
|
|
userId: UserID;
|
|
endpoint: string;
|
|
keys: {p256dh: string; auth: string};
|
|
userAgent?: string;
|
|
}): Promise<PushSubscription> {
|
|
const {userId, endpoint, keys, userAgent} = params;
|
|
|
|
const subscriptionId = crypto.createHash('sha256').update(endpoint).digest('hex').substring(0, 32);
|
|
|
|
const data: PushSubscriptionRow = {
|
|
user_id: userId,
|
|
subscription_id: subscriptionId,
|
|
endpoint,
|
|
p256dh_key: keys.p256dh,
|
|
auth_key: keys.auth,
|
|
user_agent: userAgent ?? null,
|
|
};
|
|
|
|
return await this.userContentRepository.createPushSubscription(data);
|
|
}
|
|
|
|
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
|
return await this.userContentRepository.listPushSubscriptions(userId);
|
|
}
|
|
|
|
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
|
await this.userContentRepository.deletePushSubscription(userId, subscriptionId);
|
|
}
|
|
|
|
async requestDataHarvest(userId: UserID): Promise<{harvestId: string}> {
|
|
const user = await this.userAccountRepository.findUnique(userId);
|
|
if (!user) throw new UnknownUserError();
|
|
|
|
if (!Config.dev.testModeEnabled) {
|
|
const harvestRepository = new UserHarvestRepository();
|
|
const latestHarvest = await harvestRepository.findLatestByUserId(userId);
|
|
|
|
if (latestHarvest?.requestedAt) {
|
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
if (latestHarvest.requestedAt > sevenDaysAgo) {
|
|
const retryAfter = new Date(latestHarvest.requestedAt.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
throw new HarvestOnCooldownError({retryAfter});
|
|
}
|
|
}
|
|
}
|
|
|
|
const harvestId = this.snowflakeService.generate();
|
|
const harvest = new UserHarvest({
|
|
user_id: userId,
|
|
harvest_id: harvestId,
|
|
requested_at: new Date(),
|
|
started_at: null,
|
|
completed_at: null,
|
|
failed_at: null,
|
|
storage_key: null,
|
|
file_size: null,
|
|
progress_percent: 0,
|
|
progress_step: 'Queued',
|
|
error_message: null,
|
|
download_url_expires_at: null,
|
|
});
|
|
|
|
const harvestRepository = new UserHarvestRepository();
|
|
await harvestRepository.create(harvest);
|
|
|
|
await this.workerService.addJob('harvestUserData', {
|
|
userId: userId.toString(),
|
|
harvestId: harvestId.toString(),
|
|
});
|
|
|
|
return {harvestId: harvestId.toString()};
|
|
}
|
|
|
|
async getHarvestStatus(userId: UserID, harvestId: bigint): Promise<UserHarvestResponse> {
|
|
const harvestRepository = new UserHarvestRepository();
|
|
const harvest = await harvestRepository.findByUserAndHarvestId(userId, harvestId);
|
|
if (!harvest) {
|
|
throw new UnknownHarvestError();
|
|
}
|
|
return harvest.toResponse();
|
|
}
|
|
|
|
async getLatestHarvest(userId: UserID): Promise<UserHarvestResponse | null> {
|
|
const harvestRepository = new UserHarvestRepository();
|
|
const harvest = await harvestRepository.findLatestByUserId(userId);
|
|
return harvest ? harvest.toResponse() : null;
|
|
}
|
|
|
|
async getHarvestDownloadUrl(
|
|
userId: UserID,
|
|
harvestId: bigint,
|
|
storageService: IStorageService,
|
|
): Promise<{downloadUrl: string; expiresAt: string}> {
|
|
const harvestRepository = new UserHarvestRepository();
|
|
const harvest = await harvestRepository.findByUserAndHarvestId(userId, harvestId);
|
|
|
|
if (!harvest) {
|
|
throw new UnknownHarvestError();
|
|
}
|
|
|
|
if (!harvest.completedAt || !harvest.storageKey) {
|
|
throw new HarvestNotReadyError();
|
|
}
|
|
|
|
if (harvest.failedAt) {
|
|
throw new HarvestFailedError();
|
|
}
|
|
|
|
if (harvest.downloadUrlExpiresAt && harvest.downloadUrlExpiresAt < new Date()) {
|
|
throw new HarvestExpiredError();
|
|
}
|
|
|
|
const ZIP_EXPIRY_SECONDS = 7 * 24 * 60 * 60;
|
|
const downloadUrl = await storageService.getPresignedDownloadURL({
|
|
bucket: Config.s3.buckets.harvests,
|
|
key: harvest.storageKey,
|
|
expiresIn: ZIP_EXPIRY_SECONDS,
|
|
});
|
|
|
|
const expiresAt = new Date(Date.now() + ZIP_EXPIRY_SECONDS * 1000);
|
|
|
|
return {
|
|
downloadUrl,
|
|
expiresAt: expiresAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
async requestBulkMessageDeletion(params: {userId: UserID; delayMs?: number}): Promise<void> {
|
|
const {userId, delayMs = 24 * 60 * 60 * 1000} = params;
|
|
const scheduledAt = new Date(Date.now() + delayMs);
|
|
|
|
await this.bulkMessageDeletionQueue.removeFromQueue(userId);
|
|
|
|
const counts = await this.countBulkDeletionTargets(userId, scheduledAt.getTime());
|
|
Logger.debug(
|
|
{
|
|
userId: userId.toString(),
|
|
channelCount: counts.channelCount,
|
|
messageCount: counts.messageCount,
|
|
scheduledAt: scheduledAt.toISOString(),
|
|
},
|
|
'Scheduling bulk message deletion',
|
|
);
|
|
|
|
const updatedUser = await this.userAccountRepository.patchUpsert(userId, {
|
|
pending_bulk_message_deletion_at: scheduledAt,
|
|
pending_bulk_message_deletion_channel_count: counts.channelCount,
|
|
pending_bulk_message_deletion_message_count: counts.messageCount,
|
|
});
|
|
|
|
if (!updatedUser) {
|
|
throw new UnknownUserError();
|
|
}
|
|
|
|
await this.bulkMessageDeletionQueue.scheduleDeletion(userId, scheduledAt);
|
|
|
|
await this.updatePropagator.dispatchUserUpdate(updatedUser);
|
|
}
|
|
|
|
async cancelBulkMessageDeletion(userId: UserID): Promise<void> {
|
|
Logger.debug({userId: userId.toString()}, 'Canceling pending bulk message deletion');
|
|
const updatedUser = await this.userAccountRepository.patchUpsert(userId, {
|
|
pending_bulk_message_deletion_at: null,
|
|
pending_bulk_message_deletion_channel_count: null,
|
|
pending_bulk_message_deletion_message_count: null,
|
|
});
|
|
|
|
if (!updatedUser) {
|
|
throw new UnknownUserError();
|
|
}
|
|
|
|
await this.bulkMessageDeletionQueue.removeFromQueue(userId);
|
|
|
|
await this.updatePropagator.dispatchUserUpdate(updatedUser);
|
|
}
|
|
|
|
private async countBulkDeletionTargets(
|
|
userId: UserID,
|
|
cutoffMs: number,
|
|
): Promise<{
|
|
channelCount: number;
|
|
messageCount: number;
|
|
}> {
|
|
const CHUNK_SIZE = 200;
|
|
let lastChannelId: ChannelID | undefined;
|
|
let lastMessageId: MessageID | undefined;
|
|
const channels = new Set<string>();
|
|
let messageCount = 0;
|
|
|
|
while (true) {
|
|
const messageRefs = await this.channelRepository.listMessagesByAuthor(
|
|
userId,
|
|
CHUNK_SIZE,
|
|
lastChannelId,
|
|
lastMessageId,
|
|
);
|
|
if (messageRefs.length === 0) {
|
|
break;
|
|
}
|
|
|
|
for (const {channelId, messageId} of messageRefs) {
|
|
if (SnowflakeUtils.extractTimestamp(messageId) > cutoffMs) {
|
|
continue;
|
|
}
|
|
channels.add(channelId.toString());
|
|
messageCount++;
|
|
}
|
|
|
|
lastChannelId = messageRefs[messageRefs.length - 1].channelId;
|
|
lastMessageId = messageRefs[messageRefs.length - 1].messageId;
|
|
|
|
if (messageRefs.length < CHUNK_SIZE) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {
|
|
channelCount: channels.size,
|
|
messageCount,
|
|
};
|
|
}
|
|
|
|
async dispatchRecentMentionDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
|
await this.gatewayService.dispatchPresence({
|
|
userId,
|
|
event: 'RECENT_MENTION_DELETE',
|
|
data: {message_id: messageId.toString()},
|
|
});
|
|
}
|
|
|
|
async dispatchSavedMessageCreate({
|
|
userId,
|
|
message,
|
|
userCacheService,
|
|
requestCache,
|
|
}: {
|
|
userId: UserID;
|
|
message: Message;
|
|
userCacheService: UserCacheService;
|
|
requestCache: RequestCache;
|
|
}): Promise<void> {
|
|
await this.gatewayService.dispatchPresence({
|
|
userId,
|
|
event: 'SAVED_MESSAGE_CREATE',
|
|
data: await mapMessageToResponse({
|
|
message,
|
|
currentUserId: userId,
|
|
userCacheService,
|
|
requestCache,
|
|
mediaService: this.mediaService,
|
|
}),
|
|
});
|
|
}
|
|
|
|
async dispatchSavedMessageDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
|
await this.gatewayService.dispatchPresence({
|
|
userId,
|
|
event: 'SAVED_MESSAGE_DELETE',
|
|
data: {message_id: messageId.toString()},
|
|
});
|
|
}
|
|
}
|