mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-12 13:38:31 +09:00
872 lines
32 KiB
TypeScript
872 lines
32 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import crypto from 'node:crypto';
|
|
import type {Readable} from 'node:stream';
|
|
import type {ApiContext} from '@app/api/ApiContext';
|
|
import {type ChannelID, createChannelID, createUserID, type MessageID, type UserID} from '@app/api/BrandedTypes';
|
|
import {Config} from '@app/api/Config';
|
|
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
|
import type {ChannelService} from '@app/api/channel/services/ChannelService';
|
|
import {createMessageResponseDataService} from '@app/api/channel/services/message/MessageResponseDataService';
|
|
import type {PushSubscriptionRow} from '@app/api/database/types/UserTypes';
|
|
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
|
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
|
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
|
import type {KVBulkMessageDeletionQueueService} from '@app/api/infrastructure/KVBulkMessageDeletionQueueService';
|
|
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
|
import {Logger} from '@app/api/Logger';
|
|
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
|
import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
|
|
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
|
|
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
|
import type {Message} from '@app/api/models/Message';
|
|
import {PushSubscription} from '@app/api/models/PushSubscription';
|
|
import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository';
|
|
import type {IUserContentRepository} from '@app/api/user/repositories/IUserContentRepository';
|
|
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
|
|
import {verifyHarvestDownloadToken} from '@app/api/user/services/HarvestDownloadToken';
|
|
import {buildHarvestDownloadUrl} from '@app/api/user/services/HarvestDownloadUrl';
|
|
import {
|
|
findInstalledLegacyPushSubscriptionIds,
|
|
findTargetPushSubscriptionIds,
|
|
getPushOriginReplacement,
|
|
getPushSessionPredecessor,
|
|
markInstalledLegacyPushSubscription,
|
|
markPushOriginReplaced,
|
|
markTargetPushSubscription,
|
|
sameUserAgentFamily,
|
|
type WebPushOriginKind,
|
|
} from '@app/api/user/services/WebPushOriginReplacement';
|
|
import {UserHarvest} from '@app/api/user/UserHarvestModel';
|
|
import {UserHarvestRepository} from '@app/api/user/UserHarvestRepository';
|
|
import {serializeSelfMessageFilter} from '@app/api/worker/utils/SelfMessageFilterPayload';
|
|
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
|
import {MAX_BOOKMARKS_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
|
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
|
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
|
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
|
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
|
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
|
|
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
|
import {MaxBookmarksError} from '@fluxer/errors/src/domains/core/MaxBookmarksError';
|
|
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
|
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
|
import {HarvestExpiredError} from '@fluxer/errors/src/domains/moderation/HarvestExpiredError';
|
|
import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestFailedError';
|
|
import {HarvestNotReadyError} from '@fluxer/errors/src/domains/moderation/HarvestNotReadyError';
|
|
import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError';
|
|
import {UnknownHarvestError} from '@fluxer/errors/src/domains/moderation/UnknownHarvestError';
|
|
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
|
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
|
import type {HarvestCreationResponse, HarvestStatusResponse} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
|
|
import type {
|
|
BulkDeleteSelfMessagesFilter,
|
|
HarvestSelfDataRequest,
|
|
RegisterMobileDeviceRequest,
|
|
UnregisterMobileDeviceRequest,
|
|
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
|
import type {SavedMessageStatus} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
|
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
|
import {isPubliclyRoutableUrlShape} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
|
|
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
|
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
|
import {ms} from 'itty-time';
|
|
|
|
export interface SavedMessageEntry {
|
|
channelId: ChannelID;
|
|
messageId: MessageID;
|
|
status: SavedMessageStatus;
|
|
message: Message | null;
|
|
}
|
|
|
|
interface RegisterMobileDeviceParams {
|
|
userId: UserID;
|
|
authSessionIdHash?: string | null;
|
|
device: RegisterMobileDeviceRequest;
|
|
}
|
|
|
|
interface UnregisterMobileDeviceParams {
|
|
userId: UserID;
|
|
device: UnregisterMobileDeviceRequest;
|
|
}
|
|
|
|
interface UserContentRepository extends IUserAccountRepository, IUserContentRepository {}
|
|
|
|
const WEB_PUSH_PLATFORM = 'web_push' as const;
|
|
const DEFAULT_MOBILE_APP_ID = 'stable';
|
|
const DEFAULT_APNS_PROVIDER_ENVIRONMENT = 'production';
|
|
|
|
function createPushSubscriptionId(parts: Array<string>): string {
|
|
const stableInput = parts.map((part) => `${part.length}:${part}`).join('|');
|
|
return crypto.createHash('sha256').update(stableInput).digest('hex').substring(0, 32);
|
|
}
|
|
|
|
function createWebPushSubscriptionId(endpoint: string): string {
|
|
return crypto.createHash('sha256').update(endpoint).digest('hex').substring(0, 32);
|
|
}
|
|
|
|
function assertPublicPushEndpoint(endpoint: string, fieldName: string): void {
|
|
let parsedUrl: URL;
|
|
try {
|
|
parsedUrl = new URL(endpoint);
|
|
} catch {
|
|
throw InputValidationError.fromCode(fieldName, ValidationErrorCodes.INVALID_URL_FORMAT);
|
|
}
|
|
if (!isPubliclyRoutableUrlShape(parsedUrl)) {
|
|
throw InputValidationError.fromCode(fieldName, ValidationErrorCodes.URL_NOT_PUBLICLY_ROUTABLE);
|
|
}
|
|
}
|
|
|
|
function isPushEndpointUrl(token: string): boolean {
|
|
const normalized = token.trim().toLowerCase();
|
|
return normalized.startsWith('https://') || normalized.startsWith('http://');
|
|
}
|
|
|
|
function resolveMobileWebPushKeys(device: RegisterMobileDeviceRequest): {p256dh: string; auth: string} | null {
|
|
const p256dh = device.encryption_key;
|
|
const auth = device.auth_secret;
|
|
if (p256dh && auth) return {p256dh, auth};
|
|
if (p256dh || auth) {
|
|
throw InputValidationError.create(
|
|
p256dh ? 'auth_secret' : 'encryption_key',
|
|
'Web Push registrations require encryption_key and auth_secret',
|
|
);
|
|
}
|
|
if (device.platform === 'android_unified_push' || device.platform === 'ios_apns_voip') {
|
|
throw InputValidationError.create(
|
|
'encryption_key',
|
|
'Web Push registrations require encryption_key and auth_secret',
|
|
);
|
|
}
|
|
if (isPushEndpointUrl(device.token)) {
|
|
throw InputValidationError.create('token', 'Endpoint URL registrations require encryption_key and auth_secret');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeMobileAppId(appId: string | undefined): string {
|
|
const normalized = appId?.trim();
|
|
return normalized && normalized.length > 0 ? normalized : DEFAULT_MOBILE_APP_ID;
|
|
}
|
|
|
|
function normalizeProviderEnvironment(
|
|
platform: RegisterMobileDeviceRequest['platform'],
|
|
environment: RegisterMobileDeviceRequest['provider_environment'],
|
|
): string | null {
|
|
if (environment) return environment;
|
|
return platform === 'ios_apns' || platform === 'ios_apns_voip' ? DEFAULT_APNS_PROVIDER_ENVIRONMENT : null;
|
|
}
|
|
|
|
const isUnreachableEntityError = (error: unknown): boolean =>
|
|
error instanceof MissingPermissionsError ||
|
|
error instanceof UnknownChannelError ||
|
|
error instanceof UnknownGuildError ||
|
|
error instanceof AccessDeniedError ||
|
|
error instanceof NsfwContentRequiresAgeVerificationError;
|
|
|
|
export const UserContentServiceTestHooks = {isUnreachableEntityError};
|
|
|
|
export class UserContentService {
|
|
private readonly updatePropagator: BaseUserUpdatePropagator;
|
|
private readonly userRepository: UserContentRepository;
|
|
private readonly gatewayService: IGatewayService;
|
|
private readonly workerService: IWorkerService<WorkerTaskName>;
|
|
private readonly snowflakeService: ISnowflakeService;
|
|
private readonly kv: IKVProvider;
|
|
|
|
constructor(
|
|
apiContext: ApiContext,
|
|
userCacheService: UserCacheService,
|
|
private channelService: ChannelService,
|
|
private channelRepository: IChannelRepository,
|
|
private bulkMessageDeletionQueue: KVBulkMessageDeletionQueueService,
|
|
private limitConfigService: LimitConfigService,
|
|
) {
|
|
const {users, gateway, worker, snowflake, kv} = apiContext.services;
|
|
this.userRepository = users;
|
|
this.gatewayService = gateway;
|
|
this.workerService = worker;
|
|
this.snowflakeService = snowflake;
|
|
this.kv = kv;
|
|
this.updatePropagator = new BaseUserUpdatePropagator({
|
|
userCacheService,
|
|
gatewayService: this.gatewayService,
|
|
});
|
|
}
|
|
|
|
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.userRepository.listRecentMentions(userId, everyone, roles, guilds, limit, before);
|
|
const messagesByChannel = await this.readMessagesByChannel(userId, mentions);
|
|
const messages = mentions
|
|
.map((mention) => this.pickMessage(messagesByChannel, mention))
|
|
.filter((message): message is Message => message != null);
|
|
return messages.sort((a, b) => (b.id > a.id ? 1 : -1));
|
|
}
|
|
|
|
private async readMessagesByChannel(
|
|
userId: UserID,
|
|
entries: ReadonlyArray<{channelId: ChannelID; messageId: MessageID}>,
|
|
): Promise<Map<string, Map<string, Message> | null>> {
|
|
const grouped = new Map<string, {channelId: ChannelID; messageIds: Array<MessageID>}>();
|
|
for (const entry of entries) {
|
|
const key = entry.channelId.toString();
|
|
const group = grouped.get(key);
|
|
if (group) {
|
|
group.messageIds.push(entry.messageId);
|
|
} else {
|
|
grouped.set(key, {channelId: entry.channelId, messageIds: [entry.messageId]});
|
|
}
|
|
}
|
|
const messagesByChannel = new Map<string, Map<string, Message> | null>();
|
|
await Promise.all(
|
|
Array.from(grouped, async ([key, group]) => {
|
|
try {
|
|
const messages = await this.channelService.messages.retrieval.getMessagesByIds({
|
|
userId,
|
|
channelId: group.channelId,
|
|
messageIds: group.messageIds,
|
|
});
|
|
messagesByChannel.set(key, messages);
|
|
} catch (error) {
|
|
if (isUnreachableEntityError(error)) {
|
|
messagesByChannel.set(key, null);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}),
|
|
);
|
|
return messagesByChannel;
|
|
}
|
|
|
|
private pickMessage(
|
|
messagesByChannel: ReadonlyMap<string, Map<string, Message> | null>,
|
|
entry: {channelId: ChannelID; messageId: MessageID},
|
|
): Message | null {
|
|
return messagesByChannel.get(entry.channelId.toString())?.get(entry.messageId.toString()) ?? null;
|
|
}
|
|
|
|
async deleteRecentMention({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
|
const recentMention = await this.userRepository.getRecentMention(userId, messageId);
|
|
if (!recentMention) return;
|
|
await this.userRepository.deleteRecentMention(recentMention);
|
|
await this.dispatchRecentMentionDelete({userId, messageId});
|
|
}
|
|
|
|
async deleteRecentMentions({userId, messageIds}: {userId: UserID; messageIds: Array<MessageID>}): Promise<void> {
|
|
if (messageIds.length === 0) return;
|
|
const mentions = (
|
|
await Promise.all(messageIds.map((messageId) => this.userRepository.getRecentMention(userId, messageId)))
|
|
).filter((mention) => mention != null);
|
|
if (mentions.length === 0) return;
|
|
await this.userRepository.deleteRecentMentions(mentions);
|
|
await Promise.all(
|
|
mentions.map((mention) => this.dispatchRecentMentionDelete({userId, messageId: mention.messageId})),
|
|
);
|
|
}
|
|
|
|
async getSavedMessages({
|
|
userId,
|
|
limit,
|
|
before,
|
|
}: {
|
|
userId: UserID;
|
|
limit: number;
|
|
before?: MessageID;
|
|
}): Promise<Array<SavedMessageEntry>> {
|
|
const savedMessages = await this.userRepository.listSavedMessages(userId, limit, before);
|
|
const messagesByChannel = await this.readMessagesByChannel(userId, savedMessages);
|
|
const results: Array<SavedMessageEntry> = [];
|
|
const staleMessageIds: Array<MessageID> = [];
|
|
for (const savedMessage of savedMessages) {
|
|
const channelMessages = messagesByChannel.get(savedMessage.channelId.toString());
|
|
if (channelMessages === null) {
|
|
results.push({
|
|
channelId: savedMessage.channelId,
|
|
messageId: savedMessage.messageId,
|
|
status: 'missing_permissions',
|
|
message: null,
|
|
});
|
|
continue;
|
|
}
|
|
const message = this.pickMessage(messagesByChannel, savedMessage);
|
|
if (!message) {
|
|
const stored = await this.channelRepository.messages.getMessage(savedMessage.channelId, savedMessage.messageId);
|
|
if (!stored) {
|
|
staleMessageIds.push(savedMessage.messageId);
|
|
continue;
|
|
}
|
|
results.push({
|
|
channelId: savedMessage.channelId,
|
|
messageId: savedMessage.messageId,
|
|
status: 'missing_permissions',
|
|
message: null,
|
|
});
|
|
continue;
|
|
}
|
|
results.push({
|
|
channelId: savedMessage.channelId,
|
|
messageId: savedMessage.messageId,
|
|
status: 'available',
|
|
message,
|
|
});
|
|
}
|
|
await Promise.all(staleMessageIds.map((messageId) => this.userRepository.deleteSavedMessage(userId, messageId)));
|
|
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.userRepository.findUnique(userId);
|
|
if (!user) {
|
|
throw new UnknownUserError();
|
|
}
|
|
const savedMessageCount = await this.userRepository.countSavedMessages(userId);
|
|
const ctx = createLimitMatchContext({user});
|
|
const maxBookmarks = resolveLimitSafe(
|
|
this.limitConfigService.getConfigSnapshot(),
|
|
ctx,
|
|
'max_bookmarks',
|
|
MAX_BOOKMARKS_NON_PREMIUM,
|
|
);
|
|
if (savedMessageCount >= maxBookmarks) {
|
|
throw new MaxBookmarksError({maxBookmarks});
|
|
}
|
|
await this.channelService.channelData.auth.getChannelAuthenticated({userId, channelId});
|
|
const message = await this.channelService.messages.retrieval.getMessage({userId, channelId, messageId});
|
|
if (!message) {
|
|
throw new UnknownMessageError();
|
|
}
|
|
await this.userRepository.createSavedMessage(userId, channelId, messageId);
|
|
await this.dispatchSavedMessageCreate({userId, message, userCacheService, requestCache});
|
|
}
|
|
|
|
async unsaveMessage({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
|
await this.userRepository.deleteSavedMessage(userId, messageId);
|
|
await this.dispatchSavedMessageDelete({userId, messageId});
|
|
}
|
|
|
|
async registerPushSubscription(params: {
|
|
userId: UserID;
|
|
authSessionIdHash?: string | null;
|
|
endpoint: string;
|
|
keys: {
|
|
p256dh: string;
|
|
auth: string;
|
|
};
|
|
userAgent?: string;
|
|
originKind?: WebPushOriginKind | null;
|
|
installedApp?: boolean;
|
|
}): Promise<PushSubscription> {
|
|
const {userId, authSessionIdHash, endpoint, keys, userAgent, originKind, installedApp} = params;
|
|
assertPublicPushEndpoint(endpoint, 'endpoint');
|
|
const subscriptionId = createWebPushSubscriptionId(endpoint);
|
|
const data: PushSubscriptionRow = {
|
|
user_id: userId,
|
|
subscription_id: subscriptionId,
|
|
auth_session_id_hash: authSessionIdHash ?? null,
|
|
endpoint,
|
|
p256dh_key: keys.p256dh,
|
|
auth_key: keys.auth,
|
|
user_agent: userAgent ?? null,
|
|
platform: WEB_PUSH_PLATFORM,
|
|
app_id: null,
|
|
provider_environment: null,
|
|
};
|
|
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
|
|
return subscription;
|
|
}
|
|
|
|
private async storeWebPushSubscription(
|
|
data: PushSubscriptionRow,
|
|
originKind: WebPushOriginKind | null,
|
|
installedApp: boolean,
|
|
): Promise<PushSubscription> {
|
|
if (originKind === 'legacy' && (await this.isLegacyWebPushReplaced(data, installedApp))) {
|
|
return new PushSubscription(data);
|
|
}
|
|
const subscription = await this.userRepository.createPushSubscription(data);
|
|
if (originKind === 'legacy' && installedApp) {
|
|
await this.bestEffortPushOriginWrite(() => markInstalledLegacyPushSubscription(this.kv, data.subscription_id));
|
|
}
|
|
if (originKind === 'target') {
|
|
await this.bestEffortPushOriginWrite(() => this.replaceLegacyWebPushSubscriptions(data, installedApp));
|
|
}
|
|
return subscription;
|
|
}
|
|
|
|
private async isLegacyWebPushReplaced(data: PushSubscriptionRow, installedApp: boolean): Promise<boolean> {
|
|
const sessionIdHash = data.auth_session_id_hash;
|
|
if (!sessionIdHash) return false;
|
|
try {
|
|
const replacement = await getPushOriginReplacement(this.kv, sessionIdHash);
|
|
return replacement === 'installed' || (replacement === 'browser' && !installedApp);
|
|
} catch (error) {
|
|
Logger.warn({error}, 'Failed to read the web push origin replacement');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async bestEffortPushOriginWrite(write: () => Promise<void>): Promise<void> {
|
|
try {
|
|
await write();
|
|
} catch (error) {
|
|
Logger.warn({error}, 'Failed to apply the web push origin replacement');
|
|
}
|
|
}
|
|
|
|
private async replaceLegacyWebPushSubscriptions(data: PushSubscriptionRow, installedApp: boolean): Promise<void> {
|
|
await markTargetPushSubscription(this.kv, data.subscription_id);
|
|
const sessionIdHash = data.auth_session_id_hash;
|
|
if (!sessionIdHash) return;
|
|
await markPushOriginReplaced(this.kv, sessionIdHash, installedApp ? 'installed' : 'browser');
|
|
const predecessor = await getPushSessionPredecessor(this.kv, sessionIdHash);
|
|
const candidates = (await this.userRepository.listPushSubscriptions(data.user_id)).filter(
|
|
(subscription) =>
|
|
subscription.platform === WEB_PUSH_PLATFORM &&
|
|
subscription.endpoint !== data.endpoint &&
|
|
(subscription.authSessionIdHash === sessionIdHash ||
|
|
(predecessor !== null &&
|
|
subscription.authSessionIdHash === predecessor &&
|
|
sameUserAgentFamily(subscription.userAgent, data.user_agent))),
|
|
);
|
|
const candidateIds = candidates.map((subscription) => subscription.subscriptionId);
|
|
const [targetSubscriptionIds, installedLegacySubscriptionIds] = await Promise.all([
|
|
findTargetPushSubscriptionIds(this.kv, candidateIds),
|
|
installedApp ? Promise.resolve(new Set<string>()) : findInstalledLegacyPushSubscriptionIds(this.kv, candidateIds),
|
|
]);
|
|
for (const subscription of candidates) {
|
|
if (targetSubscriptionIds.has(subscription.subscriptionId)) continue;
|
|
if (installedLegacySubscriptionIds.has(subscription.subscriptionId)) continue;
|
|
await this.userRepository.deletePushSubscription(data.user_id, subscription.subscriptionId);
|
|
}
|
|
}
|
|
|
|
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
|
const subscriptions = await this.userRepository.listPushSubscriptions(userId);
|
|
return subscriptions.filter((subscription) => subscription.platform === WEB_PUSH_PLATFORM);
|
|
}
|
|
|
|
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
|
await this.userRepository.deletePushSubscription(userId, subscriptionId);
|
|
}
|
|
|
|
async rotatePushSubscription(params: {
|
|
userId: UserID;
|
|
authSessionIdHash?: string | null;
|
|
oldEndpoint: string;
|
|
endpoint: string;
|
|
keys: {
|
|
p256dh: string;
|
|
auth: string;
|
|
};
|
|
userAgent?: string;
|
|
originKind?: WebPushOriginKind | null;
|
|
installedApp?: boolean;
|
|
}): Promise<PushSubscription> {
|
|
const {userId, authSessionIdHash, oldEndpoint, endpoint, keys, userAgent, originKind, installedApp} = params;
|
|
assertPublicPushEndpoint(endpoint, 'endpoint');
|
|
const oldSubscriptionId = createWebPushSubscriptionId(oldEndpoint);
|
|
const newSubscriptionId = createWebPushSubscriptionId(endpoint);
|
|
if (oldSubscriptionId !== newSubscriptionId) {
|
|
await this.userRepository.deletePushSubscription(userId, oldSubscriptionId);
|
|
}
|
|
const data: PushSubscriptionRow = {
|
|
user_id: userId,
|
|
subscription_id: newSubscriptionId,
|
|
auth_session_id_hash: authSessionIdHash ?? null,
|
|
endpoint,
|
|
p256dh_key: keys.p256dh,
|
|
auth_key: keys.auth,
|
|
user_agent: userAgent ?? null,
|
|
platform: WEB_PUSH_PLATFORM,
|
|
app_id: null,
|
|
provider_environment: null,
|
|
};
|
|
const subscription = await this.storeWebPushSubscription(data, originKind ?? null, installedApp === true);
|
|
return subscription;
|
|
}
|
|
|
|
async registerMobileDevice(params: RegisterMobileDeviceParams): Promise<PushSubscription> {
|
|
const {userId, authSessionIdHash, device} = params;
|
|
const webPushKeys = resolveMobileWebPushKeys(device);
|
|
if (webPushKeys) {
|
|
assertPublicPushEndpoint(device.token, 'token');
|
|
}
|
|
const appId = normalizeMobileAppId(device.app_id);
|
|
const providerEnvironment = normalizeProviderEnvironment(device.platform, device.provider_environment);
|
|
const subscriptionId = createPushSubscriptionId([device.platform, appId, providerEnvironment ?? '', device.token]);
|
|
const data: PushSubscriptionRow = {
|
|
user_id: userId,
|
|
subscription_id: subscriptionId,
|
|
auth_session_id_hash: authSessionIdHash ?? null,
|
|
endpoint: device.token,
|
|
p256dh_key: webPushKeys?.p256dh ?? null,
|
|
auth_key: webPushKeys?.auth ?? null,
|
|
user_agent: device.user_agent ?? null,
|
|
platform: device.platform,
|
|
app_id: appId,
|
|
provider_environment: providerEnvironment,
|
|
};
|
|
const subscription = await this.userRepository.createPushSubscription(data);
|
|
return subscription;
|
|
}
|
|
|
|
async listMobileDevices(userId: UserID): Promise<Array<PushSubscription>> {
|
|
const subscriptions = await this.userRepository.listPushSubscriptions(userId);
|
|
return subscriptions.filter((subscription) => subscription.platform !== WEB_PUSH_PLATFORM);
|
|
}
|
|
|
|
async deleteMobileDevice(userId: UserID, deviceId: string): Promise<void> {
|
|
await this.deletePushSubscription(userId, deviceId);
|
|
}
|
|
|
|
async unregisterMobileDevice(params: UnregisterMobileDeviceParams): Promise<void> {
|
|
const {userId, device} = params;
|
|
const appId = normalizeMobileAppId(device.app_id);
|
|
const providerEnvironment = normalizeProviderEnvironment(device.platform, device.provider_environment);
|
|
const deviceId = createPushSubscriptionId([device.platform, appId, providerEnvironment ?? '', device.token]);
|
|
await this.deleteMobileDevice(userId, deviceId);
|
|
}
|
|
|
|
async requestDataHarvest(userId: UserID): Promise<HarvestCreationResponse> {
|
|
return this.requestDataHarvestInternal(userId, null);
|
|
}
|
|
|
|
async requestFilteredDataHarvest(params: {
|
|
userId: UserID;
|
|
filter: HarvestSelfDataRequest;
|
|
}): Promise<HarvestCreationResponse> {
|
|
return this.requestDataHarvestInternal(params.userId, params.filter);
|
|
}
|
|
|
|
private async requestDataHarvestInternal(
|
|
userId: UserID,
|
|
filter: HarvestSelfDataRequest | null,
|
|
): Promise<HarvestCreationResponse> {
|
|
const user = await this.userRepository.findUnique(userId);
|
|
if (!user) throw new UnknownUserError();
|
|
const harvestId = await 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(),
|
|
...(filter
|
|
? {
|
|
filter: serializeSelfMessageFilter(filter),
|
|
}
|
|
: {}),
|
|
});
|
|
return {
|
|
harvest_id: harvest.harvestId.toString(),
|
|
status: harvest.getStatus(),
|
|
created_at: harvest.requestedAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
async getHarvestStatus(userId: UserID, harvestId: bigint): Promise<HarvestStatusResponse> {
|
|
const harvestRepository = new UserHarvestRepository();
|
|
const harvest = await harvestRepository.findByUserAndHarvestId(userId, harvestId);
|
|
if (!harvest) {
|
|
throw new UnknownHarvestError();
|
|
}
|
|
return harvest.toResponse();
|
|
}
|
|
|
|
async getLatestHarvest(userId: UserID): Promise<HarvestStatusResponse | 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<{
|
|
download_url: string;
|
|
expires_at: string;
|
|
}> {
|
|
const harvestRepository = new UserHarvestRepository();
|
|
const harvest = await harvestRepository.findByUserAndHarvestId(userId, harvestId);
|
|
if (!harvest) {
|
|
throw new UnknownHarvestError();
|
|
}
|
|
if (harvest.failedAt) {
|
|
throw new HarvestFailedError();
|
|
}
|
|
if (!harvest.completedAt || !harvest.storageKey) {
|
|
throw new HarvestNotReadyError();
|
|
}
|
|
if (harvest.downloadUrlExpiresAt && harvest.downloadUrlExpiresAt < new Date()) {
|
|
throw new HarvestExpiredError();
|
|
}
|
|
const ZIP_EXPIRY_MS = ms('7 days');
|
|
const downloadUrl = await buildHarvestDownloadUrl({
|
|
userId,
|
|
harvestId,
|
|
storageKey: harvest.storageKey,
|
|
expiresInSeconds: ZIP_EXPIRY_MS / 1000,
|
|
storageService,
|
|
});
|
|
const expiresAt = new Date(Date.now() + ZIP_EXPIRY_MS);
|
|
return {
|
|
download_url: downloadUrl,
|
|
expires_at: expiresAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
async streamHarvestDownload(params: {
|
|
harvestId: bigint;
|
|
token: string;
|
|
range?: string;
|
|
storageService: IStorageService;
|
|
}): Promise<{
|
|
body: Readable;
|
|
contentLength: number;
|
|
contentRange?: string | null;
|
|
contentType?: string | null;
|
|
filename: string;
|
|
} | null> {
|
|
if (Config.presignedHarvestDownloadsEnabled) {
|
|
return null;
|
|
}
|
|
const payload = verifyHarvestDownloadToken(params.token, Config.auth.connectionInitiationSecret);
|
|
if (!payload || payload.harvestId !== params.harvestId.toString()) {
|
|
Logger.debug({harvestId: params.harvestId.toString()}, 'Harvest download rejected: invalid or expired token');
|
|
return null;
|
|
}
|
|
let userId: UserID;
|
|
try {
|
|
userId = createUserID(BigInt(payload.userId));
|
|
} catch {
|
|
return null;
|
|
}
|
|
const harvestRepository = new UserHarvestRepository();
|
|
const harvest = await harvestRepository.findByUserAndHarvestId(userId, params.harvestId);
|
|
if (!harvest?.completedAt || !harvest.storageKey || harvest.failedAt) {
|
|
return null;
|
|
}
|
|
if (harvest.downloadUrlExpiresAt && harvest.downloadUrlExpiresAt < new Date()) {
|
|
return null;
|
|
}
|
|
if (harvest.storageKey !== payload.storageKey) {
|
|
Logger.debug({harvestId: params.harvestId.toString()}, 'Harvest download rejected: storage key mismatch');
|
|
return null;
|
|
}
|
|
const object = await params.storageService.streamObject({
|
|
bucket: Config.s3.buckets.harvests,
|
|
key: harvest.storageKey,
|
|
range: params.range,
|
|
});
|
|
if (!object) {
|
|
return null;
|
|
}
|
|
return {
|
|
body: object.body,
|
|
contentLength: object.contentLength,
|
|
contentRange: object.contentRange,
|
|
contentType: object.contentType ?? 'application/zip',
|
|
filename: `fluxer-data-${params.harvestId}.zip`,
|
|
};
|
|
}
|
|
|
|
async requestBulkMessageDeletion(params: {userId: UserID; delayMs?: number}): Promise<void> {
|
|
const {userId, delayMs = ms('1 day')} = params;
|
|
const scheduledAt = new Date(Date.now() + delayMs);
|
|
const user = await this.userRepository.findUniqueAssert(userId);
|
|
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.userRepository.patchUpsert(
|
|
userId,
|
|
{
|
|
pending_bulk_message_deletion_at: scheduledAt,
|
|
pending_bulk_message_deletion_channel_count: counts.channelCount,
|
|
pending_bulk_message_deletion_message_count: counts.messageCount,
|
|
},
|
|
user.toRow(),
|
|
);
|
|
await this.bulkMessageDeletionQueue.scheduleDeletion(userId, scheduledAt);
|
|
await this.updatePropagator.dispatchUserUpdate(updatedUser);
|
|
}
|
|
|
|
async bulkDeleteSelfMessagesImmediate(params: {userId: UserID; filter: BulkDeleteSelfMessagesFilter}): Promise<void> {
|
|
const {userId, filter} = params;
|
|
Logger.debug({userId: userId.toString(), scope: filter.scope}, 'Enqueueing immediate bulk self message deletion');
|
|
await this.workerService.addJob(
|
|
'bulkDeleteSelfMessagesImmediate',
|
|
{
|
|
userId: userId.toString(),
|
|
filter: serializeSelfMessageFilter(filter),
|
|
},
|
|
{maxAttempts: 5},
|
|
);
|
|
}
|
|
|
|
async cancelBulkMessageDeletion(userId: UserID): Promise<void> {
|
|
Logger.debug({userId: userId.toString()}, 'Canceling pending bulk message deletion');
|
|
const user = await this.userRepository.findUniqueAssert(userId);
|
|
if ((user.flags & UserFlags.SPAMMER) !== 0n) return;
|
|
const updatedUser = await this.userRepository.patchUpsert(
|
|
userId,
|
|
{
|
|
pending_bulk_message_deletion_at: null,
|
|
pending_bulk_message_deletion_channel_count: null,
|
|
pending_bulk_message_deletion_message_count: null,
|
|
},
|
|
user.toRow(),
|
|
);
|
|
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 lastMessageId: MessageID | undefined;
|
|
const channels = new Set<string>();
|
|
let messageCount = 0;
|
|
while (true) {
|
|
const messageRefs = await this.channelRepository.listMessagesByAuthor(userId, CHUNK_SIZE, lastMessageId);
|
|
if (messageRefs.length === 0) {
|
|
break;
|
|
}
|
|
for (const {channelId, messageId} of messageRefs) {
|
|
if (snowflakeToDate(messageId).getTime() > cutoffMs) {
|
|
continue;
|
|
}
|
|
channels.add(channelId.toString());
|
|
messageCount++;
|
|
}
|
|
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()},
|
|
})
|
|
.catch((error) => {
|
|
Logger.error(
|
|
{userId: userId.toString(), messageId: messageId.toString(), error},
|
|
'Failed to dispatch RECENT_MENTION_DELETE',
|
|
);
|
|
return null;
|
|
});
|
|
}
|
|
|
|
async dispatchSavedMessageCreate({
|
|
userId,
|
|
message,
|
|
}: {
|
|
userId: UserID;
|
|
message: Message;
|
|
userCacheService: UserCacheService;
|
|
requestCache: RequestCache;
|
|
}): Promise<void> {
|
|
const data = (await this.buildMessageResponsesForUser(userId, [message]))[0];
|
|
if (!data) return;
|
|
await this.gatewayService
|
|
.dispatchPresence({
|
|
userId,
|
|
event: 'SAVED_MESSAGE_CREATE',
|
|
data,
|
|
})
|
|
.catch((error) => {
|
|
Logger.error(
|
|
{userId: userId.toString(), messageId: message.id.toString(), error},
|
|
'Failed to dispatch SAVED_MESSAGE_CREATE',
|
|
);
|
|
return null;
|
|
});
|
|
}
|
|
|
|
async buildMessageResponsesForUser(userId: UserID, messages: Array<Message>): Promise<Array<MessageResponse>> {
|
|
if (messages.length === 0) return [];
|
|
const channelIds = Array.from(new Set(messages.map((message) => message.channelId.toString())));
|
|
const channels = await this.channelRepository.listChannels(
|
|
channelIds.map((channelId) => createChannelID(BigInt(channelId))),
|
|
);
|
|
const channelById = new Map(channels.map((channel) => [channel.id.toString(), channel] as const));
|
|
return createMessageResponseDataService().buildMessagesForChannels({
|
|
userId,
|
|
messages,
|
|
channelById,
|
|
});
|
|
}
|
|
|
|
async dispatchSavedMessageDelete({userId, messageId}: {userId: UserID; messageId: MessageID}): Promise<void> {
|
|
await this.gatewayService
|
|
.dispatchPresence({
|
|
userId,
|
|
event: 'SAVED_MESSAGE_DELETE',
|
|
data: {message_id: messageId.toString()},
|
|
})
|
|
.catch((error) => {
|
|
Logger.error(
|
|
{userId: userId.toString(), messageId: messageId.toString(), error},
|
|
'Failed to dispatch SAVED_MESSAGE_DELETE',
|
|
);
|
|
return null;
|
|
});
|
|
}
|
|
}
|