initial commit
This commit is contained in:
48
fluxer_api/Dockerfile
Normal file
48
fluxer_api/Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
||||
FROM node:24-bookworm-slim AS base
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.26.0 --activate
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
gcc \
|
||||
libssl-dev \
|
||||
pkg-config \
|
||||
openssl \
|
||||
libvips-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm fetch --prod && pnpm install --frozen-lockfile --prod --offline
|
||||
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
openssl \
|
||||
libimage-exiftool-perl \
|
||||
libvips \
|
||||
libvips-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.26.0 --activate
|
||||
|
||||
COPY --from=deps /usr/src/app/node_modules ./node_modules
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY tsconfig.json tsconfig.worker.json ./
|
||||
COPY src ./src
|
||||
|
||||
RUN mkdir -p /usr/src/app/.cache/corepack && \
|
||||
chown -R nobody:nogroup /usr/src/app
|
||||
|
||||
ENV HOME=/usr/src/app
|
||||
ENV COREPACK_HOME=/usr/src/app/.cache/corepack
|
||||
|
||||
USER nobody
|
||||
|
||||
EXPOSE 8080
|
||||
11
fluxer_api/knip.json
Normal file
11
fluxer_api/knip.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@latest/schema.json",
|
||||
"entry": ["src/worker/tasks/*.ts"],
|
||||
"ignoreDependencies": ["pino-pretty"],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
"ignore": ["src/errors/*.ts", "src/models/PrivateChannel.ts", "src/infrastructure/IAssetDeletionQueue.ts"],
|
||||
"rules": {
|
||||
"exports": "warn",
|
||||
"types": "warn"
|
||||
}
|
||||
}
|
||||
91
fluxer_api/package.json
Normal file
91
fluxer_api/package.json
Normal file
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"name": "fluxer_api",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --clear-screen=false src/App.ts",
|
||||
"dev:worker": "tsx watch --clear-screen=false src/worker/Worker.ts",
|
||||
"knip": "knip",
|
||||
"start": "tsx src/App.ts",
|
||||
"start:worker": "tsx src/worker/Worker.ts",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.18.8",
|
||||
"@aws-sdk/client-s3": "3.958.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.958.0",
|
||||
"@hono/node-server": "1.19.7",
|
||||
"@messageformat/core": "3.4.0",
|
||||
"@sendgrid/mail": "8.1.6",
|
||||
"@sentry/node": "10.32.1",
|
||||
"@simplewebauthn/server": "13.2.2",
|
||||
"@tsconfig/node22": "22.0.5",
|
||||
"@tsconfig/strictest": "2.0.8",
|
||||
"archiver": "7.0.1",
|
||||
"argon2": "0.44.0",
|
||||
"cassandra-driver": "4.8.0",
|
||||
"cron-parser": "^5.4.0",
|
||||
"css-select": "6.0.0",
|
||||
"domhandler": "5.0.3",
|
||||
"emoji-regex": "10.6.0",
|
||||
"graphile-worker": "0.16.6",
|
||||
"hono": "4.11.2",
|
||||
"html-entities": "2.6.0",
|
||||
"htmlparser2": "10.0.0",
|
||||
"idna-uts46-hx": "6.1.0",
|
||||
"ioredis": "5.8.2",
|
||||
"jose": "6.1.3",
|
||||
"livekit-server-sdk": "2.15.0",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "^3.7.2",
|
||||
"magic-bytes.js": "1.12.1",
|
||||
"maxmind": "^5.0.1",
|
||||
"meilisearch": "0.54.0",
|
||||
"mime": "4.1.0",
|
||||
"module-alias": "2.2.3",
|
||||
"pg": "8.16.3",
|
||||
"pino": "10.1.0",
|
||||
"pino-pretty": "13.1.3",
|
||||
"sharp": "0.34.5",
|
||||
"stripe": "20.1.0",
|
||||
"tempy": "3.1.0",
|
||||
"transliteration": "2.6.0",
|
||||
"tsx": "4.21.0",
|
||||
"twilio": "5.11.1",
|
||||
"ua-parser-js": "2.0.7",
|
||||
"uint8array-extras": "1.5.0",
|
||||
"undici": "7.16.0",
|
||||
"unique-names-generator": "4.7.1",
|
||||
"validator": "13.15.26",
|
||||
"zod": "4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "7.0.0",
|
||||
"@types/jsdom": "27.0.0",
|
||||
"@types/lodash": "4.17.21",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/node": "25.0.3",
|
||||
"@types/pg": "8.16.0",
|
||||
"@types/validator": "13.15.10",
|
||||
"@vitest/coverage-v8": "4.0.16",
|
||||
"@vitest/ui": "4.0.16",
|
||||
"jsdom": "27.3.0",
|
||||
"knip": "5.77.1",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.16"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"argon2",
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
"sharp"
|
||||
]
|
||||
},
|
||||
"_moduleAliases": {
|
||||
"~": "src"
|
||||
}
|
||||
}
|
||||
6752
fluxer_api/pnpm-lock.yaml
generated
Normal file
6752
fluxer_api/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
333
fluxer_api/src/App.ts
Normal file
333
fluxer_api/src/App.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/*
|
||||
* 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 '~/instrument';
|
||||
|
||||
import {serve} from '@hono/node-server';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {Hono} from 'hono';
|
||||
import {logger} from 'hono/logger';
|
||||
import {Redis} from 'ioredis';
|
||||
import {registerAdminControllers} from '~/admin/controllers';
|
||||
import {AuthController} from '~/auth/AuthController';
|
||||
import {Config} from '~/Config';
|
||||
import {ChannelController} from '~/channel/ChannelController';
|
||||
import type {StreamPreviewService} from '~/channel/services/StreamPreviewService';
|
||||
import {DebugController} from '~/debug/DebugController';
|
||||
import {DownloadController} from '~/download/DownloadController';
|
||||
import {AppErrorHandler, AppNotFoundHandler} from '~/Errors';
|
||||
import {InvalidApiOriginError} from '~/errors/InvalidApiOriginError';
|
||||
import {FavoriteMemeController} from '~/favorite_meme/FavoriteMemeController';
|
||||
import {GatewayController} from '~/gateway/GatewayController';
|
||||
import {GuildController} from '~/guild/GuildController';
|
||||
import {initializeMetricsService} from '~/infrastructure/MetricsService';
|
||||
import {StorageService} from '~/infrastructure/StorageService';
|
||||
import {InstanceController} from '~/instance/InstanceController';
|
||||
import {InviteController} from '~/invite/InviteController';
|
||||
import {Logger} from '~/Logger';
|
||||
import {AuditLogMiddleware} from '~/middleware/AuditLogMiddleware';
|
||||
import {IpBanMiddleware, ipBanCache} from '~/middleware/IpBanMiddleware';
|
||||
import {MetricsMiddleware} from '~/middleware/MetricsMiddleware';
|
||||
import {PendingManualVerificationMiddleware} from '~/middleware/PendingManualVerificationMiddleware';
|
||||
import {RequestCacheMiddleware} from '~/middleware/RequestCacheMiddleware';
|
||||
import {RequireXForwardedForMiddleware} from '~/middleware/RequireXForwardedForMiddleware';
|
||||
import {ensureVoiceResourcesInitialized} from '~/middleware/ServiceMiddleware';
|
||||
import {UserMiddleware} from '~/middleware/UserMiddleware';
|
||||
import {initializeOAuth} from '~/oauth/init';
|
||||
import {OAuth2ApplicationsController} from '~/oauth/OAuth2ApplicationsController';
|
||||
import {OAuth2Controller} from '~/oauth/OAuth2Controller';
|
||||
import {registerPackControllers} from '~/pack/controllers';
|
||||
import {ReadStateController} from '~/read_state/ReadStateController';
|
||||
import {ReportController} from '~/report/ReportController';
|
||||
import {RpcController} from '~/rpc/RpcController';
|
||||
import {SearchController} from '~/search/controllers/SearchController';
|
||||
import {StripeController} from '~/stripe/StripeController';
|
||||
import {VisionarySlotInitializer} from '~/stripe/VisionarySlotInitializer';
|
||||
import {TenorController} from '~/tenor/TenorController';
|
||||
import {TestHarnessController} from '~/test/TestHarnessController';
|
||||
import {ThemeController} from '~/theme/ThemeController';
|
||||
import {UserController} from '~/user/UserController';
|
||||
import {VoiceDataInitializer} from '~/voice/VoiceDataInitializer';
|
||||
import {WebhookController} from '~/webhook/WebhookController';
|
||||
import type {AdminService} from './admin/AdminService';
|
||||
import type {AdminArchiveService} from './admin/services/AdminArchiveService';
|
||||
import type {AuthService} from './auth/AuthService';
|
||||
import type {AuthMfaService} from './auth/services/AuthMfaService';
|
||||
import type {DesktopHandoffService} from './auth/services/DesktopHandoffService';
|
||||
import type {UserID} from './BrandedTypes';
|
||||
import type {IChannelRepository} from './channel/IChannelRepository';
|
||||
import type {ChannelService} from './channel/services/ChannelService';
|
||||
import type {ScheduledMessageService} from './channel/services/ScheduledMessageService';
|
||||
import type {FavoriteMemeService} from './favorite_meme/FavoriteMemeService';
|
||||
import type {FeatureFlagService} from './feature_flag/FeatureFlagService';
|
||||
import type {GuildService} from './guild/services/GuildService';
|
||||
import type {EmbedService} from './infrastructure/EmbedService';
|
||||
import type {EntityAssetService} from './infrastructure/EntityAssetService';
|
||||
import type {ICacheService} from './infrastructure/ICacheService';
|
||||
import type {IEmailService} from './infrastructure/IEmailService';
|
||||
import type {IGatewayService} from './infrastructure/IGatewayService';
|
||||
import type {IMediaService} from './infrastructure/IMediaService';
|
||||
import type {IRateLimitService} from './infrastructure/IRateLimitService';
|
||||
import type {IStorageService} from './infrastructure/IStorageService';
|
||||
import type {ITenorService} from './infrastructure/ITenorService';
|
||||
import type {LiveKitWebhookService} from './infrastructure/LiveKitWebhookService';
|
||||
import {RedisAccountDeletionQueueService} from './infrastructure/RedisAccountDeletionQueueService';
|
||||
import type {RedisActivityTracker} from './infrastructure/RedisActivityTracker';
|
||||
import type {SnowflakeService} from './infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from './infrastructure/UserCacheService';
|
||||
import type {InviteService} from './invite/InviteService';
|
||||
import type {AuthSession, User} from './Models';
|
||||
import type {RequestCache} from './middleware/RequestCacheMiddleware';
|
||||
import {ServiceMiddleware} from './middleware/ServiceMiddleware';
|
||||
import type {ApplicationService} from './oauth/ApplicationService';
|
||||
import type {BotAuthService} from './oauth/BotAuthService';
|
||||
import type {OAuth2Service} from './oauth/OAuth2Service';
|
||||
import type {IApplicationRepository} from './oauth/repositories/IApplicationRepository';
|
||||
import type {IOAuth2TokenRepository} from './oauth/repositories/IOAuth2TokenRepository';
|
||||
import type {PackRepository} from './pack/PackRepository';
|
||||
import type {PackService} from './pack/PackService';
|
||||
import type {ReadStateService} from './read_state/ReadStateService';
|
||||
import type {ReportService} from './report/ReportService';
|
||||
import type {RpcService} from './rpc/RpcService';
|
||||
import type {StripeService} from './stripe/StripeService';
|
||||
import type {IUserRepository} from './user/IUserRepository';
|
||||
import type {EmailChangeService} from './user/services/EmailChangeService';
|
||||
import {UserRepository} from './user/UserRepository';
|
||||
import type {UserService} from './user/UserService';
|
||||
import type {SendGridWebhookService} from './webhook/SendGridWebhookService';
|
||||
import type {WebhookService} from './webhook/WebhookService';
|
||||
import type {IWorkerService} from './worker/IWorkerService';
|
||||
|
||||
export interface HonoEnv {
|
||||
Variables: {
|
||||
user: User;
|
||||
adminService: AdminService;
|
||||
adminArchiveService: AdminArchiveService;
|
||||
adminUserId: UserID;
|
||||
adminUserAcls: Set<string>;
|
||||
authTokenType?: 'session' | 'bearer' | 'bot';
|
||||
authViaCookie?: boolean;
|
||||
authToken?: string;
|
||||
authUserId?: string;
|
||||
oauthBearerToken?: string;
|
||||
oauthBearerScopes?: Set<string>;
|
||||
oauthBearerUserId?: UserID;
|
||||
auditLogReason: string | null;
|
||||
authMfaService: AuthMfaService;
|
||||
authService: AuthService;
|
||||
authSession: AuthSession;
|
||||
desktopHandoffService: DesktopHandoffService;
|
||||
cacheService: ICacheService;
|
||||
channelService: ChannelService;
|
||||
channelRepository: IChannelRepository;
|
||||
streamPreviewService: StreamPreviewService;
|
||||
emailService: IEmailService;
|
||||
emailChangeService: EmailChangeService;
|
||||
embedService: EmbedService;
|
||||
entityAssetService: EntityAssetService;
|
||||
favoriteMemeService: FavoriteMemeService;
|
||||
gatewayService: IGatewayService;
|
||||
guildService: GuildService;
|
||||
packService: PackService;
|
||||
packRepository: PackRepository;
|
||||
inviteService: InviteService;
|
||||
liveKitWebhookService?: LiveKitWebhookService;
|
||||
mediaService: IMediaService;
|
||||
rateLimitService: IRateLimitService;
|
||||
readStateService: ReadStateService;
|
||||
redisActivityTracker: RedisActivityTracker;
|
||||
reportService: ReportService;
|
||||
requestCache: RequestCache;
|
||||
rpcService: RpcService;
|
||||
snowflakeService: SnowflakeService;
|
||||
storageService: IStorageService;
|
||||
tenorService: ITenorService;
|
||||
userCacheService: UserCacheService;
|
||||
userRepository: IUserRepository;
|
||||
userService: UserService;
|
||||
sendGridWebhookService: SendGridWebhookService;
|
||||
webhookService: WebhookService;
|
||||
workerService: IWorkerService;
|
||||
scheduledMessageService: ScheduledMessageService;
|
||||
stripeService: StripeService;
|
||||
applicationService: ApplicationService;
|
||||
oauth2Service: OAuth2Service;
|
||||
applicationRepository: IApplicationRepository;
|
||||
oauth2TokenRepository: IOAuth2TokenRepository;
|
||||
botAuthService: BotAuthService;
|
||||
sudoModeValid: boolean;
|
||||
sudoModeToken: string | null;
|
||||
featureFlagService: FeatureFlagService;
|
||||
};
|
||||
}
|
||||
|
||||
export type HonoApp = typeof app;
|
||||
|
||||
const routes = new Hono<HonoEnv>({strict: true});
|
||||
|
||||
routes.use(
|
||||
logger((message: string, ...rest: Array<string>) => {
|
||||
Logger.info(rest.length > 0 ? `${message} ${rest.join(' ')}` : message);
|
||||
}),
|
||||
);
|
||||
|
||||
if (Config.nodeEnv === 'production') {
|
||||
routes.use('*', async (ctx, next) => {
|
||||
const host = ctx.req.header('host');
|
||||
if (ctx.req.method !== 'GET' && (host === 'web.fluxer.app' || host === 'web.canary.fluxer.app')) {
|
||||
const origin = ctx.req.header('origin');
|
||||
if (!origin || origin !== `https://${host}`) {
|
||||
throw new InvalidApiOriginError();
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
routes.use(IpBanMiddleware);
|
||||
routes.use(MetricsMiddleware);
|
||||
routes.use(AuditLogMiddleware);
|
||||
routes.use(RequireXForwardedForMiddleware());
|
||||
routes.use(RequestCacheMiddleware);
|
||||
routes.use(ServiceMiddleware);
|
||||
routes.use(UserMiddleware);
|
||||
routes.use(PendingManualVerificationMiddleware);
|
||||
|
||||
routes.use('*', async (ctx, next) => {
|
||||
const user = ctx.get('user');
|
||||
const clientIp = ctx.req.header('X-Forwarded-For')?.split(',')[0]?.trim();
|
||||
|
||||
Sentry.setUser({
|
||||
id: user?.id.toString(),
|
||||
username: user?.username,
|
||||
email: user?.email ?? undefined,
|
||||
ip_address: clientIp,
|
||||
});
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
routes.onError(AppErrorHandler);
|
||||
routes.notFound(AppNotFoundHandler);
|
||||
|
||||
routes.get('/_health', async (ctx) => ctx.text('OK'));
|
||||
|
||||
GatewayController(routes);
|
||||
|
||||
DebugController(routes);
|
||||
registerAdminControllers(routes);
|
||||
AuthController(routes);
|
||||
ChannelController(routes);
|
||||
InstanceController(routes);
|
||||
DownloadController(routes);
|
||||
FavoriteMemeController(routes);
|
||||
InviteController(routes);
|
||||
registerPackControllers(routes);
|
||||
ReadStateController(routes);
|
||||
ReportController(routes);
|
||||
RpcController(routes);
|
||||
GuildController(routes);
|
||||
SearchController(routes);
|
||||
TenorController(routes);
|
||||
ThemeController(routes);
|
||||
if (Config.dev.testModeEnabled) {
|
||||
TestHarnessController(routes);
|
||||
}
|
||||
UserController(routes);
|
||||
WebhookController(routes);
|
||||
OAuth2Controller(routes);
|
||||
OAuth2ApplicationsController(routes);
|
||||
|
||||
if (!Config.instance.selfHosted) {
|
||||
StripeController(routes);
|
||||
}
|
||||
|
||||
const app = new Hono<HonoEnv>({strict: true});
|
||||
app.route('/v1', routes);
|
||||
app.route('/', routes);
|
||||
|
||||
app.onError(AppErrorHandler);
|
||||
app.notFound(AppNotFoundHandler);
|
||||
|
||||
await ipBanCache.initialize();
|
||||
|
||||
initializeMetricsService(Config.metrics.host ?? null);
|
||||
|
||||
await initializeOAuth();
|
||||
|
||||
try {
|
||||
const redis = new Redis(Config.redis.url);
|
||||
const userRepository = new UserRepository();
|
||||
const redisDeletionQueue = new RedisAccountDeletionQueueService(redis, userRepository);
|
||||
|
||||
if (await redisDeletionQueue.needsRebuild()) {
|
||||
Logger.warn('Redis deletion queue needs rebuild, rebuilding...');
|
||||
await redisDeletionQueue.rebuildState();
|
||||
} else {
|
||||
Logger.info('Redis deletion queue state is healthy');
|
||||
}
|
||||
|
||||
await redis.quit();
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Failed to verify Redis deletion queue state');
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (Config.nodeEnv === 'development') {
|
||||
const storageService = new StorageService();
|
||||
await storageService.createBucket(Config.s3.buckets.cdn, true);
|
||||
await storageService.createBucket(Config.s3.buckets.uploads);
|
||||
await storageService.createBucket(Config.s3.buckets.reports);
|
||||
await storageService.createBucket(Config.s3.buckets.harvests);
|
||||
await storageService.createBucket(Config.s3.buckets.downloads, true);
|
||||
await storageService.purgeBucket(Config.s3.buckets.uploads);
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
search_enabled: Config.search.enabled,
|
||||
meilisearch_url: Config.search.url,
|
||||
meilisearch_api_key_set: !!Config.search.apiKey,
|
||||
},
|
||||
'Search configuration loaded',
|
||||
);
|
||||
|
||||
if (Config.search.enabled) {
|
||||
const {initializeMeilisearch} = await import('~/Meilisearch');
|
||||
await initializeMeilisearch();
|
||||
}
|
||||
|
||||
if (Config.voice.enabled && Config.voice.autoCreateDummyData) {
|
||||
const voiceDataInitializer = new VoiceDataInitializer();
|
||||
await voiceDataInitializer.initialize();
|
||||
await ensureVoiceResourcesInitialized();
|
||||
}
|
||||
|
||||
if (Config.dev.testModeEnabled && Config.stripe.enabled) {
|
||||
const visionarySlotInitializer = new VisionarySlotInitializer();
|
||||
await visionarySlotInitializer.initialize();
|
||||
}
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
hostname: '0.0.0.0',
|
||||
port: Config.port,
|
||||
});
|
||||
|
||||
Logger.info({port: Config.port}, `Fluxer API listening on http://0.0.0.0:${Config.port}`);
|
||||
124
fluxer_api/src/BrandedTypes.ts
Normal file
124
fluxer_api/src/BrandedTypes.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
declare const __brand: unique symbol;
|
||||
|
||||
type Brand<T, TBrand extends string> = T & {readonly [__brand]: TBrand};
|
||||
interface BrandedValue {
|
||||
readonly [__brand]: unknown;
|
||||
}
|
||||
|
||||
const brand = <TValue extends string | bigint, TBrand extends string>(value: TValue): Brand<TValue, TBrand> =>
|
||||
value as Brand<TValue, TBrand>;
|
||||
|
||||
const rebrand = <TValue extends string | bigint, TBrand extends string>(
|
||||
value: Brand<TValue, string>,
|
||||
): Brand<TValue, TBrand> => value as Brand<TValue, TBrand>;
|
||||
|
||||
export type UserID = Brand<bigint, 'UserID'>;
|
||||
export type GuildID = Brand<bigint, 'GuildID'>;
|
||||
export type ChannelID = Brand<bigint, 'ChannelID'>;
|
||||
export type MessageID = Brand<bigint, 'MessageID'>;
|
||||
export type RoleID = Brand<bigint, 'RoleID'>;
|
||||
export type EmojiID = Brand<bigint, 'EmojiID'>;
|
||||
export type WebhookID = Brand<bigint, 'WebhookID'>;
|
||||
export type AttachmentID = Brand<bigint, 'AttachmentID'>;
|
||||
export type StickerID = Brand<bigint, 'StickerID'>;
|
||||
export type ReportID = Brand<bigint, 'ReportID'>;
|
||||
export type MemeID = Brand<bigint, 'MemeID'>;
|
||||
export type ApplicationID = Brand<bigint, 'ApplicationID'>;
|
||||
|
||||
export type InviteCode = Brand<string, 'InviteCode'>;
|
||||
export type VanityURLCode = Brand<string, 'VanityURLCode'>;
|
||||
export type BetaCode = Brand<string, 'BetaCode'>;
|
||||
export type EmailVerificationToken = Brand<string, 'EmailVerificationToken'>;
|
||||
export type PasswordResetToken = Brand<string, 'PasswordResetToken'>;
|
||||
export type EmailRevertToken = Brand<string, 'EmailRevertToken'>;
|
||||
export type IpAuthorizationToken = Brand<string, 'IpAuthorizationToken'>;
|
||||
export type IpAuthorizationTicket = Brand<string, 'IpAuthorizationTicket'>;
|
||||
type MfaTicket = Brand<string, 'MfaTicket'>;
|
||||
export type WebhookToken = Brand<string, 'WebhookToken'>;
|
||||
export type MfaBackupCode = Brand<string, 'MfaBackupCode'>;
|
||||
export type PhoneVerificationToken = Brand<string, 'PhoneVerificationToken'>;
|
||||
|
||||
export const createUserID = <T extends bigint>(id: T extends BrandedValue ? never : T): UserID =>
|
||||
brand<T, 'UserID'>(id);
|
||||
export const createGuildID = <T extends bigint>(id: T extends BrandedValue ? never : T): GuildID =>
|
||||
brand<T, 'GuildID'>(id);
|
||||
export const createChannelID = <T extends bigint>(id: T extends BrandedValue ? never : T): ChannelID =>
|
||||
brand<T, 'ChannelID'>(id);
|
||||
export const createMessageID = <T extends bigint>(id: T extends BrandedValue ? never : T): MessageID =>
|
||||
brand<T, 'MessageID'>(id);
|
||||
export const createRoleID = <T extends bigint>(id: T extends BrandedValue ? never : T): RoleID =>
|
||||
brand<T, 'RoleID'>(id);
|
||||
export const createEmojiID = <T extends bigint>(id: T extends BrandedValue ? never : T): EmojiID =>
|
||||
brand<T, 'EmojiID'>(id);
|
||||
export const createWebhookID = <T extends bigint>(id: T extends BrandedValue ? never : T): WebhookID =>
|
||||
brand<T, 'WebhookID'>(id);
|
||||
export const createAttachmentID = <T extends bigint>(id: T extends BrandedValue ? never : T): AttachmentID =>
|
||||
brand<T, 'AttachmentID'>(id);
|
||||
export const createStickerID = <T extends bigint>(id: T extends BrandedValue ? never : T): StickerID =>
|
||||
brand<T, 'StickerID'>(id);
|
||||
export const createReportID = <T extends bigint>(id: T extends BrandedValue ? never : T): ReportID =>
|
||||
brand<T, 'ReportID'>(id);
|
||||
export const createMemeID = <T extends bigint>(id: T extends BrandedValue ? never : T): MemeID =>
|
||||
brand<T, 'MemeID'>(id);
|
||||
export const createApplicationID = <T extends bigint>(id: T extends BrandedValue ? never : T): ApplicationID =>
|
||||
brand<T, 'ApplicationID'>(id);
|
||||
|
||||
export const createInviteCode = <T extends string>(code: T extends BrandedValue ? never : T): InviteCode =>
|
||||
brand<T, 'InviteCode'>(code);
|
||||
export const createVanityURLCode = <T extends string>(code: T extends BrandedValue ? never : T): VanityURLCode =>
|
||||
brand<T, 'VanityURLCode'>(code);
|
||||
export const createBetaCode = <T extends string>(code: T extends BrandedValue ? never : T): BetaCode =>
|
||||
brand<T, 'BetaCode'>(code);
|
||||
export const createEmailVerificationToken = <T extends string>(
|
||||
token: T extends BrandedValue ? never : T,
|
||||
): EmailVerificationToken => brand<T, 'EmailVerificationToken'>(token);
|
||||
export const createPasswordResetToken = <T extends string>(
|
||||
token: T extends BrandedValue ? never : T,
|
||||
): PasswordResetToken => brand<T, 'PasswordResetToken'>(token);
|
||||
export const createEmailRevertToken = <T extends string>(token: T extends BrandedValue ? never : T): EmailRevertToken =>
|
||||
brand<T, 'EmailRevertToken'>(token);
|
||||
export const createIpAuthorizationToken = <T extends string>(
|
||||
token: T extends BrandedValue ? never : T,
|
||||
): IpAuthorizationToken => brand<T, 'IpAuthorizationToken'>(token);
|
||||
export const createIpAuthorizationTicket = <T extends string>(
|
||||
ticket: T extends BrandedValue ? never : T,
|
||||
): IpAuthorizationTicket => brand<T, 'IpAuthorizationTicket'>(ticket);
|
||||
export const createMfaTicket = <T extends string>(ticket: T extends BrandedValue ? never : T): MfaTicket =>
|
||||
brand<T, 'MfaTicket'>(ticket);
|
||||
export const createWebhookToken = <T extends string>(token: T extends BrandedValue ? never : T): WebhookToken =>
|
||||
brand<T, 'WebhookToken'>(token);
|
||||
export const createMfaBackupCode = <T extends string>(code: T extends BrandedValue ? never : T): MfaBackupCode =>
|
||||
brand<T, 'MfaBackupCode'>(code);
|
||||
export const createPhoneVerificationToken = <T extends string>(
|
||||
token: T extends BrandedValue ? never : T,
|
||||
): PhoneVerificationToken => brand<T, 'PhoneVerificationToken'>(token);
|
||||
|
||||
export const createUserIDSet = (ids: Set<bigint>): Set<UserID> => ids as Set<UserID>;
|
||||
export const createGuildIDSet = (ids: Set<bigint>): Set<GuildID> => ids as Set<GuildID>;
|
||||
export const createRoleIDSet = (ids: Set<bigint>): Set<RoleID> => ids as Set<RoleID>;
|
||||
export const guildIdToRoleId = (guildId: GuildID): RoleID => rebrand<bigint, 'RoleID'>(guildId);
|
||||
export const channelIdToUserId = (channelId: ChannelID): UserID => rebrand<bigint, 'UserID'>(channelId);
|
||||
export const userIdToChannelId = (userId: UserID): ChannelID => rebrand<bigint, 'ChannelID'>(userId);
|
||||
export const vanityCodeToInviteCode = (vanityCode: VanityURLCode): InviteCode =>
|
||||
rebrand<string, 'InviteCode'>(vanityCode);
|
||||
export const channelIdToMessageId = (channelId: ChannelID): MessageID => rebrand<bigint, 'MessageID'>(channelId);
|
||||
export const applicationIdToUserId = (applicationId: ApplicationID): UserID => rebrand<bigint, 'UserID'>(applicationId);
|
||||
545
fluxer_api/src/Config.ts
Normal file
545
fluxer_api/src/Config.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
/*
|
||||
* 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 process from 'node:process';
|
||||
|
||||
import {z} from '~/Schema';
|
||||
|
||||
function required(key: string): string {
|
||||
const value = process.env[key];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optional(key: string): string | undefined {
|
||||
return process.env[key] || undefined;
|
||||
}
|
||||
|
||||
function optionalInt(key: string, defaultValue: number): number {
|
||||
const value = process.env[key];
|
||||
if (!value) return defaultValue;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? defaultValue : parsed;
|
||||
}
|
||||
|
||||
function optionalBool(key: string, defaultValue = false): boolean {
|
||||
const value = process.env[key];
|
||||
if (!value) return defaultValue;
|
||||
return value.toLowerCase() === 'true' || value === '1';
|
||||
}
|
||||
|
||||
function extractHostname(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
throw new Error(`Invalid URL: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
if (value.length > 1 && value.endsWith('/')) {
|
||||
return trimTrailingSlash(value.slice(0, -1));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (trimmed === '' || trimmed === '/') return '';
|
||||
const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
return trimTrailingSlash(withLeadingSlash);
|
||||
}
|
||||
|
||||
function appendPath(endpoint: string, path: string): string {
|
||||
const cleanEndpoint = trimTrailingSlash(endpoint);
|
||||
const normalizedPath = normalizePath(path);
|
||||
return normalizedPath ? `${cleanEndpoint}${normalizedPath}` : cleanEndpoint;
|
||||
}
|
||||
|
||||
function parseCommaSeparated(value: string): Array<string> {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
const ConfigSchema = z.object({
|
||||
nodeEnv: z.enum(['development', 'production']),
|
||||
port: z.number(),
|
||||
|
||||
postgres: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
|
||||
cassandra: z.object({
|
||||
hosts: z.string(),
|
||||
keyspace: z.string(),
|
||||
localDc: z.string(),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
}),
|
||||
|
||||
redis: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
|
||||
gateway: z.object({
|
||||
rpcHost: z.string(),
|
||||
rpcPort: z.number(),
|
||||
rpcSecret: z.string(),
|
||||
}),
|
||||
|
||||
mediaProxy: z.object({
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
secretKey: z.string(),
|
||||
}),
|
||||
|
||||
geoip: z.object({
|
||||
provider: z.enum(['ipinfo', 'maxmind']),
|
||||
host: z.string().optional(),
|
||||
maxmindDbPath: z.string().optional(),
|
||||
}),
|
||||
|
||||
endpoints: z.object({
|
||||
apiPublic: z.string(),
|
||||
apiClient: z.string(),
|
||||
webApp: z.string(),
|
||||
gateway: z.string(),
|
||||
media: z.string(),
|
||||
cdn: z.string(),
|
||||
marketing: z.string(),
|
||||
admin: z.string(),
|
||||
invite: z.string(),
|
||||
gift: z.string(),
|
||||
}),
|
||||
|
||||
hosts: z.object({
|
||||
invite: z.string(),
|
||||
gift: z.string(),
|
||||
marketing: z.string(),
|
||||
unfurlIgnored: z.array(z.string()),
|
||||
}),
|
||||
|
||||
s3: z.object({
|
||||
endpoint: z.string(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
buckets: z.object({
|
||||
cdn: z.string(),
|
||||
uploads: z.string(),
|
||||
reports: z.string(),
|
||||
harvests: z.string(),
|
||||
downloads: z.string(),
|
||||
}),
|
||||
}),
|
||||
|
||||
email: z.object({
|
||||
enabled: z.boolean(),
|
||||
apiKey: z.string().optional(),
|
||||
webhookPublicKey: z.string().optional(),
|
||||
fromEmail: z.string(),
|
||||
fromName: z.string(),
|
||||
}),
|
||||
|
||||
sms: z.object({
|
||||
enabled: z.boolean(),
|
||||
accountSid: z.string().optional(),
|
||||
authToken: z.string().optional(),
|
||||
verifyServiceSid: z.string().optional(),
|
||||
}),
|
||||
|
||||
captcha: z.object({
|
||||
enabled: z.boolean(),
|
||||
provider: z.enum(['hcaptcha', 'turnstile', 'none']),
|
||||
hcaptcha: z
|
||||
.object({
|
||||
siteKey: z.string(),
|
||||
secretKey: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
turnstile: z
|
||||
.object({
|
||||
siteKey: z.string(),
|
||||
secretKey: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
|
||||
voice: z.object({
|
||||
enabled: z.boolean(),
|
||||
apiKey: z.string().optional(),
|
||||
apiSecret: z.string().optional(),
|
||||
webhookUrl: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
autoCreateDummyData: z.boolean(),
|
||||
}),
|
||||
|
||||
search: z.object({
|
||||
enabled: z.boolean(),
|
||||
url: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
}),
|
||||
|
||||
stripe: z.object({
|
||||
enabled: z.boolean(),
|
||||
secretKey: z.string().optional(),
|
||||
webhookSecret: z.string().optional(),
|
||||
prices: z
|
||||
.object({
|
||||
monthlyUsd: z.string().optional(),
|
||||
monthlyEur: z.string().optional(),
|
||||
yearlyUsd: z.string().optional(),
|
||||
yearlyEur: z.string().optional(),
|
||||
visionaryUsd: z.string().optional(),
|
||||
visionaryEur: z.string().optional(),
|
||||
giftVisionaryUsd: z.string().optional(),
|
||||
giftVisionaryEur: z.string().optional(),
|
||||
gift1MonthUsd: z.string().optional(),
|
||||
gift1MonthEur: z.string().optional(),
|
||||
gift1YearUsd: z.string().optional(),
|
||||
gift1YearEur: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
|
||||
cloudflare: z.object({
|
||||
purgeEnabled: z.boolean(),
|
||||
zoneId: z.string().optional(),
|
||||
apiToken: z.string().optional(),
|
||||
}),
|
||||
|
||||
alerts: z.object({
|
||||
webhookUrl: z.string().url().optional(),
|
||||
}),
|
||||
|
||||
clamav: z.object({
|
||||
enabled: z.boolean(),
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
failOpen: z.boolean(),
|
||||
}),
|
||||
|
||||
adminOauth2: z.object({
|
||||
clientId: z.string().optional(),
|
||||
clientSecret: z.string().optional(),
|
||||
redirectUri: z.string(),
|
||||
autoCreate: z.boolean(),
|
||||
}),
|
||||
|
||||
auth: z.object({
|
||||
sudoModeSecret: z.string(),
|
||||
passkeys: z.object({
|
||||
rpName: z.string(),
|
||||
rpId: z.string(),
|
||||
allowedOrigins: z.array(z.string()),
|
||||
}),
|
||||
}),
|
||||
|
||||
cookie: z.object({
|
||||
domain: z.string(),
|
||||
secure: z.boolean(),
|
||||
}),
|
||||
|
||||
tenor: z.object({
|
||||
apiKey: z.string().optional(),
|
||||
}),
|
||||
|
||||
youtube: z.object({
|
||||
apiKey: z.string().optional(),
|
||||
}),
|
||||
|
||||
instance: z.object({
|
||||
selfHosted: z.boolean(),
|
||||
autoJoinInviteCode: z.string().optional(),
|
||||
visionariesGuildId: z.string().optional(),
|
||||
operatorsGuildId: z.string().optional(),
|
||||
}),
|
||||
|
||||
dev: z.object({
|
||||
relaxRegistrationRateLimits: z.boolean(),
|
||||
disableRateLimits: z.boolean(),
|
||||
testModeEnabled: z.boolean(),
|
||||
testHarnessToken: z.string().optional(),
|
||||
}),
|
||||
|
||||
attachmentDecayEnabled: z.boolean(),
|
||||
|
||||
deletionGracePeriodHours: z.number(),
|
||||
inactivityDeletionThresholdDays: z.number().optional(),
|
||||
|
||||
push: z.object({
|
||||
publicVapidKey: z.string().optional(),
|
||||
}),
|
||||
|
||||
metrics: z.object({
|
||||
host: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
function loadConfig() {
|
||||
const apiPublicEndpoint = required('FLUXER_API_PUBLIC_ENDPOINT');
|
||||
const apiClientEndpoint = optional('FLUXER_API_CLIENT_ENDPOINT') || apiPublicEndpoint;
|
||||
const webAppEndpoint = required('FLUXER_APP_ENDPOINT');
|
||||
const gatewayEndpoint = required('FLUXER_GATEWAY_ENDPOINT');
|
||||
const mediaEndpoint = required('FLUXER_MEDIA_ENDPOINT');
|
||||
const cdnEndpoint = required('FLUXER_CDN_ENDPOINT');
|
||||
const marketingEndpoint = appendPath(required('FLUXER_MARKETING_ENDPOINT'), required('FLUXER_PATH_MARKETING'));
|
||||
const adminEndpoint = appendPath(required('FLUXER_ADMIN_ENDPOINT'), required('FLUXER_PATH_ADMIN'));
|
||||
const inviteEndpoint = required('FLUXER_INVITE_ENDPOINT');
|
||||
const giftEndpoint = required('FLUXER_GIFT_ENDPOINT');
|
||||
|
||||
const passkeyOriginsEnv = optional('PASSKEY_ALLOWED_ORIGINS');
|
||||
const passkeyAllowedOrigins = passkeyOriginsEnv
|
||||
? parseCommaSeparated(passkeyOriginsEnv)
|
||||
: Array.from(new Set([apiPublicEndpoint, webAppEndpoint, apiClientEndpoint]));
|
||||
|
||||
const testModeEnabled = optionalBool('FLUXER_TEST_MODE');
|
||||
const geoipProviderRaw = optional('GEOIP_PROVIDER')?.trim().toLowerCase();
|
||||
const geoipProvider = geoipProviderRaw === 'maxmind' ? 'maxmind' : 'ipinfo';
|
||||
const maxmindDbPath = optional('MAXMIND_DB_PATH');
|
||||
if (geoipProvider === 'maxmind' && !maxmindDbPath) {
|
||||
throw new Error('Missing required environment variable: MAXMIND_DB_PATH');
|
||||
}
|
||||
|
||||
return ConfigSchema.parse({
|
||||
nodeEnv: optional('NODE_ENV') || 'development',
|
||||
port: optionalInt('FLUXER_API_PORT', 8080),
|
||||
|
||||
postgres: {
|
||||
url: required('DATABASE_URL'),
|
||||
},
|
||||
|
||||
cassandra: {
|
||||
hosts: required('CASSANDRA_HOSTS'),
|
||||
keyspace: required('CASSANDRA_KEYSPACE'),
|
||||
localDc: optional('CASSANDRA_LOCAL_DC'),
|
||||
username: required('CASSANDRA_USERNAME'),
|
||||
password: required('CASSANDRA_PASSWORD'),
|
||||
},
|
||||
|
||||
redis: {
|
||||
url: required('REDIS_URL'),
|
||||
},
|
||||
|
||||
gateway: {
|
||||
rpcHost: optional('FLUXER_GATEWAY_RPC_HOST') || 'gateway',
|
||||
rpcPort: optionalInt('FLUXER_GATEWAY_RPC_PORT', 8081),
|
||||
rpcSecret: required('GATEWAY_RPC_SECRET'),
|
||||
},
|
||||
|
||||
mediaProxy: {
|
||||
host: optional('FLUXER_MEDIA_PROXY_HOST') || 'media',
|
||||
port: optionalInt('FLUXER_MEDIA_PROXY_PORT', 8080),
|
||||
secretKey: required('MEDIA_PROXY_SECRET_KEY'),
|
||||
},
|
||||
|
||||
geoip: {
|
||||
provider: geoipProvider,
|
||||
host: optional('GEOIP_HOST') || 'geoip',
|
||||
maxmindDbPath,
|
||||
},
|
||||
|
||||
endpoints: {
|
||||
apiPublic: apiPublicEndpoint,
|
||||
apiClient: apiClientEndpoint,
|
||||
webApp: webAppEndpoint,
|
||||
gateway: gatewayEndpoint,
|
||||
media: mediaEndpoint,
|
||||
cdn: cdnEndpoint,
|
||||
marketing: marketingEndpoint,
|
||||
admin: adminEndpoint,
|
||||
invite: inviteEndpoint,
|
||||
gift: giftEndpoint,
|
||||
},
|
||||
|
||||
hosts: {
|
||||
invite: extractHostname(inviteEndpoint),
|
||||
gift: extractHostname(giftEndpoint),
|
||||
marketing: extractHostname(marketingEndpoint),
|
||||
unfurlIgnored: parseCommaSeparated(optional('FLUXER_UNFURL_IGNORED_HOSTS') || '').concat([
|
||||
'web.fluxer.app',
|
||||
'web.canary.fluxer.app',
|
||||
]),
|
||||
},
|
||||
|
||||
s3: {
|
||||
endpoint: required('AWS_S3_ENDPOINT'),
|
||||
accessKeyId: required('AWS_ACCESS_KEY_ID'),
|
||||
secretAccessKey: required('AWS_SECRET_ACCESS_KEY'),
|
||||
buckets: {
|
||||
cdn: required('AWS_S3_BUCKET_CDN'),
|
||||
uploads: required('AWS_S3_BUCKET_UPLOADS'),
|
||||
reports: required('AWS_S3_BUCKET_REPORTS'),
|
||||
harvests: required('AWS_S3_BUCKET_HARVESTS'),
|
||||
downloads: required('AWS_S3_BUCKET_DOWNLOADS'),
|
||||
},
|
||||
},
|
||||
|
||||
email: {
|
||||
enabled: optionalBool('EMAIL_ENABLED'),
|
||||
apiKey: optional('SENDGRID_API_KEY'),
|
||||
webhookPublicKey: optional('SENDGRID_WEBHOOK_PUBLIC_KEY'),
|
||||
fromEmail: optional('SENDGRID_FROM_EMAIL') || 'noreply@fluxer.app',
|
||||
fromName: optional('SENDGRID_FROM_NAME') || 'Fluxer',
|
||||
},
|
||||
|
||||
sms: {
|
||||
enabled: optionalBool('SMS_ENABLED'),
|
||||
accountSid: optional('TWILIO_ACCOUNT_SID'),
|
||||
authToken: optional('TWILIO_AUTH_TOKEN'),
|
||||
verifyServiceSid: optional('TWILIO_VERIFY_SERVICE_SID'),
|
||||
},
|
||||
|
||||
captcha: {
|
||||
enabled: optionalBool('CAPTCHA_ENABLED'),
|
||||
provider: (optional('CAPTCHA_PRIMARY_PROVIDER') as 'hcaptcha' | 'turnstile' | 'none') || 'none',
|
||||
hcaptcha:
|
||||
optional('HCAPTCHA_SITE_KEY') && optional('HCAPTCHA_SECRET_KEY')
|
||||
? {
|
||||
siteKey: required('HCAPTCHA_SITE_KEY'),
|
||||
secretKey: required('HCAPTCHA_SECRET_KEY'),
|
||||
}
|
||||
: undefined,
|
||||
turnstile:
|
||||
optional('TURNSTILE_SITE_KEY') && optional('TURNSTILE_SECRET_KEY')
|
||||
? {
|
||||
siteKey: required('TURNSTILE_SITE_KEY'),
|
||||
secretKey: required('TURNSTILE_SECRET_KEY'),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
|
||||
voice: {
|
||||
enabled: optionalBool('VOICE_ENABLED'),
|
||||
apiKey: optional('LIVEKIT_API_KEY'),
|
||||
apiSecret: optional('LIVEKIT_API_SECRET'),
|
||||
webhookUrl: optional('LIVEKIT_WEBHOOK_URL'),
|
||||
url: optional('LIVEKIT_URL'),
|
||||
autoCreateDummyData: optionalBool('LIVEKIT_AUTO_CREATE_DUMMY_DATA'),
|
||||
},
|
||||
|
||||
search: {
|
||||
enabled: optionalBool('SEARCH_ENABLED'),
|
||||
url: optional('MEILISEARCH_URL'),
|
||||
apiKey: optional('MEILISEARCH_API_KEY'),
|
||||
},
|
||||
|
||||
stripe: {
|
||||
enabled: optionalBool('STRIPE_ENABLED'),
|
||||
secretKey: optional('STRIPE_SECRET_KEY'),
|
||||
webhookSecret: optional('STRIPE_WEBHOOK_SECRET'),
|
||||
prices: optionalBool('STRIPE_ENABLED')
|
||||
? {
|
||||
monthlyUsd: optional('STRIPE_PRICE_ID_MONTHLY_USD'),
|
||||
monthlyEur: optional('STRIPE_PRICE_ID_MONTHLY_EUR'),
|
||||
yearlyUsd: optional('STRIPE_PRICE_ID_YEARLY_USD'),
|
||||
yearlyEur: optional('STRIPE_PRICE_ID_YEARLY_EUR'),
|
||||
visionaryUsd: optional('STRIPE_PRICE_ID_VISIONARY_USD'),
|
||||
visionaryEur: optional('STRIPE_PRICE_ID_VISIONARY_EUR'),
|
||||
giftVisionaryUsd: optional('STRIPE_PRICE_ID_GIFT_VISIONARY_USD'),
|
||||
giftVisionaryEur: optional('STRIPE_PRICE_ID_GIFT_VISIONARY_EUR'),
|
||||
gift1MonthUsd: optional('STRIPE_PRICE_ID_GIFT_1_MONTH_USD'),
|
||||
gift1MonthEur: optional('STRIPE_PRICE_ID_GIFT_1_MONTH_EUR'),
|
||||
gift1YearUsd: optional('STRIPE_PRICE_ID_GIFT_1_YEAR_USD'),
|
||||
gift1YearEur: optional('STRIPE_PRICE_ID_GIFT_1_YEAR_EUR'),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
|
||||
cloudflare: {
|
||||
purgeEnabled: optionalBool('CLOUDFLARE_PURGE_ENABLED'),
|
||||
zoneId: optional('CLOUDFLARE_ZONE_ID'),
|
||||
apiToken: optional('CLOUDFLARE_API_TOKEN'),
|
||||
},
|
||||
|
||||
alerts: {
|
||||
webhookUrl: optional('ALERT_WEBHOOK_URL'),
|
||||
},
|
||||
|
||||
clamav: {
|
||||
enabled: optionalBool('CLAMAV_ENABLED'),
|
||||
host: optional('CLAMAV_HOST') || 'clamav',
|
||||
port: optionalInt('CLAMAV_PORT', 3310),
|
||||
failOpen: optionalBool('CLAMAV_FAIL_OPEN', true),
|
||||
},
|
||||
|
||||
adminOauth2: {
|
||||
clientId: optional('ADMIN_OAUTH2_CLIENT_ID'),
|
||||
clientSecret: optional('ADMIN_OAUTH2_CLIENT_SECRET'),
|
||||
redirectUri: `${adminEndpoint}/oauth2_callback`,
|
||||
autoCreate: optionalBool('ADMIN_OAUTH2_AUTO_CREATE'),
|
||||
},
|
||||
|
||||
auth: {
|
||||
sudoModeSecret: required('SUDO_MODE_SECRET'),
|
||||
passkeys: {
|
||||
rpName: optional('PASSKEY_RP_NAME') || 'Fluxer',
|
||||
rpId: optional('PASSKEY_RP_ID') || extractHostname(webAppEndpoint),
|
||||
allowedOrigins: passkeyAllowedOrigins,
|
||||
},
|
||||
},
|
||||
|
||||
cookie: {
|
||||
domain: optional('FLUXER_COOKIE_DOMAIN') || '',
|
||||
secure: optionalBool('FLUXER_COOKIE_SECURE', true),
|
||||
},
|
||||
|
||||
tenor: {
|
||||
apiKey: optional('TENOR_API_KEY'),
|
||||
},
|
||||
|
||||
youtube: {
|
||||
apiKey: optional('YOUTUBE_API_KEY'),
|
||||
},
|
||||
|
||||
instance: {
|
||||
selfHosted: optionalBool('SELF_HOSTED'),
|
||||
autoJoinInviteCode: optional('AUTO_JOIN_INVITE_CODE'),
|
||||
visionariesGuildId: optional('FLUXER_VISIONARIES_GUILD_ID'),
|
||||
operatorsGuildId: optional('FLUXER_OPERATORS_GUILD_ID'),
|
||||
},
|
||||
|
||||
dev: {
|
||||
relaxRegistrationRateLimits: optionalBool('RELAX_REGISTRATION_RATE_LIMITS'),
|
||||
disableRateLimits: optionalBool('DISABLE_RATE_LIMITS'),
|
||||
testModeEnabled,
|
||||
testHarnessToken: optional('FLUXER_TEST_TOKEN'),
|
||||
},
|
||||
|
||||
attachmentDecayEnabled: optionalBool('ATTACHMENT_DECAY_ENABLED', true),
|
||||
|
||||
deletionGracePeriodHours: testModeEnabled ? 0.01 : 336,
|
||||
inactivityDeletionThresholdDays: optionalInt('INACTIVITY_DELETION_THRESHOLD_DAYS', 365 * 2),
|
||||
|
||||
push: {
|
||||
publicVapidKey: optional('VAPID_PUBLIC_KEY'),
|
||||
},
|
||||
|
||||
metrics: {
|
||||
host: optional('FLUXER_METRICS_HOST'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const Config = loadConfig();
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
26
fluxer_api/src/Constants.ts
Normal file
26
fluxer_api/src/Constants.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export * from './constants/API';
|
||||
export * from './constants/AuditLogActionType';
|
||||
export * from './constants/Channel';
|
||||
export * from './constants/Core';
|
||||
export * from './constants/Gateway';
|
||||
export * from './constants/Guild';
|
||||
export * from './constants/User';
|
||||
177
fluxer_api/src/Errors.ts
Normal file
177
fluxer_api/src/Errors.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export * from './errors/AccessDeniedError';
|
||||
export * from './errors/AccountSuspiciousActivityError';
|
||||
export * from './errors/AclsMustBeNonEmptyError';
|
||||
export * from './errors/AlreadyFriendsError';
|
||||
export * from './errors/ApplicationNotFoundError';
|
||||
export * from './errors/AuditLogIndexingError';
|
||||
export * from './errors/BadRequestError';
|
||||
export * from './errors/BannedFromGuildError';
|
||||
export * from './errors/BetaCodeAllowanceExceededError';
|
||||
export * from './errors/BetaCodeMaxUnclaimedError';
|
||||
export * from './errors/BotAlreadyInGuildError';
|
||||
export * from './errors/BotApplicationNotFoundError';
|
||||
export * from './errors/BotIsPrivateError';
|
||||
export * from './errors/BotsCannotHaveFriendsError';
|
||||
export * from './errors/BotUserNotFoundError';
|
||||
export * from './errors/CallAlreadyExistsError';
|
||||
export * from './errors/CannotEditOtherUserMessageError';
|
||||
export * from './errors/CannotEditSystemMessageError';
|
||||
export * from './errors/CannotExecuteOnDmError';
|
||||
export * from './errors/CannotRedeemPlutoniumWithVisionaryError';
|
||||
export * from './errors/CannotRemoveOtherRecipientsError';
|
||||
export * from './errors/CannotReportOwnGuildError';
|
||||
export * from './errors/CannotReportOwnMessageError';
|
||||
export * from './errors/CannotReportYourselfError';
|
||||
export * from './errors/CannotSendEmptyMessageError';
|
||||
export * from './errors/CannotSendFriendRequestToBlockedUserError';
|
||||
export * from './errors/CannotSendFriendRequestToSelfError';
|
||||
export * from './errors/CannotSendMessagesToUserError';
|
||||
export * from './errors/CannotSendMessageToNonTextChannelError';
|
||||
export * from './errors/CannotShrinkReservedSlotsError';
|
||||
export * from './errors/CaptchaVerificationRequiredError';
|
||||
export * from './errors/ChannelIndexingError';
|
||||
export * from './errors/CommunicationDisabledError';
|
||||
export * from './errors/CommunicationDisabledError';
|
||||
export * from './errors/CreationFailedError';
|
||||
export * from './errors/DeletionFailedError';
|
||||
export * from './errors/EmailServiceNotTestableError';
|
||||
export * from './errors/ErrorHandlers';
|
||||
export * from './errors/ExplicitContentCannotBeSentError';
|
||||
export * from './errors/FeatureTemporarilyDisabledError';
|
||||
export * from './errors/FileSizeTooLargeError';
|
||||
export * from './errors/FluxerAPIError';
|
||||
export * from './errors/ForbiddenError';
|
||||
export * from './errors/FriendRequestBlockedError';
|
||||
export * from './errors/GiftCodeAlreadyRedeemedError';
|
||||
export * from './errors/GuildDisallowsUnclaimedAccountsError';
|
||||
export * from './errors/GuildVerificationRequiredError';
|
||||
export * from './errors/HarvestExpiredError';
|
||||
export * from './errors/HarvestFailedError';
|
||||
export * from './errors/HarvestNotReadyError';
|
||||
export * from './errors/HarvestOnCooldownError';
|
||||
export * from './errors/InputValidationError';
|
||||
export * from './errors/InternalServerError';
|
||||
export * from './errors/InvalidAclsFormatError';
|
||||
export * from './errors/InvalidBotFlagError';
|
||||
export * from './errors/InvalidCaptchaError';
|
||||
export * from './errors/InvalidChannelTypeError';
|
||||
export * from './errors/InvalidChannelTypeForCallError';
|
||||
export * from './errors/InvalidClientError';
|
||||
export * from './errors/InvalidDiscriminatorError';
|
||||
export * from './errors/InvalidDsaReportTargetError';
|
||||
export * from './errors/InvalidDsaTicketError';
|
||||
export * from './errors/InvalidDsaVerificationCodeError';
|
||||
export * from './errors/InvalidFlagsFormatError';
|
||||
export * from './errors/InvalidGrantError';
|
||||
export * from './errors/InvalidPhoneNumberError';
|
||||
export * from './errors/InvalidPhoneVerificationCodeError';
|
||||
export * from './errors/InvalidRequestError';
|
||||
export * from './errors/InvalidScopeError';
|
||||
export * from './errors/InvalidSuspiciousFlagsFormatError';
|
||||
export * from './errors/InvalidSystemFlagError';
|
||||
export * from './errors/InvalidTimestampError';
|
||||
export * from './errors/InvalidTokenError';
|
||||
export * from './errors/InvitesDisabledError';
|
||||
export * from './errors/IpBannedError';
|
||||
export * from './errors/IpBannedFromGuildError';
|
||||
export * from './errors/LockedError';
|
||||
export * from './errors/MaxBookmarksError';
|
||||
export * from './errors/MaxCategoryChannelsError';
|
||||
export * from './errors/MaxFavoriteMemesError';
|
||||
export * from './errors/MaxGroupDmRecipientsError';
|
||||
export * from './errors/MaxGroupDmsError';
|
||||
export * from './errors/MaxGuildChannelsError';
|
||||
export * from './errors/MaxGuildEmojisAnimatedError';
|
||||
export * from './errors/MaxGuildEmojisStaticError';
|
||||
export * from './errors/MaxGuildInvitesError';
|
||||
export * from './errors/MaxGuildMembersError';
|
||||
export * from './errors/MaxGuildRolesError';
|
||||
export * from './errors/MaxGuildStickersStaticError';
|
||||
export * from './errors/MaxGuildsError';
|
||||
export * from './errors/MaxReactionsPerMessageError';
|
||||
export * from './errors/MaxRelationshipsError';
|
||||
export * from './errors/MaxUsersPerMessageReactionError';
|
||||
export * from './errors/MaxWebhooksPerChannelError';
|
||||
export * from './errors/MaxWebhooksPerGuildError';
|
||||
export * from './errors/MediaMetadataError';
|
||||
export * from './errors/MfaNotDisabledError';
|
||||
export * from './errors/MfaNotEnabledError';
|
||||
export * from './errors/MissingACLError';
|
||||
export * from './errors/MissingAccessError';
|
||||
export * from './errors/MissingOAuthFieldsError';
|
||||
export * from './errors/MissingPermissionsError';
|
||||
export * from './errors/NoActiveCallError';
|
||||
export * from './errors/NoActiveSubscriptionError';
|
||||
export * from './errors/NoPendingDeletionError';
|
||||
export * from './errors/NotABotApplicationError';
|
||||
export * from './errors/NotFoundError';
|
||||
export * from './errors/NotFriendsWithUserError';
|
||||
export * from './errors/NoUsersWithFluxertagError';
|
||||
export * from './errors/NoVisionarySlotsAvailableError';
|
||||
export * from './errors/NsfwContentRequiresAgeVerificationError';
|
||||
export * from './errors/OAuth2Error';
|
||||
export * from './errors/PhoneAlreadyUsedError';
|
||||
export * from './errors/PhoneRequiredForSmsMfaError';
|
||||
export * from './errors/PhoneVerificationRequiredError';
|
||||
export * from './errors/PremiumPurchaseBlockedError';
|
||||
export * from './errors/PremiumRequiredError';
|
||||
export * from './errors/ProcessingFailedError';
|
||||
export * from './errors/RateLimitError';
|
||||
export * from './errors/ReportAlreadyResolvedError';
|
||||
export * from './errors/ReportBannedError';
|
||||
export * from './errors/ResourceLockedError';
|
||||
export * from './errors/SlowmodeRateLimitError';
|
||||
export * from './errors/SmsMfaNotEnabledError';
|
||||
export * from './errors/SmsMfaRequiresTotpError';
|
||||
export * from './errors/StripeError';
|
||||
export * from './errors/StripeWebhookSignatureInvalidError';
|
||||
export * from './errors/StripeWebhookSignatureMissingError';
|
||||
export * from './errors/TagAlreadyTakenError';
|
||||
export * from './errors/TemporaryInviteRequiresPresenceError';
|
||||
export * from './errors/TestHarnessDisabledError';
|
||||
export * from './errors/TestHarnessForbiddenError';
|
||||
export * from './errors/UnauthorizedError';
|
||||
export * from './errors/UnclaimedAccountRestrictedError';
|
||||
export * from './errors/UnknownApplicationError';
|
||||
export * from './errors/UnknownChannelError';
|
||||
export * from './errors/UnknownFavoriteMemeError';
|
||||
export * from './errors/UnknownGiftCodeError';
|
||||
export * from './errors/UnknownGuildEmojiError';
|
||||
export * from './errors/UnknownGuildError';
|
||||
export * from './errors/UnknownGuildMemberError';
|
||||
export * from './errors/UnknownGuildRoleError';
|
||||
export * from './errors/UnknownGuildStickerError';
|
||||
export * from './errors/UnknownHarvestError';
|
||||
export * from './errors/UnknownInviteError';
|
||||
export * from './errors/UnknownMessageError';
|
||||
export * from './errors/UnknownReportError';
|
||||
export * from './errors/UnknownSuspiciousFlagError';
|
||||
export * from './errors/UnknownUserError';
|
||||
export * from './errors/UnknownUserFlagError';
|
||||
export * from './errors/UnknownVoiceRegionError';
|
||||
export * from './errors/UnknownVoiceServerError';
|
||||
export * from './errors/UnknownWebhookError';
|
||||
export * from './errors/UnsupportedGrantTypeError';
|
||||
export * from './errors/UpdateFailedError';
|
||||
export * from './errors/UserNotInVoiceError';
|
||||
export * from './errors/UserOwnsGuildsError';
|
||||
export * from './errors/ValidationError';
|
||||
53
fluxer_api/src/Logger.ts
Normal file
53
fluxer_api/src/Logger.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 pino from 'pino';
|
||||
import {Config} from '~/Config';
|
||||
|
||||
export const Logger = pino({
|
||||
level: Config.nodeEnv === 'development' ? 'debug' : 'info',
|
||||
transport:
|
||||
Config.nodeEnv === 'development'
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'HH:MM:ss.l',
|
||||
ignore: 'pid,hostname',
|
||||
messageFormat: '{msg}',
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
formatters: {
|
||||
level: (label) => ({level: label}),
|
||||
},
|
||||
errorKey: 'error',
|
||||
serializers: {
|
||||
reason: (value) => {
|
||||
if (value instanceof Error) {
|
||||
return pino.stdSerializers.err(value);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
base: {
|
||||
service: 'fluxer-api',
|
||||
},
|
||||
});
|
||||
161
fluxer_api/src/Meilisearch.ts
Normal file
161
fluxer_api/src/Meilisearch.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* 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 {MeiliSearch} from 'meilisearch';
|
||||
import {Config} from './Config';
|
||||
import {Logger} from './Logger';
|
||||
import {AuditLogSearchService} from './search/AuditLogSearchService';
|
||||
import {GuildSearchService} from './search/GuildSearchService';
|
||||
import {MessageSearchService} from './search/MessageSearchService';
|
||||
import {ReportSearchService} from './search/ReportSearchService';
|
||||
import {UserSearchService} from './search/UserSearchService';
|
||||
|
||||
let meilisearchClient: MeiliSearch | null = null;
|
||||
let messageSearchService: MessageSearchService | null = null;
|
||||
let guildSearchService: GuildSearchService | null = null;
|
||||
let userSearchService: UserSearchService | null = null;
|
||||
let reportSearchService: ReportSearchService | null = null;
|
||||
let auditLogSearchService: AuditLogSearchService | null = null;
|
||||
|
||||
function getMeilisearchClient(): MeiliSearch | null {
|
||||
if (!Config.search.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!meilisearchClient) {
|
||||
if (!Config.search.url || !Config.search.apiKey) {
|
||||
throw new Error('Meilisearch URL and API key are required when search is enabled');
|
||||
}
|
||||
|
||||
meilisearchClient = new MeiliSearch({
|
||||
host: Config.search.url,
|
||||
apiKey: Config.search.apiKey,
|
||||
});
|
||||
}
|
||||
|
||||
return meilisearchClient;
|
||||
}
|
||||
|
||||
export function getMessageSearchService(): MessageSearchService | null {
|
||||
if (!Config.search.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const client = getMeilisearchClient();
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!messageSearchService) {
|
||||
messageSearchService = new MessageSearchService(client);
|
||||
}
|
||||
|
||||
return messageSearchService;
|
||||
}
|
||||
|
||||
export function getGuildSearchService(): GuildSearchService | null {
|
||||
if (!Config.search.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const client = getMeilisearchClient();
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!guildSearchService) {
|
||||
guildSearchService = new GuildSearchService(client);
|
||||
}
|
||||
|
||||
return guildSearchService;
|
||||
}
|
||||
|
||||
export function getUserSearchService(): UserSearchService | null {
|
||||
if (!Config.search.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const client = getMeilisearchClient();
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!userSearchService) {
|
||||
userSearchService = new UserSearchService(client);
|
||||
}
|
||||
|
||||
return userSearchService;
|
||||
}
|
||||
|
||||
export function getReportSearchService(): ReportSearchService | null {
|
||||
if (!Config.search.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const client = getMeilisearchClient();
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!reportSearchService) {
|
||||
reportSearchService = new ReportSearchService(client);
|
||||
}
|
||||
|
||||
return reportSearchService;
|
||||
}
|
||||
|
||||
export function getAuditLogSearchService(): AuditLogSearchService | null {
|
||||
if (!Config.search.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const client = getMeilisearchClient();
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!auditLogSearchService) {
|
||||
auditLogSearchService = new AuditLogSearchService(client);
|
||||
}
|
||||
|
||||
return auditLogSearchService;
|
||||
}
|
||||
|
||||
export async function initializeMeilisearch(): Promise<void> {
|
||||
if (!Config.search.enabled) {
|
||||
Logger.info('Search is disabled, skipping Meilisearch initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
const messageSearch = getMessageSearchService();
|
||||
const guildSearch = getGuildSearchService();
|
||||
const userSearch = getUserSearchService();
|
||||
const reportSearch = getReportSearchService();
|
||||
const auditLogSearch = getAuditLogSearchService();
|
||||
|
||||
await Promise.all([
|
||||
messageSearch?.initialize(),
|
||||
guildSearch?.initialize(),
|
||||
userSearch?.initialize(),
|
||||
reportSearch?.initialize(),
|
||||
auditLogSearch?.initialize(),
|
||||
]);
|
||||
|
||||
Logger.info('Meilisearch initialized successfully');
|
||||
}
|
||||
65
fluxer_api/src/Models.ts
Normal file
65
fluxer_api/src/Models.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export {Attachment} from './models/Attachment';
|
||||
export {AuthSession} from './models/AuthSession';
|
||||
export {BetaCode} from './models/BetaCode';
|
||||
export {Channel} from './models/Channel';
|
||||
export {ChannelPermissionOverwrite} from './models/ChannelPermissionOverwrite';
|
||||
export {EmailRevertToken} from './models/EmailRevertToken';
|
||||
export {EmailVerificationToken} from './models/EmailVerificationToken';
|
||||
export {Embed} from './models/Embed';
|
||||
export {EmbedAuthor} from './models/EmbedAuthor';
|
||||
export {EmbedField} from './models/EmbedField';
|
||||
export {EmbedFooter} from './models/EmbedFooter';
|
||||
export {EmbedMedia} from './models/EmbedMedia';
|
||||
export {ExpressionPack} from './models/ExpressionPack';
|
||||
export {FavoriteMeme} from './models/FavoriteMeme';
|
||||
export {GiftCode} from './models/GiftCode';
|
||||
export {Guild} from './models/Guild';
|
||||
export {GuildAuditLog} from './models/GuildAuditLog';
|
||||
export {GuildBan} from './models/GuildBan';
|
||||
export {GuildChannelOverride} from './models/GuildChannelOverride';
|
||||
export {GuildEmoji} from './models/GuildEmoji';
|
||||
export {GuildMember} from './models/GuildMember';
|
||||
export {GuildRole} from './models/GuildRole';
|
||||
export {GuildSticker} from './models/GuildSticker';
|
||||
export {Invite} from './models/Invite';
|
||||
export {Message} from './models/Message';
|
||||
export {MessageReaction} from './models/MessageReaction';
|
||||
export {MessageRef} from './models/MessageRef';
|
||||
export {MfaBackupCode} from './models/MfaBackupCode';
|
||||
export {MuteConfiguration} from './models/MuteConfiguration';
|
||||
export {PasswordResetToken} from './models/PasswordResetToken';
|
||||
export {Payment} from './models/Payment';
|
||||
export {PrivateChannel} from './models/PrivateChannel';
|
||||
export {PushSubscription} from './models/PushSubscription';
|
||||
export {ReadState} from './models/ReadState';
|
||||
export {RecentMention} from './models/RecentMention';
|
||||
export {Relationship} from './models/Relationship';
|
||||
export {SavedMessage} from './models/SavedMessage';
|
||||
export {ScheduledMessage} from './models/ScheduledMessage';
|
||||
export {StickerItem} from './models/StickerItem';
|
||||
export {User} from './models/User';
|
||||
export {UserGuildSettings} from './models/UserGuildSettings';
|
||||
export {UserNote} from './models/UserNote';
|
||||
export {UserSettings} from './models/UserSettings';
|
||||
export {VisionarySlot} from './models/VisionarySlot';
|
||||
export {WebAuthnCredential} from './models/WebAuthnCredential';
|
||||
export {Webhook} from './models/Webhook';
|
||||
48
fluxer_api/src/RateLimitConfig.ts
Normal file
48
fluxer_api/src/RateLimitConfig.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 {RateLimitSection} from '~/rate_limit_configs/helpers';
|
||||
import {mergeRateLimitSections} from '~/rate_limit_configs/helpers';
|
||||
import {AdminRateLimitConfigs} from './rate_limit_configs/AdminRateLimitConfig';
|
||||
import {AuthRateLimitConfigs} from './rate_limit_configs/AuthRateLimitConfig';
|
||||
import {ChannelRateLimitConfigs} from './rate_limit_configs/ChannelRateLimitConfig';
|
||||
import {GuildRateLimitConfigs} from './rate_limit_configs/GuildRateLimitConfig';
|
||||
import {IntegrationRateLimitConfigs} from './rate_limit_configs/IntegrationRateLimitConfig';
|
||||
import {InviteRateLimitConfigs} from './rate_limit_configs/InviteRateLimitConfig';
|
||||
import {MiscRateLimitConfigs} from './rate_limit_configs/MiscRateLimitConfig';
|
||||
import {OAuthRateLimitConfigs} from './rate_limit_configs/OAuthRateLimitConfig';
|
||||
import {PackRateLimitConfigs} from './rate_limit_configs/PackRateLimitConfig';
|
||||
import {UserRateLimitConfigs} from './rate_limit_configs/UserRateLimitConfig';
|
||||
import {WebhookRateLimitConfigs} from './rate_limit_configs/WebhookRateLimitConfig';
|
||||
|
||||
const rateLimitSections = [
|
||||
AuthRateLimitConfigs,
|
||||
OAuthRateLimitConfigs,
|
||||
UserRateLimitConfigs,
|
||||
ChannelRateLimitConfigs,
|
||||
GuildRateLimitConfigs,
|
||||
InviteRateLimitConfigs,
|
||||
WebhookRateLimitConfigs,
|
||||
IntegrationRateLimitConfigs,
|
||||
AdminRateLimitConfigs,
|
||||
MiscRateLimitConfigs,
|
||||
PackRateLimitConfigs,
|
||||
] satisfies ReadonlyArray<RateLimitSection>;
|
||||
|
||||
export const RateLimitConfigs = mergeRateLimitSections(...rateLimitSections);
|
||||
592
fluxer_api/src/Schema.ts
Normal file
592
fluxer_api/src/Schema.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
/*
|
||||
* 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 validator from 'validator';
|
||||
import {z} from 'zod';
|
||||
import {Config} from '~/Config';
|
||||
|
||||
const RTL_OVERRIDE_REGEX = /\u202E/g;
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: this is fine
|
||||
const FORM_FEED_REGEX = /\u000C/g;
|
||||
const EMAIL_LOCAL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+$/;
|
||||
const DISCRIMINATOR_REGEX = /^\d{1,4}$/;
|
||||
const FILENAME_SAFE_REGEX = /^[\p{L}\p{N}\p{M}_.-]+$/u;
|
||||
const VANITY_URL_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
||||
const ISO_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/;
|
||||
const WHITESPACE_REGEX = /\s+/g;
|
||||
const MULTIPLE_HYPHENS_REGEX = /-{2,}/g;
|
||||
const NON_FILENAME_CHARS_REGEX = /[^\p{L}\p{N}\p{M}_.-]/gu;
|
||||
|
||||
const PROTOCOLS = ['http', 'https'];
|
||||
const TRUE_VALUES = ['true', 'True', '1'];
|
||||
|
||||
const DISALLOWED_CHARS = new Set(' !"#$%&\'()*+,/:;<=>?@[\\]^`{|}~');
|
||||
|
||||
const URL_VALIDATOR_OPTIONS = {
|
||||
require_protocol: true,
|
||||
require_host: true,
|
||||
disallow_auth: true,
|
||||
allow_trailing_dot: false,
|
||||
allow_protocol_relative_urls: false,
|
||||
allow_fragments: false,
|
||||
validate_length: true,
|
||||
protocols: ['http', 'https'] as Array<string>,
|
||||
} as const;
|
||||
|
||||
export const PHONE_E164_REGEX = /^\+[1-9]\d{1,14}$/;
|
||||
const PHONE_E164_ERROR_MESSAGE = 'Phone number must be in E.164 format (e.g., +1234567890)';
|
||||
|
||||
const normalizeString = (value: string): string => {
|
||||
return value.replace(RTL_OVERRIDE_REGEX, '').replace(FORM_FEED_REGEX, '').trim();
|
||||
};
|
||||
|
||||
const isValidBase64 = (value: string): boolean => {
|
||||
if (value.length % 4 !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let padding = 0;
|
||||
for (let i = value.length - 1; i >= 0; i--) {
|
||||
if (value.charCodeAt(i) !== 61) {
|
||||
break;
|
||||
}
|
||||
padding++;
|
||||
}
|
||||
|
||||
if (padding > 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const boundary = value.length - padding;
|
||||
|
||||
for (let i = 0; i < boundary; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
const isUpper = code >= 65 && code <= 90;
|
||||
const isLower = code >= 97 && code <= 122;
|
||||
const isDigit = code >= 48 && code <= 57;
|
||||
const isPlus = code === 43;
|
||||
const isSlash = code === 47;
|
||||
|
||||
if (!(isUpper || isLower || isDigit || isPlus || isSlash)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = boundary; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) !== 61) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(value, 'base64');
|
||||
if (decoded.length === 0) {
|
||||
return value === '';
|
||||
}
|
||||
|
||||
return decoded.toString('base64') === value;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const C0_C1_CTRL_REGEX =
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: this is fine
|
||||
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F]/g;
|
||||
|
||||
const SURROGATES_REGEX = /[\uD800-\uDFFF]/g;
|
||||
|
||||
const JOIN_CONTROLS_REGEX = /(?:\u200C|\u200D)/g;
|
||||
|
||||
const WJ_BOM_REGEX = /(?:\u2060|\uFEFF)/g;
|
||||
|
||||
const BIDI_CTRL_REGEX = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
|
||||
|
||||
const MISC_INVISIBLES_REGEX = /[\u00AD\u180E\uFFFE\uFFFF]/g;
|
||||
|
||||
const TAG_CHARS_REGEX = /[\u{E0000}-\u{E007F}]/gu;
|
||||
|
||||
const VARIATION_SELECTORS_BASIC = /[\uFE00-\uFE0F]/g;
|
||||
const VARIATION_SELECTORS_IDEOGRAPHIC = /[\u{E0100}-\u{E01EF}]/gu;
|
||||
|
||||
const UNICODE_SPACES_REGEX = /[\s\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]/g;
|
||||
|
||||
const normalizeWhitespace = (s: string): string => s.replace(UNICODE_SPACES_REGEX, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const stripInvisibles = (s: string): string =>
|
||||
s
|
||||
.replace(C0_C1_CTRL_REGEX, '')
|
||||
.replace(JOIN_CONTROLS_REGEX, '')
|
||||
.replace(WJ_BOM_REGEX, '')
|
||||
.replace(BIDI_CTRL_REGEX, '')
|
||||
.replace(MISC_INVISIBLES_REGEX, '')
|
||||
.replace(TAG_CHARS_REGEX, '');
|
||||
|
||||
const stripVariationSelectors = (s: string): string =>
|
||||
s.replace(VARIATION_SELECTORS_BASIC, '').replace(VARIATION_SELECTORS_IDEOGRAPHIC, '');
|
||||
|
||||
const sanitizeUsername = (value: string): string => {
|
||||
let s = normalizeString(value);
|
||||
s = s.replace(SURROGATES_REGEX, '');
|
||||
s = stripInvisibles(s);
|
||||
s = stripVariationSelectors(s);
|
||||
s = normalizeWhitespace(s);
|
||||
return s;
|
||||
};
|
||||
|
||||
const sanitizeChannelName = (value: string): string => {
|
||||
let s = normalizeString(value);
|
||||
s = stripInvisibles(s);
|
||||
s = normalizeWhitespace(s);
|
||||
return s;
|
||||
};
|
||||
|
||||
export const EmailType = z
|
||||
.email('Invalid email format')
|
||||
.transform(normalizeString)
|
||||
.refine((value) => value.length >= 1 && value.length <= 254, 'Email length must be between 1 and 254 characters')
|
||||
.refine((value) => {
|
||||
const atIndex = value.indexOf('@');
|
||||
if (atIndex === -1) return false;
|
||||
const local = value.slice(0, atIndex);
|
||||
return EMAIL_LOCAL_REGEX.test(local);
|
||||
}, 'Invalid email local part');
|
||||
|
||||
export const DiscriminatorType = z
|
||||
.string()
|
||||
.regex(DISCRIMINATOR_REGEX, 'Discriminator must be 1-4 digits')
|
||||
.superRefine((value, ctx) => {
|
||||
const num = Number.parseInt(value, 10);
|
||||
if (num < 0 || num > 9999) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Discriminator must be between 0 and 9999',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.transform((value) => Number.parseInt(value, 10));
|
||||
|
||||
const FLUXER_TAG_REGEX = /^[a-zA-Z0-9_]+$/;
|
||||
|
||||
export const UsernameType = z
|
||||
.string()
|
||||
.transform((value) => value.trim())
|
||||
.refine((value) => value.length >= 1 && value.length <= 32, 'Username length must be between 1 and 32 characters')
|
||||
.refine(
|
||||
(value) => FLUXER_TAG_REGEX.test(value),
|
||||
'Username can only contain Latin letters (a-z, A-Z), numbers (0-9), and underscores (_)',
|
||||
)
|
||||
.refine((value) => {
|
||||
const lowerValue = value.toLowerCase();
|
||||
return lowerValue !== 'everyone' && lowerValue !== 'here';
|
||||
}, 'Username cannot be "everyone" or "here"')
|
||||
.refine((value) => {
|
||||
const lowerValue = value.toLowerCase();
|
||||
return !lowerValue.includes('fluxer') && !lowerValue.includes('system message');
|
||||
}, 'Username cannot contain "fluxer" or "system message"');
|
||||
|
||||
export const GlobalNameType = z
|
||||
.string()
|
||||
.transform(sanitizeUsername)
|
||||
.refine((value) => value.length >= 1 && value.length <= 32, 'Global name length must be between 1 and 32 characters')
|
||||
.refine((value) => {
|
||||
const lowerValue = value.toLowerCase();
|
||||
return lowerValue !== 'everyone' && lowerValue !== 'here';
|
||||
}, 'Global name cannot be "everyone" or "here"')
|
||||
.refine((value) => {
|
||||
const lowerValue = value.toLowerCase();
|
||||
return !lowerValue.includes('system message');
|
||||
}, 'Global name cannot contain "system message"');
|
||||
|
||||
export const URLType = z
|
||||
.string()
|
||||
.transform(normalizeString)
|
||||
.refine((value) => value.length >= 1 && value.length <= 2048, 'URL length must be between 1 and 2048 characters')
|
||||
.refine((value) => {
|
||||
if (!value.startsWith('http://') && !value.startsWith('https://')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return PROTOCOLS.includes(url.protocol.slice(0, -1));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 'Invalid URL format')
|
||||
.refine(
|
||||
(value) =>
|
||||
validator.isURL(value, {
|
||||
...URL_VALIDATOR_OPTIONS,
|
||||
require_tld: Config.nodeEnv !== 'development',
|
||||
}),
|
||||
'Invalid URL format',
|
||||
);
|
||||
|
||||
export const AttachmentURLType = z
|
||||
.string()
|
||||
.transform(normalizeString)
|
||||
.refine((value) => value.length >= 1 && value.length <= 2048, 'URL length must be between 1 and 2048 characters')
|
||||
.refine((value) => {
|
||||
if (value.startsWith('attachment://')) {
|
||||
const filename = value.slice(13);
|
||||
if (filename.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return FILENAME_SAFE_REGEX.test(filename);
|
||||
}
|
||||
|
||||
if (!value.startsWith('http://') && !value.startsWith('https://')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return PROTOCOLS.includes(url.protocol.slice(0, -1));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 'Invalid URL format or attachment reference')
|
||||
.refine((value) => {
|
||||
if (value.startsWith('attachment://')) {
|
||||
return true;
|
||||
}
|
||||
return validator.isURL(value, {
|
||||
...URL_VALIDATOR_OPTIONS,
|
||||
require_tld: Config.nodeEnv !== 'development',
|
||||
});
|
||||
}, 'Invalid URL format');
|
||||
|
||||
const WINDOWS_RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
|
||||
|
||||
function normalizeFilename(value: string): string {
|
||||
let normalized = normalizeString(value);
|
||||
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: null byte filtering is intentional for security
|
||||
normalized = normalized.replace(/\x00/g, '');
|
||||
|
||||
normalized = normalized.replace(/[/\\]/g, '_');
|
||||
|
||||
normalized = normalized.replace(/\.{2,}/g, '_');
|
||||
|
||||
while (normalized.includes('..')) {
|
||||
normalized = normalized.replace(/\.\./g, '_');
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/[<>:"|?*]/g, '');
|
||||
|
||||
if (WINDOWS_RESERVED_NAMES.test(normalized)) {
|
||||
normalized = `_${normalized}`;
|
||||
}
|
||||
|
||||
normalized = normalized.replace(WHITESPACE_REGEX, '_');
|
||||
|
||||
normalized = normalized.replace(NON_FILENAME_CHARS_REGEX, '');
|
||||
|
||||
normalized = normalized.replace(/\.\./g, '_');
|
||||
normalized = normalized.replace(/[/\\]/g, '_');
|
||||
|
||||
if (!normalized || /^[._]+$/.test(normalized)) {
|
||||
normalized = 'unnamed';
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export const FilenameType = z
|
||||
.string()
|
||||
.refine((value) => value.length >= 1 && value.length <= 255, 'Filename length must be between 1 and 255 characters')
|
||||
.transform(normalizeFilename)
|
||||
.refine((value) => value.length >= 1, 'Filename cannot be empty after normalization')
|
||||
.refine((value) => FILENAME_SAFE_REGEX.test(value), 'Filename contains invalid characters');
|
||||
|
||||
export const Int64Type = z.union([z.string(), z.number().int()]).transform((value, ctx) => {
|
||||
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Invalid integer format',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
const normalized = typeof value === 'number' ? value.toString() : value;
|
||||
const trimmed = normalized.trim();
|
||||
|
||||
try {
|
||||
const bigInt = BigInt(trimmed);
|
||||
if (bigInt < -9223372036854775808n || bigInt > 9223372036854775807n) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Integer out of valid int64 range',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return bigInt;
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Invalid integer format',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
|
||||
export const ColorType = z
|
||||
.number()
|
||||
.int()
|
||||
.min(0x000000, 'Color value must be at least 0x000000')
|
||||
.max(0xffffff, 'Color value must not exceed 0xffffff');
|
||||
|
||||
export const Int32Type = z.number().int().min(0).max(2147483647);
|
||||
|
||||
const INTEGER_STRING_REGEX = /^[+-]?\d+$/;
|
||||
|
||||
const coerceNumericStringToNumber = (value: unknown): unknown => {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0 || !INTEGER_STRING_REGEX.test(trimmed)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isNaN(parsed) ? value : parsed;
|
||||
};
|
||||
|
||||
export const coerceNumberFromString = <T extends z.ZodNumber>(schema: T) =>
|
||||
z.preprocess((value) => coerceNumericStringToNumber(value), schema);
|
||||
|
||||
export const QueryBooleanType = z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.default('false')
|
||||
.transform((value) => TRUE_VALUES.includes(value));
|
||||
|
||||
export const createQueryIntegerType = ({defaultValue = 0, minValue = 0, maxValue = 2147483647} = {}) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.default(defaultValue.toString())
|
||||
.superRefine((value, ctx) => {
|
||||
const num = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(num) || num < minValue || num > maxValue) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Invalid integer: must be between ${minValue} and ${maxValue}`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.transform((value) => Number.parseInt(value, 10));
|
||||
|
||||
export const DateTimeType = z.union([
|
||||
z
|
||||
.string()
|
||||
.regex(ISO_TIMESTAMP_REGEX, 'Must be a valid ISO timestamp')
|
||||
.superRefine((value, ctx) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Invalid date',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.transform((value) => new Date(value)),
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(8640000000000000)
|
||||
.superRefine((value, ctx) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Invalid date',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.transform((value) => new Date(value)),
|
||||
]);
|
||||
|
||||
export const WebhookNameType = z
|
||||
.string()
|
||||
.transform(normalizeString)
|
||||
.refine(
|
||||
(value) => value.length >= 1 && value.length <= 80,
|
||||
'Webhook name length must be between 1 and 80 characters',
|
||||
);
|
||||
|
||||
export const PasswordType = z
|
||||
.string()
|
||||
.transform(normalizeString)
|
||||
.refine((value) => value.length >= 8 && value.length <= 256, 'Password length must be between 8 and 256 characters');
|
||||
|
||||
export const PhoneNumberType = z
|
||||
.string()
|
||||
.transform(normalizeString)
|
||||
.refine((value) => PHONE_E164_REGEX.test(value), PHONE_E164_ERROR_MESSAGE);
|
||||
|
||||
export const createStringType = (minLength = 1, maxLength = 256) =>
|
||||
z
|
||||
.string()
|
||||
.transform(normalizeString)
|
||||
.refine(
|
||||
(value: string) => value.length >= minLength && value.length <= maxLength,
|
||||
minLength === maxLength
|
||||
? `String must be exactly ${minLength} characters`
|
||||
: `String length must be between ${minLength} and ${maxLength} characters`,
|
||||
);
|
||||
|
||||
export const createUnboundedStringType = () => z.string().transform(normalizeString);
|
||||
|
||||
export const createBase64StringType = (minLength = 1, maxLength = 256) =>
|
||||
z
|
||||
.string()
|
||||
.superRefine((value, ctx) => {
|
||||
const normalized = normalizeString(value);
|
||||
const commaIndex = normalized.indexOf(',');
|
||||
const base64 = commaIndex !== -1 ? normalized.slice(commaIndex + 1) : normalized;
|
||||
|
||||
if (base64.length < minLength || base64.length > maxLength) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Base64 string length must be between ${minLength} and ${maxLength} characters`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
if (base64.length < 1 || !isValidBase64(base64)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Value must be a valid base64-encoded string',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.transform((value) => {
|
||||
const normalized = normalizeString(value);
|
||||
const commaIndex = normalized.indexOf(',');
|
||||
return commaIndex !== -1 ? normalized.slice(commaIndex + 1) : normalized;
|
||||
});
|
||||
|
||||
export const ChannelNameType = z
|
||||
.string()
|
||||
.superRefine((value, ctx) => {
|
||||
const normalized = normalizeString(value);
|
||||
const processed =
|
||||
normalized
|
||||
.toLowerCase()
|
||||
.replace(WHITESPACE_REGEX, '-')
|
||||
.split('')
|
||||
.filter((char) => !DISALLOWED_CHARS.has(char))
|
||||
.join('') || '-';
|
||||
if (processed.length < 1) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Channel name cannot be empty after normalization',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.transform((value) => {
|
||||
const normalized = normalizeString(value);
|
||||
return (
|
||||
normalized
|
||||
.toLowerCase()
|
||||
.replace(WHITESPACE_REGEX, '-')
|
||||
.split('')
|
||||
.filter((char) => !DISALLOWED_CHARS.has(char))
|
||||
.join('') || '-'
|
||||
);
|
||||
})
|
||||
.refine(
|
||||
(value) => value.length >= 1 && value.length <= 100,
|
||||
'Channel name length must be between 1 and 100 characters',
|
||||
);
|
||||
|
||||
export const GeneralChannelNameType = z
|
||||
.string()
|
||||
.transform((value) => {
|
||||
let sanitized = sanitizeChannelName(value);
|
||||
sanitized = sanitized.replace(WHITESPACE_REGEX, ' ');
|
||||
return sanitized;
|
||||
})
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.refine((v) => v.trim().length > 0, 'Name cannot be empty after normalization')
|
||||
.min(1, 'Name length must be between 1 and 100 characters')
|
||||
.max(100, 'Name length must be between 1 and 100 characters'),
|
||||
);
|
||||
|
||||
export const VanityURLCodeType = z
|
||||
.string()
|
||||
.superRefine((value, ctx) => {
|
||||
const normalized = normalizeString(value);
|
||||
const processed = normalized.toLowerCase().replace(WHITESPACE_REGEX, '-').replace(MULTIPLE_HYPHENS_REGEX, '-');
|
||||
if (!VANITY_URL_REGEX.test(processed)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Vanity URL can only contain lowercase letters (a-z), digits (0-9), and hyphens (-)',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.transform((value) => {
|
||||
const normalized = normalizeString(value);
|
||||
return normalized.toLowerCase().replace(WHITESPACE_REGEX, '-').replace(MULTIPLE_HYPHENS_REGEX, '-');
|
||||
})
|
||||
.refine(
|
||||
(value) => value.length >= 2 && value.length <= 32,
|
||||
'Vanity URL code length must be between 2 and 32 characters',
|
||||
);
|
||||
|
||||
const AUDIT_LOG_REASON_MAX_LENGTH = 512;
|
||||
|
||||
export const AuditLogReasonType = z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeString(value);
|
||||
if (normalized.length < 1 || normalized.length > AUDIT_LOG_REASON_MAX_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
|
||||
export const SudoVerificationSchema = z.object({
|
||||
password: PasswordType.optional(),
|
||||
mfa_method: z.enum(['totp', 'sms', 'webauthn']).optional(),
|
||||
mfa_code: createStringType(1, 32).optional(),
|
||||
webauthn_response: z.any().optional(),
|
||||
webauthn_challenge: createStringType().optional(),
|
||||
});
|
||||
|
||||
export {z};
|
||||
931
fluxer_api/src/Tables.ts
Normal file
931
fluxer_api/src/Tables.ts
Normal file
@@ -0,0 +1,931 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AttachmentID, ChannelID, GuildID, MemeID, UserID} from '~/BrandedTypes';
|
||||
import {defineTable} from '~/database/Cassandra';
|
||||
import {
|
||||
ADMIN_ARCHIVE_COLUMNS,
|
||||
ADMIN_AUDIT_LOG_COLUMNS,
|
||||
type AdminArchiveRow,
|
||||
type AdminAuditLogRow,
|
||||
APPLICATION_COLUMNS,
|
||||
type ApplicationByOwnerRow,
|
||||
type ApplicationRow,
|
||||
ATTACHMENT_LOOKUP_COLUMNS,
|
||||
type AttachmentLookupRow,
|
||||
AUTH_SESSION_COLUMNS,
|
||||
AUTHORIZED_IP_COLUMNS,
|
||||
type AuthorizedIpRow,
|
||||
type AuthSessionRow,
|
||||
BANNED_EMAIL_COLUMNS,
|
||||
BANNED_IP_COLUMNS,
|
||||
BANNED_PHONE_COLUMNS,
|
||||
type BannedEmailRow,
|
||||
type BannedIpRow,
|
||||
type BannedPhoneRow,
|
||||
BETA_CODE_BY_CODE_COLUMNS,
|
||||
BETA_CODE_COLUMNS,
|
||||
type BetaCodeByCodeRow,
|
||||
type BetaCodeRow,
|
||||
CHANNEL_COLUMNS,
|
||||
CHANNEL_EMPTY_BUCKET_COLUMNS,
|
||||
CHANNEL_MESSAGE_BUCKET_COLUMNS,
|
||||
CHANNEL_PIN_COLUMNS,
|
||||
CHANNEL_STATE_COLUMNS,
|
||||
CHANNELS_BY_GUILD_COLUMNS,
|
||||
type ChannelEmptyBucketRow,
|
||||
type ChannelMessageBucketRow,
|
||||
type ChannelPinRow,
|
||||
type ChannelRow,
|
||||
type ChannelStateRow,
|
||||
type ChannelsByGuildRow,
|
||||
DM_STATE_COLUMNS,
|
||||
type DmStateRow,
|
||||
DSA_REPORT_EMAIL_VERIFICATION_COLUMNS,
|
||||
DSA_REPORT_TICKET_COLUMNS,
|
||||
type DSAReportEmailVerificationRow,
|
||||
type DSAReportTicketRow,
|
||||
EMAIL_CHANGE_TICKET_COLUMNS,
|
||||
EMAIL_CHANGE_TOKEN_COLUMNS,
|
||||
EMAIL_REVERT_TOKEN_COLUMNS,
|
||||
EMAIL_VERIFICATION_TOKEN_COLUMNS,
|
||||
type EmailChangeTicketRow,
|
||||
type EmailChangeTokenRow,
|
||||
type EmailRevertTokenRow,
|
||||
type EmailVerificationTokenRow,
|
||||
EXPRESSION_PACK_COLUMNS,
|
||||
type ExpressionPackRow,
|
||||
FAVORITE_MEME_COLUMNS,
|
||||
type FavoriteMemeRow,
|
||||
GIFT_CODE_BY_CREATOR_COLUMNS,
|
||||
GIFT_CODE_BY_PAYMENT_INTENT_COLUMNS,
|
||||
GIFT_CODE_BY_REDEEMER_COLUMNS,
|
||||
GIFT_CODE_COLUMNS,
|
||||
type GiftCodeByCreatorRow,
|
||||
type GiftCodeByPaymentIntentRow,
|
||||
type GiftCodeByRedeemerRow,
|
||||
type GiftCodeRow,
|
||||
GUILD_AUDIT_LOG_COLUMNS,
|
||||
GUILD_BAN_COLUMNS,
|
||||
GUILD_BY_OWNER_ID_COLUMNS,
|
||||
GUILD_COLUMNS,
|
||||
GUILD_EMOJI_BY_EMOJI_ID_COLUMNS,
|
||||
GUILD_EMOJI_COLUMNS,
|
||||
GUILD_MEMBER_BY_USER_ID_COLUMNS,
|
||||
GUILD_MEMBER_COLUMNS,
|
||||
GUILD_ROLE_COLUMNS,
|
||||
GUILD_STICKER_BY_STICKER_ID_COLUMNS,
|
||||
GUILD_STICKER_COLUMNS,
|
||||
type GuildAuditLogRow,
|
||||
type GuildBanRow,
|
||||
type GuildByOwnerIdRow,
|
||||
type GuildEmojiRow,
|
||||
type GuildMemberByUserIdRow,
|
||||
type GuildMemberRow,
|
||||
type GuildRoleRow,
|
||||
type GuildRow,
|
||||
type GuildStickerRow,
|
||||
IAR_SUBMISSION_COLUMNS,
|
||||
type IARSubmissionRow,
|
||||
INSTANCE_CONFIGURATION_COLUMNS,
|
||||
INVITE_COLUMNS,
|
||||
type InstanceConfigurationRow,
|
||||
type InviteRow,
|
||||
IP_AUTHORIZATION_TOKEN_COLUMNS,
|
||||
type IpAuthorizationTokenRow,
|
||||
MESSAGE_BY_AUTHOR_COLUMNS,
|
||||
MESSAGE_COLUMNS,
|
||||
MESSAGE_REACTION_COLUMNS,
|
||||
type MessageByAuthorRow,
|
||||
type MessageReactionRow,
|
||||
type MessageRow,
|
||||
MFA_BACKUP_CODE_COLUMNS,
|
||||
type MfaBackupCodeRow,
|
||||
NOTE_COLUMNS,
|
||||
type NoteRow,
|
||||
OAUTH2_ACCESS_TOKEN_COLUMNS,
|
||||
OAUTH2_AUTHORIZATION_CODE_COLUMNS,
|
||||
OAUTH2_REFRESH_TOKEN_COLUMNS,
|
||||
type OAuth2AccessTokenByUserRow,
|
||||
type OAuth2AccessTokenRow,
|
||||
type OAuth2AuthorizationCodeRow,
|
||||
type OAuth2RefreshTokenByUserRow,
|
||||
type OAuth2RefreshTokenRow,
|
||||
PASSWORD_RESET_TOKEN_COLUMNS,
|
||||
PAYMENT_BY_PAYMENT_INTENT_COLUMNS,
|
||||
PAYMENT_BY_SUBSCRIPTION_COLUMNS,
|
||||
PAYMENT_BY_USER_COLUMNS,
|
||||
PAYMENT_COLUMNS,
|
||||
type PackInstallationRow,
|
||||
type PasswordResetTokenRow,
|
||||
type PaymentByPaymentIntentRow,
|
||||
type PaymentBySubscriptionRow,
|
||||
type PaymentByUserRow,
|
||||
type PaymentRow,
|
||||
PENDING_VERIFICATION_COLUMNS,
|
||||
type PendingVerificationRow,
|
||||
PHONE_TOKEN_COLUMNS,
|
||||
type PhoneTokenRow,
|
||||
PRIVATE_CHANNEL_COLUMNS,
|
||||
type PrivateChannelRow,
|
||||
PUSH_SUBSCRIPTION_COLUMNS,
|
||||
type PushSubscriptionRow,
|
||||
RECENT_MENTION_COLUMNS,
|
||||
RELATIONSHIP_COLUMNS,
|
||||
type RecentMentionRow,
|
||||
type RelationshipRow,
|
||||
SAVED_MESSAGE_COLUMNS,
|
||||
type SavedMessageRow,
|
||||
SCHEDULED_MESSAGE_COLUMNS,
|
||||
type ScheduledMessageRow,
|
||||
USER_BY_EMAIL_COLUMNS,
|
||||
USER_BY_PHONE_COLUMNS,
|
||||
USER_BY_STRIPE_CUSTOMER_ID_COLUMNS,
|
||||
USER_BY_STRIPE_SUBSCRIPTION_ID_COLUMNS,
|
||||
USER_BY_USERNAME_COLUMNS,
|
||||
USER_COLUMNS,
|
||||
USER_CONTACT_CHANGE_LOG_COLUMNS,
|
||||
USER_GUILD_SETTINGS_COLUMNS,
|
||||
USER_HARVEST_COLUMNS,
|
||||
USER_SETTINGS_COLUMNS,
|
||||
USERS_PENDING_DELETION_COLUMNS,
|
||||
type UserByEmailRow,
|
||||
type UserByPhoneRow,
|
||||
type UserByStripeCustomerIdRow,
|
||||
type UserByStripeSubscriptionIdRow,
|
||||
type UserByUsernameRow,
|
||||
type UserContactChangeLogRow,
|
||||
type UserGuildSettingsRow,
|
||||
type UserHarvestRow,
|
||||
type UserRow,
|
||||
type UserSettingsRow,
|
||||
type UsersPendingDeletionRow,
|
||||
VISIONARY_SLOT_COLUMNS,
|
||||
type VisionarySlotRow,
|
||||
WEBAUTHN_CREDENTIAL_COLUMNS,
|
||||
WEBHOOK_COLUMNS,
|
||||
type WebAuthnCredentialRow,
|
||||
type WebhookRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import {ATTACHMENT_DECAY_COLUMNS, type AttachmentDecayRow} from '~/types/AttachmentDecayTypes';
|
||||
|
||||
export const Users = defineTable<UserRow, 'user_id'>({
|
||||
name: 'users',
|
||||
columns: USER_COLUMNS,
|
||||
primaryKey: ['user_id'],
|
||||
});
|
||||
|
||||
export const UsersPendingDeletion = defineTable<
|
||||
UsersPendingDeletionRow,
|
||||
'deletion_date' | 'pending_deletion_at' | 'user_id'
|
||||
>({
|
||||
name: 'users_pending_deletion',
|
||||
columns: USERS_PENDING_DELETION_COLUMNS,
|
||||
primaryKey: ['deletion_date', 'pending_deletion_at', 'user_id'],
|
||||
});
|
||||
|
||||
export const UserByUsername = defineTable<UserByUsernameRow, 'username' | 'discriminator' | 'user_id'>({
|
||||
name: 'users_by_username',
|
||||
columns: USER_BY_USERNAME_COLUMNS,
|
||||
primaryKey: ['username', 'discriminator', 'user_id'],
|
||||
});
|
||||
|
||||
export const UserByEmail = defineTable<UserByEmailRow, 'email_lower' | 'user_id'>({
|
||||
name: 'users_by_email',
|
||||
columns: USER_BY_EMAIL_COLUMNS,
|
||||
primaryKey: ['email_lower', 'user_id'],
|
||||
});
|
||||
|
||||
export const UserByPhone = defineTable<UserByPhoneRow, 'phone' | 'user_id'>({
|
||||
name: 'users_by_phone',
|
||||
columns: USER_BY_PHONE_COLUMNS,
|
||||
primaryKey: ['phone', 'user_id'],
|
||||
});
|
||||
|
||||
export const UserByStripeCustomerId = defineTable<UserByStripeCustomerIdRow, 'stripe_customer_id' | 'user_id'>({
|
||||
name: 'users_by_stripe_customer_id',
|
||||
columns: USER_BY_STRIPE_CUSTOMER_ID_COLUMNS,
|
||||
primaryKey: ['stripe_customer_id', 'user_id'],
|
||||
});
|
||||
|
||||
export const UserByStripeSubscriptionId = defineTable<
|
||||
UserByStripeSubscriptionIdRow,
|
||||
'stripe_subscription_id' | 'user_id'
|
||||
>({
|
||||
name: 'users_by_stripe_subscription_id',
|
||||
columns: USER_BY_STRIPE_SUBSCRIPTION_ID_COLUMNS,
|
||||
primaryKey: ['stripe_subscription_id', 'user_id'],
|
||||
});
|
||||
|
||||
export const UserSettings = defineTable<UserSettingsRow, 'user_id'>({
|
||||
name: 'user_settings',
|
||||
columns: USER_SETTINGS_COLUMNS,
|
||||
primaryKey: ['user_id'],
|
||||
});
|
||||
|
||||
export const UserGuildSettings = defineTable<UserGuildSettingsRow, 'user_id' | 'guild_id'>({
|
||||
name: 'user_guild_settings',
|
||||
columns: USER_GUILD_SETTINGS_COLUMNS,
|
||||
primaryKey: ['user_id', 'guild_id'],
|
||||
});
|
||||
|
||||
export const UserContactChangeLogs = defineTable<UserContactChangeLogRow, 'user_id' | 'event_id'>({
|
||||
name: 'user_contact_change_logs',
|
||||
columns: USER_CONTACT_CHANGE_LOG_COLUMNS,
|
||||
primaryKey: ['user_id', 'event_id'],
|
||||
});
|
||||
|
||||
export const Notes = defineTable<NoteRow, 'source_user_id' | 'target_user_id'>({
|
||||
name: 'notes',
|
||||
columns: NOTE_COLUMNS,
|
||||
primaryKey: ['source_user_id', 'target_user_id'],
|
||||
});
|
||||
|
||||
export const Relationships = defineTable<RelationshipRow, 'source_user_id' | 'target_user_id' | 'type'>({
|
||||
name: 'relationships',
|
||||
columns: RELATIONSHIP_COLUMNS,
|
||||
primaryKey: ['source_user_id', 'target_user_id', 'type'],
|
||||
});
|
||||
|
||||
export const UserHarvests = defineTable<UserHarvestRow, 'user_id' | 'harvest_id'>({
|
||||
name: 'user_harvests',
|
||||
columns: USER_HARVEST_COLUMNS,
|
||||
primaryKey: ['user_id', 'harvest_id'],
|
||||
});
|
||||
|
||||
export const Guilds = defineTable<GuildRow, 'guild_id'>({
|
||||
name: 'guilds',
|
||||
columns: GUILD_COLUMNS,
|
||||
primaryKey: ['guild_id'],
|
||||
});
|
||||
|
||||
export const GuildBans = defineTable<GuildBanRow, 'guild_id' | 'user_id'>({
|
||||
name: 'guild_bans',
|
||||
columns: GUILD_BAN_COLUMNS,
|
||||
primaryKey: ['guild_id', 'user_id'],
|
||||
});
|
||||
|
||||
export const GuildAuditLogs = defineTable<GuildAuditLogRow, 'guild_id' | 'log_id'>({
|
||||
name: 'guild_audit_logs_v2',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'log_id'],
|
||||
});
|
||||
|
||||
export const GuildAuditLogsByUser = defineTable<GuildAuditLogRow, 'guild_id' | 'user_id' | 'log_id'>({
|
||||
name: 'guild_audit_logs_v2_by_user',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'user_id', 'log_id'],
|
||||
});
|
||||
|
||||
export const GuildAuditLogsByAction = defineTable<GuildAuditLogRow, 'guild_id' | 'action_type' | 'log_id'>({
|
||||
name: 'guild_audit_logs_v2_by_action',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'action_type', 'log_id'],
|
||||
});
|
||||
|
||||
export const GuildAuditLogsByUserAction = defineTable<
|
||||
GuildAuditLogRow,
|
||||
'guild_id' | 'user_id' | 'action_type' | 'log_id'
|
||||
>({
|
||||
name: 'guild_audit_logs_v2_by_user_action',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'user_id', 'action_type', 'log_id'],
|
||||
});
|
||||
|
||||
export const GuildMembersByUserId = defineTable<GuildMemberByUserIdRow, 'user_id' | 'guild_id'>({
|
||||
name: 'guild_members_by_user_id',
|
||||
columns: GUILD_MEMBER_BY_USER_ID_COLUMNS,
|
||||
primaryKey: ['user_id', 'guild_id'],
|
||||
});
|
||||
|
||||
export const GuildsByOwnerId = defineTable<GuildByOwnerIdRow, 'owner_id' | 'guild_id'>({
|
||||
name: 'guilds_by_owner_id',
|
||||
columns: GUILD_BY_OWNER_ID_COLUMNS,
|
||||
primaryKey: ['owner_id', 'guild_id'],
|
||||
});
|
||||
|
||||
export const GuildEmojis = defineTable<GuildEmojiRow, 'guild_id' | 'emoji_id'>({
|
||||
name: 'guild_emojis',
|
||||
columns: GUILD_EMOJI_COLUMNS,
|
||||
primaryKey: ['guild_id', 'emoji_id'],
|
||||
});
|
||||
|
||||
export const GuildEmojisByEmojiId = defineTable<GuildEmojiRow, 'emoji_id'>({
|
||||
name: 'guild_emojis_by_emoji_id',
|
||||
columns: GUILD_EMOJI_BY_EMOJI_ID_COLUMNS,
|
||||
primaryKey: ['emoji_id'],
|
||||
});
|
||||
|
||||
export const GuildStickers = defineTable<GuildStickerRow, 'guild_id' | 'sticker_id'>({
|
||||
name: 'guild_stickers',
|
||||
columns: GUILD_STICKER_COLUMNS,
|
||||
primaryKey: ['guild_id', 'sticker_id'],
|
||||
});
|
||||
|
||||
export const GuildStickersByStickerId = defineTable<GuildStickerRow, 'sticker_id'>({
|
||||
name: 'guild_stickers_by_sticker_id',
|
||||
columns: GUILD_STICKER_BY_STICKER_ID_COLUMNS,
|
||||
primaryKey: ['sticker_id'],
|
||||
});
|
||||
|
||||
export const GuildRoles = defineTable<GuildRoleRow, 'guild_id' | 'role_id'>({
|
||||
name: 'guild_roles',
|
||||
columns: GUILD_ROLE_COLUMNS,
|
||||
primaryKey: ['guild_id', 'role_id'],
|
||||
});
|
||||
|
||||
export const GuildMembers = defineTable<GuildMemberRow, 'guild_id' | 'user_id'>({
|
||||
name: 'guild_members',
|
||||
columns: GUILD_MEMBER_COLUMNS,
|
||||
primaryKey: ['guild_id', 'user_id'],
|
||||
});
|
||||
|
||||
export const Channels = defineTable<ChannelRow, 'channel_id' | 'soft_deleted'>({
|
||||
name: 'channels',
|
||||
columns: CHANNEL_COLUMNS,
|
||||
primaryKey: ['channel_id', 'soft_deleted'],
|
||||
});
|
||||
|
||||
export const ChannelsByGuild = defineTable<ChannelsByGuildRow, 'guild_id' | 'channel_id'>({
|
||||
name: 'channels_by_guild_id',
|
||||
columns: CHANNELS_BY_GUILD_COLUMNS,
|
||||
primaryKey: ['guild_id', 'channel_id'],
|
||||
});
|
||||
|
||||
export const ChannelState = defineTable<ChannelStateRow, 'channel_id'>({
|
||||
name: 'channel_state',
|
||||
columns: CHANNEL_STATE_COLUMNS,
|
||||
primaryKey: ['channel_id'],
|
||||
});
|
||||
|
||||
export const ChannelPins = defineTable<ChannelPinRow, 'channel_id' | 'pinned_timestamp' | 'message_id'>({
|
||||
name: 'channel_pins',
|
||||
columns: CHANNEL_PIN_COLUMNS,
|
||||
primaryKey: ['channel_id', 'pinned_timestamp', 'message_id'],
|
||||
});
|
||||
|
||||
export const ChannelMessageBuckets = defineTable<ChannelMessageBucketRow, 'channel_id' | 'bucket', 'channel_id'>({
|
||||
name: 'channel_message_buckets',
|
||||
columns: CHANNEL_MESSAGE_BUCKET_COLUMNS,
|
||||
primaryKey: ['channel_id', 'bucket'],
|
||||
partitionKey: ['channel_id'],
|
||||
});
|
||||
|
||||
export const ChannelEmptyBuckets = defineTable<ChannelEmptyBucketRow, 'channel_id' | 'bucket', 'channel_id'>({
|
||||
name: 'channel_empty_buckets',
|
||||
columns: CHANNEL_EMPTY_BUCKET_COLUMNS,
|
||||
primaryKey: ['channel_id', 'bucket'],
|
||||
partitionKey: ['channel_id'],
|
||||
});
|
||||
|
||||
export const PrivateChannels = defineTable<PrivateChannelRow, 'user_id' | 'channel_id'>({
|
||||
name: 'private_channels',
|
||||
columns: PRIVATE_CHANNEL_COLUMNS,
|
||||
primaryKey: ['user_id', 'channel_id'],
|
||||
});
|
||||
|
||||
export const DmStates = defineTable<DmStateRow, 'hi_user_id' | 'lo_user_id' | 'channel_id'>({
|
||||
name: 'dm_states',
|
||||
columns: DM_STATE_COLUMNS,
|
||||
primaryKey: ['hi_user_id', 'lo_user_id', 'channel_id'],
|
||||
});
|
||||
|
||||
interface PinnedDmRow {
|
||||
user_id: bigint;
|
||||
channel_id: bigint;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
const PINNED_DM_COLUMNS = ['user_id', 'channel_id', 'sort_order'] as const satisfies ReadonlyArray<keyof PinnedDmRow>;
|
||||
|
||||
export const PinnedDms = defineTable<PinnedDmRow, 'user_id' | 'channel_id'>({
|
||||
name: 'pinned_dms',
|
||||
columns: PINNED_DM_COLUMNS,
|
||||
primaryKey: ['user_id', 'channel_id'],
|
||||
});
|
||||
|
||||
interface ReadStateRow {
|
||||
user_id: bigint;
|
||||
channel_id: bigint;
|
||||
}
|
||||
|
||||
const READ_STATE_COLUMNS = ['user_id', 'channel_id'] as const satisfies ReadonlyArray<keyof ReadStateRow>;
|
||||
|
||||
export const ReadStates = defineTable<ReadStateRow, 'user_id' | 'channel_id'>({
|
||||
name: 'read_states',
|
||||
columns: READ_STATE_COLUMNS,
|
||||
primaryKey: ['user_id', 'channel_id'],
|
||||
});
|
||||
|
||||
export const Messages = defineTable<MessageRow, 'channel_id' | 'bucket' | 'message_id'>({
|
||||
name: 'messages',
|
||||
columns: MESSAGE_COLUMNS,
|
||||
primaryKey: ['channel_id', 'bucket', 'message_id'],
|
||||
});
|
||||
|
||||
export const MessagesByAuthor = defineTable<MessageByAuthorRow, 'author_id' | 'channel_id' | 'message_id'>({
|
||||
name: 'messages_by_author_id',
|
||||
columns: MESSAGE_BY_AUTHOR_COLUMNS,
|
||||
primaryKey: ['author_id', 'channel_id', 'message_id'],
|
||||
});
|
||||
|
||||
export const MessageReactions = defineTable<
|
||||
MessageReactionRow,
|
||||
'channel_id' | 'bucket' | 'message_id' | 'emoji_id' | 'emoji_name' | 'user_id',
|
||||
'channel_id' | 'bucket' | 'message_id'
|
||||
>({
|
||||
name: 'message_reactions',
|
||||
columns: MESSAGE_REACTION_COLUMNS,
|
||||
primaryKey: ['channel_id', 'bucket', 'message_id', 'emoji_id', 'emoji_name', 'user_id'],
|
||||
partitionKey: ['channel_id', 'bucket', 'message_id'],
|
||||
});
|
||||
|
||||
export const AttachmentLookup = defineTable<AttachmentLookupRow, 'channel_id' | 'attachment_id' | 'filename'>({
|
||||
name: 'attachment_lookup',
|
||||
columns: ATTACHMENT_LOOKUP_COLUMNS,
|
||||
primaryKey: ['channel_id', 'attachment_id', 'filename'],
|
||||
});
|
||||
|
||||
export const RecentMentions = defineTable<RecentMentionRow, 'user_id' | 'message_id'>({
|
||||
name: 'recent_mentions',
|
||||
columns: RECENT_MENTION_COLUMNS,
|
||||
primaryKey: ['user_id', 'message_id'],
|
||||
});
|
||||
|
||||
interface RecentMentionsByGuildRow {
|
||||
user_id: bigint;
|
||||
guild_id: bigint;
|
||||
message_id: bigint;
|
||||
channel_id: bigint;
|
||||
is_everyone: boolean;
|
||||
is_role: boolean;
|
||||
}
|
||||
|
||||
const RECENT_MENTIONS_BY_GUILD_COLUMNS = [
|
||||
'user_id',
|
||||
'guild_id',
|
||||
'message_id',
|
||||
'channel_id',
|
||||
'is_everyone',
|
||||
'is_role',
|
||||
] as const satisfies ReadonlyArray<keyof RecentMentionsByGuildRow>;
|
||||
|
||||
export const RecentMentionsByGuild = defineTable<RecentMentionsByGuildRow, 'user_id' | 'guild_id' | 'message_id'>({
|
||||
name: 'recent_mentions_by_guild',
|
||||
columns: RECENT_MENTIONS_BY_GUILD_COLUMNS,
|
||||
primaryKey: ['user_id', 'guild_id', 'message_id'],
|
||||
});
|
||||
|
||||
export const SavedMessages = defineTable<SavedMessageRow, 'user_id' | 'message_id'>({
|
||||
name: 'saved_messages',
|
||||
columns: SAVED_MESSAGE_COLUMNS,
|
||||
primaryKey: ['user_id', 'message_id'],
|
||||
});
|
||||
|
||||
export const ScheduledMessages = defineTable<ScheduledMessageRow, 'user_id' | 'scheduled_message_id'>({
|
||||
name: 'scheduled_messages',
|
||||
columns: SCHEDULED_MESSAGE_COLUMNS,
|
||||
primaryKey: ['user_id', 'scheduled_message_id'],
|
||||
});
|
||||
|
||||
export const PushSubscriptions = defineTable<PushSubscriptionRow, 'user_id' | 'subscription_id'>({
|
||||
name: 'push_subscriptions',
|
||||
columns: PUSH_SUBSCRIPTION_COLUMNS,
|
||||
primaryKey: ['user_id', 'subscription_id'],
|
||||
});
|
||||
|
||||
export const Payments = defineTable<PaymentRow, 'checkout_session_id'>({
|
||||
name: 'payments',
|
||||
columns: PAYMENT_COLUMNS,
|
||||
primaryKey: ['checkout_session_id'],
|
||||
});
|
||||
|
||||
export const PaymentsByPaymentIntent = defineTable<PaymentByPaymentIntentRow, 'payment_intent_id'>({
|
||||
name: 'payments_by_payment_intent',
|
||||
columns: PAYMENT_BY_PAYMENT_INTENT_COLUMNS,
|
||||
primaryKey: ['payment_intent_id'],
|
||||
});
|
||||
|
||||
export const PaymentsBySubscription = defineTable<PaymentBySubscriptionRow, 'subscription_id'>({
|
||||
name: 'payments_by_subscription',
|
||||
columns: PAYMENT_BY_SUBSCRIPTION_COLUMNS,
|
||||
primaryKey: ['subscription_id'],
|
||||
});
|
||||
|
||||
export const PaymentsByUser = defineTable<PaymentByUserRow, 'user_id' | 'created_at'>({
|
||||
name: 'payments_by_user',
|
||||
columns: PAYMENT_BY_USER_COLUMNS,
|
||||
primaryKey: ['user_id', 'created_at'],
|
||||
});
|
||||
|
||||
export const VisionarySlots = defineTable<VisionarySlotRow, 'slot_index'>({
|
||||
name: 'visionary_slots',
|
||||
columns: VISIONARY_SLOT_COLUMNS,
|
||||
primaryKey: ['slot_index'],
|
||||
});
|
||||
|
||||
export const GiftCodes = defineTable<GiftCodeRow, 'code'>({
|
||||
name: 'gift_codes',
|
||||
columns: GIFT_CODE_COLUMNS,
|
||||
primaryKey: ['code'],
|
||||
});
|
||||
|
||||
export const GiftCodesByCreator = defineTable<GiftCodeByCreatorRow, 'created_by_user_id' | 'code'>({
|
||||
name: 'gift_codes_by_creator',
|
||||
columns: GIFT_CODE_BY_CREATOR_COLUMNS,
|
||||
primaryKey: ['created_by_user_id', 'code'],
|
||||
});
|
||||
|
||||
export const GiftCodesByPaymentIntent = defineTable<GiftCodeByPaymentIntentRow, 'stripe_payment_intent_id'>({
|
||||
name: 'gift_codes_by_payment_intent',
|
||||
columns: GIFT_CODE_BY_PAYMENT_INTENT_COLUMNS,
|
||||
primaryKey: ['stripe_payment_intent_id'],
|
||||
});
|
||||
|
||||
export const GiftCodesByRedeemer = defineTable<GiftCodeByRedeemerRow, 'redeemed_by_user_id' | 'code'>({
|
||||
name: 'gift_codes_by_redeemer',
|
||||
columns: GIFT_CODE_BY_REDEEMER_COLUMNS,
|
||||
primaryKey: ['redeemed_by_user_id', 'code'],
|
||||
});
|
||||
|
||||
export const BetaCodes = defineTable<BetaCodeRow, 'creator_id' | 'code'>({
|
||||
name: 'beta_codes',
|
||||
columns: BETA_CODE_COLUMNS,
|
||||
primaryKey: ['creator_id', 'code'],
|
||||
});
|
||||
|
||||
export const BetaCodesByCode = defineTable<BetaCodeByCodeRow, 'code' | 'creator_id'>({
|
||||
name: 'beta_codes_by_code',
|
||||
columns: BETA_CODE_BY_CODE_COLUMNS,
|
||||
primaryKey: ['code', 'creator_id'],
|
||||
});
|
||||
|
||||
export const AdminArchivesBySubject = defineTable<AdminArchiveRow, 'subject_type' | 'subject_id' | 'archive_id'>({
|
||||
name: 'admin_archives_by_subject',
|
||||
columns: ADMIN_ARCHIVE_COLUMNS,
|
||||
primaryKey: ['subject_type', 'subject_id', 'archive_id'],
|
||||
});
|
||||
|
||||
export const AdminArchivesByRequester = defineTable<AdminArchiveRow, 'requested_by' | 'archive_id'>({
|
||||
name: 'admin_archives_by_requester',
|
||||
columns: ADMIN_ARCHIVE_COLUMNS,
|
||||
primaryKey: ['requested_by', 'archive_id'],
|
||||
});
|
||||
|
||||
export const AdminArchivesByType = defineTable<AdminArchiveRow, 'subject_type' | 'archive_id'>({
|
||||
name: 'admin_archives_by_type',
|
||||
columns: ADMIN_ARCHIVE_COLUMNS,
|
||||
primaryKey: ['subject_type', 'archive_id'],
|
||||
});
|
||||
|
||||
export const AdminAuditLogs = defineTable<AdminAuditLogRow, 'log_id'>({
|
||||
name: 'admin_audit_logs',
|
||||
columns: ADMIN_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['log_id'],
|
||||
});
|
||||
|
||||
export const BannedIps = defineTable<BannedIpRow, 'ip'>({
|
||||
name: 'banned_ips',
|
||||
columns: BANNED_IP_COLUMNS,
|
||||
primaryKey: ['ip'],
|
||||
});
|
||||
|
||||
export const BannedEmails = defineTable<BannedEmailRow, 'email_lower'>({
|
||||
name: 'banned_emails',
|
||||
columns: BANNED_EMAIL_COLUMNS,
|
||||
primaryKey: ['email_lower'],
|
||||
});
|
||||
|
||||
export const BannedPhones = defineTable<BannedPhoneRow, 'phone'>({
|
||||
name: 'banned_phones',
|
||||
columns: BANNED_PHONE_COLUMNS,
|
||||
primaryKey: ['phone'],
|
||||
});
|
||||
|
||||
export const PendingVerifications = defineTable<PendingVerificationRow, 'user_id'>({
|
||||
name: 'pending_verifications',
|
||||
columns: PENDING_VERIFICATION_COLUMNS,
|
||||
primaryKey: ['user_id'],
|
||||
});
|
||||
|
||||
export const IARSubmissions = defineTable<IARSubmissionRow, 'report_id'>({
|
||||
name: 'iar_submissions',
|
||||
columns: IAR_SUBMISSION_COLUMNS,
|
||||
primaryKey: ['report_id'],
|
||||
});
|
||||
|
||||
export const DSAReportEmailVerifications = defineTable<DSAReportEmailVerificationRow, 'email_lower'>({
|
||||
name: 'dsa_report_email_verifications',
|
||||
columns: DSA_REPORT_EMAIL_VERIFICATION_COLUMNS,
|
||||
primaryKey: ['email_lower'],
|
||||
});
|
||||
|
||||
export const DSAReportTickets = defineTable<DSAReportTicketRow, 'ticket'>({
|
||||
name: 'dsa_report_tickets',
|
||||
columns: DSA_REPORT_TICKET_COLUMNS,
|
||||
primaryKey: ['ticket'],
|
||||
});
|
||||
|
||||
export const EmailVerificationTokens = defineTable<EmailVerificationTokenRow, 'token_' | 'user_id'>({
|
||||
name: 'email_verification_tokens',
|
||||
columns: EMAIL_VERIFICATION_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
});
|
||||
|
||||
export const PasswordResetTokens = defineTable<PasswordResetTokenRow, 'token_' | 'user_id'>({
|
||||
name: 'password_reset_tokens',
|
||||
columns: PASSWORD_RESET_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
});
|
||||
|
||||
export const EmailRevertTokens = defineTable<EmailRevertTokenRow, 'token_' | 'user_id'>({
|
||||
name: 'email_revert_tokens',
|
||||
columns: EMAIL_REVERT_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
});
|
||||
|
||||
export const PhoneTokens = defineTable<PhoneTokenRow, 'token_' | 'user_id'>({
|
||||
name: 'phone_tokens',
|
||||
columns: PHONE_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
});
|
||||
|
||||
export const AuthSessions = defineTable<AuthSessionRow, 'session_id_hash'>({
|
||||
name: 'auth_sessions',
|
||||
columns: AUTH_SESSION_COLUMNS,
|
||||
primaryKey: ['session_id_hash'],
|
||||
});
|
||||
|
||||
export const AuthSessionsByUserId = defineTable<
|
||||
{user_id: UserID; session_id_hash: Buffer},
|
||||
'user_id' | 'session_id_hash'
|
||||
>({
|
||||
name: 'auth_sessions_by_user_id',
|
||||
columns: ['user_id', 'session_id_hash'],
|
||||
primaryKey: ['user_id', 'session_id_hash'],
|
||||
});
|
||||
|
||||
export const MfaBackupCodes = defineTable<MfaBackupCodeRow, 'user_id' | 'code'>({
|
||||
name: 'mfa_backup_codes',
|
||||
columns: MFA_BACKUP_CODE_COLUMNS,
|
||||
primaryKey: ['user_id', 'code'],
|
||||
});
|
||||
|
||||
export const WebAuthnCredentials = defineTable<WebAuthnCredentialRow, 'user_id' | 'credential_id'>({
|
||||
name: 'webauthn_credentials',
|
||||
columns: WEBAUTHN_CREDENTIAL_COLUMNS,
|
||||
primaryKey: ['user_id', 'credential_id'],
|
||||
});
|
||||
|
||||
export const WebAuthnCredentialLookup = defineTable<{credential_id: string; user_id: UserID}, 'credential_id'>({
|
||||
name: 'webauthn_credential_lookup',
|
||||
columns: ['credential_id', 'user_id'],
|
||||
primaryKey: ['credential_id'],
|
||||
});
|
||||
|
||||
export const IpAuthorizationTokens = defineTable<IpAuthorizationTokenRow, 'token_' | 'user_id'>({
|
||||
name: 'ip_authorization_tokens',
|
||||
columns: IP_AUTHORIZATION_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
});
|
||||
|
||||
export const AuthorizedIps = defineTable<AuthorizedIpRow, 'user_id' | 'ip'>({
|
||||
name: 'authorized_ips_v2',
|
||||
columns: AUTHORIZED_IP_COLUMNS,
|
||||
primaryKey: ['user_id', 'ip'],
|
||||
});
|
||||
|
||||
export const EmailChangeTickets = defineTable<EmailChangeTicketRow, 'ticket'>({
|
||||
name: 'email_change_tickets',
|
||||
columns: EMAIL_CHANGE_TICKET_COLUMNS,
|
||||
primaryKey: ['ticket'],
|
||||
});
|
||||
|
||||
export const EmailChangeTokens = defineTable<EmailChangeTokenRow, 'token_'>({
|
||||
name: 'email_change_tokens',
|
||||
columns: EMAIL_CHANGE_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_'],
|
||||
});
|
||||
|
||||
export const PendingVerificationsByTime = defineTable<{created_at: Date; user_id: UserID}, 'created_at' | 'user_id'>({
|
||||
name: 'pending_verifications_by_time',
|
||||
columns: ['created_at', 'user_id'],
|
||||
primaryKey: ['created_at', 'user_id'],
|
||||
});
|
||||
|
||||
interface AttachmentDecayByExpiryRow {
|
||||
expiry_bucket: number;
|
||||
expires_at: Date;
|
||||
attachment_id: AttachmentID;
|
||||
channel_id: ChannelID;
|
||||
message_id: bigint;
|
||||
}
|
||||
|
||||
const ATTACHMENT_DECAY_BY_EXPIRY_COLUMNS = [
|
||||
'expiry_bucket',
|
||||
'expires_at',
|
||||
'attachment_id',
|
||||
'channel_id',
|
||||
'message_id',
|
||||
] as const satisfies ReadonlyArray<keyof AttachmentDecayByExpiryRow>;
|
||||
|
||||
export const AttachmentDecayById = defineTable<AttachmentDecayRow, 'attachment_id'>({
|
||||
name: 'attachment_decay_by_id',
|
||||
columns: ATTACHMENT_DECAY_COLUMNS,
|
||||
primaryKey: ['attachment_id'],
|
||||
});
|
||||
|
||||
export const AttachmentDecayByExpiry = defineTable<
|
||||
AttachmentDecayByExpiryRow,
|
||||
'expiry_bucket' | 'expires_at' | 'attachment_id'
|
||||
>({
|
||||
name: 'attachment_decay_by_expiry',
|
||||
columns: ATTACHMENT_DECAY_BY_EXPIRY_COLUMNS,
|
||||
primaryKey: ['expiry_bucket', 'expires_at', 'attachment_id'],
|
||||
});
|
||||
|
||||
interface FavoriteMemesByMemeIdRow {
|
||||
meme_id: MemeID;
|
||||
user_id: UserID;
|
||||
}
|
||||
|
||||
const FAVORITE_MEMES_BY_MEME_ID_COLUMNS = ['meme_id', 'user_id'] as const satisfies ReadonlyArray<
|
||||
keyof FavoriteMemesByMemeIdRow
|
||||
>;
|
||||
|
||||
export const FavoriteMemes = defineTable<FavoriteMemeRow, 'user_id' | 'meme_id'>({
|
||||
name: 'favorite_memes',
|
||||
columns: FAVORITE_MEME_COLUMNS,
|
||||
primaryKey: ['user_id', 'meme_id'],
|
||||
});
|
||||
|
||||
export const FavoriteMemesByMemeId = defineTable<FavoriteMemesByMemeIdRow, 'meme_id' | 'user_id'>({
|
||||
name: 'favorite_memes_by_meme_id',
|
||||
columns: FAVORITE_MEMES_BY_MEME_ID_COLUMNS,
|
||||
primaryKey: ['meme_id', 'user_id'],
|
||||
});
|
||||
|
||||
export const ExpressionPacks = defineTable<ExpressionPackRow, 'pack_id'>({
|
||||
name: 'expression_packs',
|
||||
columns: EXPRESSION_PACK_COLUMNS,
|
||||
primaryKey: ['pack_id'],
|
||||
});
|
||||
|
||||
export const ExpressionPacksByCreator = defineTable<ExpressionPackRow, 'creator_id' | 'pack_id'>({
|
||||
name: 'expression_packs_by_creator',
|
||||
columns: EXPRESSION_PACK_COLUMNS,
|
||||
primaryKey: ['creator_id', 'pack_id'],
|
||||
partitionKey: ['creator_id'],
|
||||
});
|
||||
|
||||
const PACK_INSTALLATION_COLUMNS = ['user_id', 'pack_id', 'pack_type', 'installed_at'] as const satisfies ReadonlyArray<
|
||||
keyof PackInstallationRow
|
||||
>;
|
||||
|
||||
export const PackInstallations = defineTable<PackInstallationRow, 'user_id' | 'pack_id'>({
|
||||
name: 'pack_installations',
|
||||
columns: PACK_INSTALLATION_COLUMNS,
|
||||
primaryKey: ['user_id', 'pack_id'],
|
||||
});
|
||||
|
||||
interface InvitesByChannelRow {
|
||||
channel_id: ChannelID;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface InvitesByGuildRow {
|
||||
guild_id: GuildID;
|
||||
code: string;
|
||||
}
|
||||
|
||||
const INVITES_BY_CHANNEL_COLUMNS = ['channel_id', 'code'] as const satisfies ReadonlyArray<keyof InvitesByChannelRow>;
|
||||
|
||||
const INVITES_BY_GUILD_COLUMNS = ['guild_id', 'code'] as const satisfies ReadonlyArray<keyof InvitesByGuildRow>;
|
||||
|
||||
export const Invites = defineTable<InviteRow, 'code'>({
|
||||
name: 'invites',
|
||||
columns: INVITE_COLUMNS,
|
||||
primaryKey: ['code'],
|
||||
});
|
||||
|
||||
export const InvitesByChannel = defineTable<InvitesByChannelRow, 'channel_id' | 'code'>({
|
||||
name: 'invites_by_channel_id',
|
||||
columns: INVITES_BY_CHANNEL_COLUMNS,
|
||||
primaryKey: ['channel_id', 'code'],
|
||||
});
|
||||
|
||||
export const InvitesByGuild = defineTable<InvitesByGuildRow, 'guild_id' | 'code'>({
|
||||
name: 'invites_by_guild_id',
|
||||
columns: INVITES_BY_GUILD_COLUMNS,
|
||||
primaryKey: ['guild_id', 'code'],
|
||||
});
|
||||
|
||||
const APPLICATIONS_BY_OWNER_COLUMNS = ['owner_user_id', 'application_id'] as const satisfies ReadonlyArray<
|
||||
keyof ApplicationByOwnerRow
|
||||
>;
|
||||
|
||||
export const Applications = defineTable<ApplicationRow, 'application_id'>({
|
||||
name: 'applications',
|
||||
columns: APPLICATION_COLUMNS,
|
||||
primaryKey: ['application_id'],
|
||||
});
|
||||
|
||||
export const ApplicationsByOwner = defineTable<ApplicationByOwnerRow, 'owner_user_id' | 'application_id'>({
|
||||
name: 'applications_by_owner',
|
||||
columns: APPLICATIONS_BY_OWNER_COLUMNS,
|
||||
primaryKey: ['owner_user_id', 'application_id'],
|
||||
});
|
||||
|
||||
const OAUTH2_ACCESS_TOKENS_BY_USER_COLUMNS = ['user_id', 'token_'] as const satisfies ReadonlyArray<
|
||||
keyof OAuth2AccessTokenByUserRow
|
||||
>;
|
||||
|
||||
const OAUTH2_REFRESH_TOKENS_BY_USER_COLUMNS = ['user_id', 'token_'] as const satisfies ReadonlyArray<
|
||||
keyof OAuth2RefreshTokenByUserRow
|
||||
>;
|
||||
|
||||
export const OAuth2AuthorizationCodes = defineTable<OAuth2AuthorizationCodeRow, 'code'>({
|
||||
name: 'oauth2_authorization_codes',
|
||||
columns: OAUTH2_AUTHORIZATION_CODE_COLUMNS,
|
||||
primaryKey: ['code'],
|
||||
});
|
||||
|
||||
export const OAuth2AccessTokens = defineTable<OAuth2AccessTokenRow, 'token_'>({
|
||||
name: 'oauth2_access_tokens',
|
||||
columns: OAUTH2_ACCESS_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_'],
|
||||
});
|
||||
|
||||
export const OAuth2AccessTokensByUser = defineTable<OAuth2AccessTokenByUserRow, 'user_id' | 'token_'>({
|
||||
name: 'oauth2_access_tokens_by_user',
|
||||
columns: OAUTH2_ACCESS_TOKENS_BY_USER_COLUMNS,
|
||||
primaryKey: ['user_id', 'token_'],
|
||||
});
|
||||
|
||||
export const OAuth2RefreshTokens = defineTable<OAuth2RefreshTokenRow, 'token_'>({
|
||||
name: 'oauth2_refresh_tokens',
|
||||
columns: OAUTH2_REFRESH_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_'],
|
||||
});
|
||||
|
||||
export const OAuth2RefreshTokensByUser = defineTable<OAuth2RefreshTokenByUserRow, 'user_id' | 'token_'>({
|
||||
name: 'oauth2_refresh_tokens_by_user',
|
||||
columns: OAUTH2_REFRESH_TOKENS_BY_USER_COLUMNS,
|
||||
primaryKey: ['user_id', 'token_'],
|
||||
});
|
||||
|
||||
interface WebhooksByChannelRow {
|
||||
channel_id: ChannelID;
|
||||
webhook_id: bigint;
|
||||
}
|
||||
|
||||
interface WebhooksByGuildRow {
|
||||
guild_id: GuildID;
|
||||
webhook_id: bigint;
|
||||
}
|
||||
|
||||
const WEBHOOKS_BY_CHANNEL_COLUMNS = ['channel_id', 'webhook_id'] as const satisfies ReadonlyArray<
|
||||
keyof WebhooksByChannelRow
|
||||
>;
|
||||
|
||||
const WEBHOOKS_BY_GUILD_COLUMNS = ['guild_id', 'webhook_id'] as const satisfies ReadonlyArray<keyof WebhooksByGuildRow>;
|
||||
|
||||
export const Webhooks = defineTable<WebhookRow, 'webhook_id' | 'webhook_token'>({
|
||||
name: 'webhooks',
|
||||
columns: WEBHOOK_COLUMNS,
|
||||
primaryKey: ['webhook_id', 'webhook_token'],
|
||||
});
|
||||
|
||||
export const WebhooksByChannel = defineTable<WebhooksByChannelRow, 'channel_id' | 'webhook_id'>({
|
||||
name: 'webhooks_by_channel_id',
|
||||
columns: WEBHOOKS_BY_CHANNEL_COLUMNS,
|
||||
primaryKey: ['channel_id', 'webhook_id'],
|
||||
});
|
||||
|
||||
export const WebhooksByGuild = defineTable<WebhooksByGuildRow, 'guild_id' | 'webhook_id'>({
|
||||
name: 'webhooks_by_guild_id',
|
||||
columns: WEBHOOKS_BY_GUILD_COLUMNS,
|
||||
primaryKey: ['guild_id', 'webhook_id'],
|
||||
});
|
||||
|
||||
export const InstanceConfiguration = defineTable<InstanceConfigurationRow, 'key'>({
|
||||
name: 'instance_configuration',
|
||||
columns: INSTANCE_CONFIGURATION_COLUMNS,
|
||||
primaryKey: ['key'],
|
||||
});
|
||||
183
fluxer_api/src/Validator.ts
Normal file
183
fluxer_api/src/Validator.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* 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 {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
|
||||
import {getCookie} from 'hono/cookie';
|
||||
import type {ZodError, ZodType} from 'zod';
|
||||
import {InputValidationError, type ValidationError} from '~/Errors';
|
||||
|
||||
const isEmptyObject = (obj: object): boolean => Object.keys(obj).length === 0;
|
||||
|
||||
const convertEmptyValuesToNull = (obj: unknown, isRoot = true): unknown => {
|
||||
if (typeof obj === 'string' && obj === '') return null;
|
||||
if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, false));
|
||||
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
if (isEmptyObject(obj) && !isRoot) return null;
|
||||
|
||||
const processed = Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) => [key, convertEmptyValuesToNull(value, false)]),
|
||||
);
|
||||
|
||||
if (!isRoot && Object.values(processed).every((value) => value === null)) return null;
|
||||
return processed;
|
||||
}
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
type HasUndefined<T> = undefined extends T ? true : false;
|
||||
type SafeParseResult<T extends ZodType> =
|
||||
| {success: true; data: T['_output']}
|
||||
| {success: false; error: ZodError<T['_input']>};
|
||||
|
||||
type Hook<
|
||||
T extends ZodType,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
Target extends keyof ValidationTargets = keyof ValidationTargets,
|
||||
V extends Input = Input,
|
||||
O = Record<string, unknown>,
|
||||
> = (
|
||||
result: SafeParseResult<T> & {target: Target},
|
||||
c: Context<E, P, V>,
|
||||
) => Response | undefined | TypedResponse<O> | Promise<Response | undefined | TypedResponse<O>>;
|
||||
|
||||
type PreHook<E extends Env, P extends string, Target extends keyof ValidationTargets, V extends Input> = (
|
||||
value: unknown,
|
||||
c: Context<E, P, V>,
|
||||
target: Target,
|
||||
) => unknown | Promise<unknown>;
|
||||
|
||||
type ValidatorOptions<
|
||||
T extends ZodType,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
Target extends keyof ValidationTargets,
|
||||
V extends Input,
|
||||
> = {
|
||||
pre?: PreHook<E, P, Target, V>;
|
||||
post?: Hook<T, E, P, Target, V>;
|
||||
};
|
||||
|
||||
export const Validator = <
|
||||
T extends ZodType,
|
||||
Target extends keyof ValidationTargets,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
In = T['_input'],
|
||||
Out = T['_output'],
|
||||
I extends Input = {
|
||||
in: HasUndefined<In> extends true
|
||||
? {[K in Target]?: In extends ValidationTargets[K] ? In : {[K2 in keyof In]?: ValidationTargets[K][K2]}}
|
||||
: {[K in Target]: In extends ValidationTargets[K] ? In : {[K2 in keyof In]: ValidationTargets[K][K2]}};
|
||||
out: {[K in Target]: Out};
|
||||
},
|
||||
V extends I = I,
|
||||
>(
|
||||
target: Target,
|
||||
schema: T,
|
||||
hookOrOptions?: Hook<T, E, P, Target, V> | ValidatorOptions<T, E, P, Target, V>,
|
||||
): MiddlewareHandler<E, P, V> => {
|
||||
const options: ValidatorOptions<T, E, P, Target, V> =
|
||||
typeof hookOrOptions === 'function' ? {post: hookOrOptions} : (hookOrOptions ?? {});
|
||||
|
||||
return async (c, next): Promise<Response | undefined> => {
|
||||
let value: unknown;
|
||||
switch (target) {
|
||||
case 'json':
|
||||
try {
|
||||
value = await c.req.json<unknown>();
|
||||
} catch {
|
||||
value = {};
|
||||
}
|
||||
break;
|
||||
case 'form': {
|
||||
const formData = await c.req.formData();
|
||||
type FormValue = FormDataEntryValue | Array<FormDataEntryValue>;
|
||||
const form: Record<string, FormValue> = {};
|
||||
formData.forEach((formValue, key) => {
|
||||
const existingValue = form[key];
|
||||
if (key.endsWith('[]')) {
|
||||
const list = Array.isArray(existingValue)
|
||||
? existingValue
|
||||
: existingValue !== undefined
|
||||
? [existingValue]
|
||||
: [];
|
||||
list.push(formValue);
|
||||
form[key] = list;
|
||||
} else if (Array.isArray(existingValue)) {
|
||||
existingValue.push(formValue);
|
||||
} else if (existingValue !== undefined) {
|
||||
form[key] = [existingValue, formValue];
|
||||
} else {
|
||||
form[key] = formValue;
|
||||
}
|
||||
});
|
||||
value = form;
|
||||
break;
|
||||
}
|
||||
case 'query':
|
||||
value = Object.fromEntries(
|
||||
Object.entries(c.req.queries()).map(([k, v]) => (v.length === 1 ? [k, v[0]] : [k, v])),
|
||||
);
|
||||
break;
|
||||
case 'param':
|
||||
value = c.req.param();
|
||||
break;
|
||||
case 'header':
|
||||
value = c.req.header();
|
||||
break;
|
||||
case 'cookie':
|
||||
value = getCookie(c);
|
||||
break;
|
||||
default:
|
||||
value = {};
|
||||
}
|
||||
|
||||
if (options.pre) {
|
||||
value = await options.pre(value, c, target);
|
||||
}
|
||||
|
||||
const transformedValue = convertEmptyValuesToNull(value);
|
||||
|
||||
const result = await schema.safeParseAsync(transformedValue);
|
||||
|
||||
if (options.post) {
|
||||
const hookResult = await options.post({...result, target}, c);
|
||||
if (hookResult) {
|
||||
if (hookResult instanceof Response) return hookResult;
|
||||
if ('response' in hookResult && hookResult.response instanceof Response) return hookResult.response;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
const errors: Array<ValidationError> = [];
|
||||
for (const issue of result.error.issues) {
|
||||
const path = issue.path.length > 0 ? issue.path.join('.') : 'root';
|
||||
errors.push({path, message: issue.message});
|
||||
}
|
||||
throw new InputValidationError(errors);
|
||||
}
|
||||
|
||||
c.req.addValidatedData(target, result.data as ValidationTargets[Target]);
|
||||
await next();
|
||||
return;
|
||||
};
|
||||
};
|
||||
20
fluxer_api/src/admin/AdminModel.ts
Normal file
20
fluxer_api/src/admin/AdminModel.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export * from './models';
|
||||
182
fluxer_api/src/admin/AdminRepository.ts
Normal file
182
fluxer_api/src/admin/AdminRepository.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* 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 {UserID} from '~/BrandedTypes';
|
||||
import {createUserID} from '~/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {AdminAuditLogRow, PendingVerificationRow} from '~/database/types/AdminArchiveTypes';
|
||||
import {AdminAuditLogs, BannedEmails, BannedIps, BannedPhones, PendingVerifications} from '~/Tables';
|
||||
import type {AdminAuditLog, IAdminRepository} from './IAdminRepository';
|
||||
|
||||
const FETCH_AUDIT_LOG_BY_ID_QUERY = AdminAuditLogs.select({
|
||||
where: AdminAuditLogs.where.eq('log_id'),
|
||||
});
|
||||
|
||||
const FETCH_AUDIT_LOGS_BY_IDS_QUERY = AdminAuditLogs.select({
|
||||
where: AdminAuditLogs.where.in('log_id', 'log_ids'),
|
||||
});
|
||||
|
||||
const IS_IP_BANNED_QUERY = BannedIps.select({
|
||||
where: BannedIps.where.eq('ip'),
|
||||
});
|
||||
|
||||
const LOAD_ALL_BANNED_IPS_QUERY = BannedIps.select();
|
||||
|
||||
const IS_EMAIL_BANNED_QUERY = BannedEmails.select({
|
||||
where: BannedEmails.where.eq('email_lower'),
|
||||
});
|
||||
|
||||
const IS_PHONE_BANNED_QUERY = BannedPhones.select({
|
||||
where: BannedPhones.where.eq('phone'),
|
||||
});
|
||||
|
||||
const createListAllAuditLogsPaginatedQuery = (limit: number) =>
|
||||
AdminAuditLogs.select({
|
||||
where: AdminAuditLogs.where.tokenGt('log_id', 'last_log_id'),
|
||||
limit,
|
||||
});
|
||||
|
||||
const createListAllAuditLogsFirstPageQuery = (limit: number) =>
|
||||
AdminAuditLogs.select({
|
||||
limit,
|
||||
});
|
||||
|
||||
const createListPendingVerificationsQuery = (limit: number) =>
|
||||
PendingVerifications.select({
|
||||
limit,
|
||||
});
|
||||
|
||||
export class AdminRepository implements IAdminRepository {
|
||||
async createAuditLog(log: AdminAuditLogRow): Promise<AdminAuditLog> {
|
||||
await upsertOne(AdminAuditLogs.insert(log));
|
||||
return this.mapRowToAuditLog(log);
|
||||
}
|
||||
|
||||
async getAuditLog(logId: bigint): Promise<AdminAuditLog | null> {
|
||||
const row = await fetchOne<AdminAuditLogRow>(FETCH_AUDIT_LOG_BY_ID_QUERY.bind({log_id: logId}));
|
||||
return row ? this.mapRowToAuditLog(row) : null;
|
||||
}
|
||||
|
||||
async listAuditLogsByIds(logIds: Array<bigint>): Promise<Array<AdminAuditLog>> {
|
||||
if (logIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await fetchMany<AdminAuditLogRow>(FETCH_AUDIT_LOGS_BY_IDS_QUERY.bind({log_ids: logIds}));
|
||||
return rows.map((row) => this.mapRowToAuditLog(row));
|
||||
}
|
||||
|
||||
async listAllAuditLogsPaginated(limit: number, lastLogId?: bigint): Promise<Array<AdminAuditLog>> {
|
||||
let rows: Array<AdminAuditLogRow>;
|
||||
|
||||
if (lastLogId) {
|
||||
const query = createListAllAuditLogsPaginatedQuery(limit);
|
||||
rows = await fetchMany<AdminAuditLogRow>(query.bind({last_log_id: lastLogId}));
|
||||
} else {
|
||||
const query = createListAllAuditLogsFirstPageQuery(limit);
|
||||
rows = await fetchMany<AdminAuditLogRow>(query.bind({}));
|
||||
}
|
||||
|
||||
return rows.map((row) => this.mapRowToAuditLog(row));
|
||||
}
|
||||
|
||||
async isIpBanned(ip: string): Promise<boolean> {
|
||||
const result = await fetchOne<{ip: string}>(IS_IP_BANNED_QUERY.bind({ip}));
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async banIp(ip: string): Promise<void> {
|
||||
await upsertOne(BannedIps.insert({ip}));
|
||||
}
|
||||
|
||||
async unbanIp(ip: string): Promise<void> {
|
||||
await deleteOneOrMany(BannedIps.deleteByPk({ip}));
|
||||
}
|
||||
|
||||
async listBannedIps(): Promise<Array<string>> {
|
||||
const rows = await fetchMany<{ip: string}>(LOAD_ALL_BANNED_IPS_QUERY.bind({}));
|
||||
return rows.map((row) => row.ip);
|
||||
}
|
||||
|
||||
async loadAllBannedIps(): Promise<Set<string>> {
|
||||
const rows = await fetchMany<{ip: string}>(LOAD_ALL_BANNED_IPS_QUERY.bind({}));
|
||||
return new Set(rows.map((row) => row.ip));
|
||||
}
|
||||
|
||||
async isEmailBanned(email: string): Promise<boolean> {
|
||||
const emailLower = email.toLowerCase();
|
||||
const result = await fetchOne<{email_lower: string}>(IS_EMAIL_BANNED_QUERY.bind({email_lower: emailLower}));
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async banEmail(email: string): Promise<void> {
|
||||
const emailLower = email.toLowerCase();
|
||||
await upsertOne(BannedEmails.insert({email_lower: emailLower}));
|
||||
}
|
||||
|
||||
async unbanEmail(email: string): Promise<void> {
|
||||
const emailLower = email.toLowerCase();
|
||||
await deleteOneOrMany(BannedEmails.deleteByPk({email_lower: emailLower}));
|
||||
}
|
||||
|
||||
async isPhoneBanned(phone: string): Promise<boolean> {
|
||||
const result = await fetchOne<{phone: string}>(IS_PHONE_BANNED_QUERY.bind({phone}));
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async banPhone(phone: string): Promise<void> {
|
||||
await upsertOne(BannedPhones.insert({phone}));
|
||||
}
|
||||
|
||||
async unbanPhone(phone: string): Promise<void> {
|
||||
await deleteOneOrMany(BannedPhones.deleteByPk({phone}));
|
||||
}
|
||||
|
||||
async listPendingVerifications(
|
||||
limit = 100,
|
||||
): Promise<Array<{userId: UserID; createdAt: Date; metadata: Map<string, string>}>> {
|
||||
const query = createListPendingVerificationsQuery(limit);
|
||||
const rows = await fetchMany<PendingVerificationRow>(query.bind({}));
|
||||
|
||||
return rows
|
||||
.sort((a, b) => (a.user_id < b.user_id ? -1 : 1))
|
||||
.map((row) => ({
|
||||
userId: createUserID(row.user_id),
|
||||
createdAt: row.created_at,
|
||||
metadata: row.metadata ?? new Map(),
|
||||
}));
|
||||
}
|
||||
|
||||
async removePendingVerification(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(PendingVerifications.deleteByPk({user_id: userId}));
|
||||
}
|
||||
|
||||
private mapRowToAuditLog(row: AdminAuditLogRow): AdminAuditLog {
|
||||
return {
|
||||
logId: row.log_id,
|
||||
adminUserId: createUserID(row.admin_user_id),
|
||||
targetType: row.target_type,
|
||||
targetId: row.target_id,
|
||||
action: row.action,
|
||||
auditLogReason: row.audit_log_reason,
|
||||
metadata: row.metadata || new Map(),
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
686
fluxer_api/src/admin/AdminService.ts
Normal file
686
fluxer_api/src/admin/AdminService.ts
Normal file
@@ -0,0 +1,686 @@
|
||||
/*
|
||||
* 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 {AuthService} from '~/auth/AuthService';
|
||||
import type {AttachmentID, ChannelID, GuildID, ReportID, UserID} from '~/BrandedTypes';
|
||||
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {GuildService} from '~/guild/services/GuildService';
|
||||
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService} from '~/infrastructure/EntityAssetService';
|
||||
import type {IAssetDeletionQueue} from '~/infrastructure/IAssetDeletionQueue';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteStore';
|
||||
import type {RedisBulkMessageDeletionQueueService} from '~/infrastructure/RedisBulkMessageDeletionQueueService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {InviteRepository} from '~/invite/InviteRepository';
|
||||
import type {InviteService} from '~/invite/InviteService';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
|
||||
import type {ReportService} from '~/report/ReportService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
|
||||
import type {VoiceRepository} from '~/voice/VoiceRepository';
|
||||
import type {IWorkerService} from '~/worker/IWorkerService';
|
||||
import type {
|
||||
BulkAddGuildMembersRequest,
|
||||
BulkScheduleUserDeletionRequest,
|
||||
BulkUpdateGuildFeaturesRequest,
|
||||
BulkUpdateUserFlagsRequest,
|
||||
CancelBulkMessageDeletionRequest,
|
||||
ChangeDobRequest,
|
||||
ChangeEmailRequest,
|
||||
ChangeUsernameRequest,
|
||||
ClearGuildFieldsRequest,
|
||||
ClearUserFieldsRequest,
|
||||
CreateVoiceRegionRequest,
|
||||
CreateVoiceServerRequest,
|
||||
DeleteAllUserMessagesRequest,
|
||||
DeleteMessageRequest,
|
||||
DeleteVoiceRegionRequest,
|
||||
DeleteVoiceServerRequest,
|
||||
DisableForSuspiciousActivityRequest,
|
||||
DisableMfaRequest,
|
||||
ForceAddUserToGuildRequest,
|
||||
GetVoiceRegionRequest,
|
||||
GetVoiceServerRequest,
|
||||
ListAuditLogsRequest,
|
||||
ListGuildEmojisResponse,
|
||||
ListGuildMembersRequest,
|
||||
ListGuildStickersResponse,
|
||||
ListUserChangeLogRequest,
|
||||
ListUserGuildsRequest,
|
||||
ListVoiceRegionsRequest,
|
||||
ListVoiceServersRequest,
|
||||
LookupGuildRequest,
|
||||
LookupMessageByAttachmentRequest,
|
||||
LookupMessageRequest,
|
||||
LookupUserRequest,
|
||||
MessageShredRequest,
|
||||
PurgeGuildAssetsRequest,
|
||||
PurgeGuildAssetsResponse,
|
||||
SearchReportsRequest,
|
||||
SendPasswordResetRequest,
|
||||
SetUserAclsRequest,
|
||||
SetUserBotStatusRequest,
|
||||
SetUserSystemStatusRequest,
|
||||
TempBanUserRequest,
|
||||
TerminateSessionsRequest,
|
||||
TransferGuildOwnershipRequest,
|
||||
UnlinkPhoneRequest,
|
||||
UpdateGuildNameRequest,
|
||||
UpdateGuildSettingsRequest,
|
||||
UpdateGuildVanityRequest,
|
||||
UpdateSuspiciousActivityFlagsRequest,
|
||||
UpdateVoiceRegionRequest,
|
||||
UpdateVoiceServerRequest,
|
||||
VerifyUserEmailRequest,
|
||||
} from './AdminModel';
|
||||
import type {IAdminRepository} from './IAdminRepository';
|
||||
import {AdminAssetPurgeService} from './services/AdminAssetPurgeService';
|
||||
import {AdminAuditService} from './services/AdminAuditService';
|
||||
import {AdminCodeGenerationService} from './services/AdminCodeGenerationService';
|
||||
import {AdminGuildService} from './services/AdminGuildService';
|
||||
import {AdminMessageDeletionService} from './services/AdminMessageDeletionService';
|
||||
import {AdminMessageService} from './services/AdminMessageService';
|
||||
import {AdminMessageShredService} from './services/AdminMessageShredService';
|
||||
import {AdminReportService} from './services/AdminReportService';
|
||||
import {AdminSearchService} from './services/AdminSearchService';
|
||||
import {AdminUserService} from './services/AdminUserService';
|
||||
import {AdminVoiceService} from './services/AdminVoiceService';
|
||||
|
||||
interface ForceAddUserToGuildParams {
|
||||
data: ForceAddUserToGuildRequest;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface LookupAttachmentParams {
|
||||
channelId: ChannelID;
|
||||
attachmentId: AttachmentID;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export class AdminService {
|
||||
private readonly auditService: AdminAuditService;
|
||||
private readonly userService: AdminUserService;
|
||||
private readonly guildServiceAggregate: AdminGuildService;
|
||||
private readonly messageService: AdminMessageService;
|
||||
private readonly messageShredService: AdminMessageShredService;
|
||||
private readonly messageDeletionService: AdminMessageDeletionService;
|
||||
private readonly reportServiceAggregate: AdminReportService;
|
||||
private readonly voiceService: AdminVoiceService;
|
||||
private readonly searchService: AdminSearchService;
|
||||
private readonly codeGenerationService: AdminCodeGenerationService;
|
||||
private readonly assetPurgeService: AdminAssetPurgeService;
|
||||
|
||||
constructor(
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly guildRepository: IGuildRepository,
|
||||
private readonly channelRepository: IChannelRepository,
|
||||
private readonly adminRepository: IAdminRepository,
|
||||
private readonly inviteRepository: InviteRepository,
|
||||
private readonly inviteService: InviteService,
|
||||
private readonly pendingJoinInviteStore: PendingJoinInviteStore,
|
||||
private readonly discriminatorService: IDiscriminatorService,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
private readonly guildService: GuildService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
private readonly entityAssetService: EntityAssetService,
|
||||
private readonly assetDeletionQueue: IAssetDeletionQueue,
|
||||
private readonly emailService: IEmailService,
|
||||
private readonly mediaService: IMediaService,
|
||||
private readonly storageService: IStorageService,
|
||||
private readonly reportService: ReportService,
|
||||
private readonly workerService: IWorkerService,
|
||||
private readonly cacheService: ICacheService,
|
||||
private readonly voiceRepository: VoiceRepository,
|
||||
private readonly botMfaMirrorService: BotMfaMirrorService,
|
||||
private readonly contactChangeLogService: UserContactChangeLogService,
|
||||
private readonly bulkMessageDeletionQueue: RedisBulkMessageDeletionQueueService,
|
||||
) {
|
||||
this.auditService = new AdminAuditService(this.adminRepository, this.snowflakeService);
|
||||
this.userService = new AdminUserService({
|
||||
userRepository: this.userRepository,
|
||||
guildRepository: this.guildRepository,
|
||||
discriminatorService: this.discriminatorService,
|
||||
snowflakeService: this.snowflakeService,
|
||||
authService: this.authService,
|
||||
emailService: this.emailService,
|
||||
entityAssetService: this.entityAssetService,
|
||||
auditService: this.auditService,
|
||||
gatewayService: this.gatewayService,
|
||||
userCacheService: this.userCacheService,
|
||||
adminRepository: this.adminRepository,
|
||||
botMfaMirrorService: this.botMfaMirrorService,
|
||||
inviteService: this.inviteService,
|
||||
pendingJoinInviteStore: this.pendingJoinInviteStore,
|
||||
contactChangeLogService: this.contactChangeLogService,
|
||||
bulkMessageDeletionQueue: this.bulkMessageDeletionQueue,
|
||||
cacheService: this.cacheService,
|
||||
});
|
||||
this.guildServiceAggregate = new AdminGuildService({
|
||||
guildRepository: this.guildRepository,
|
||||
userRepository: this.userRepository,
|
||||
channelRepository: this.channelRepository,
|
||||
inviteRepository: this.inviteRepository,
|
||||
guildService: this.guildService,
|
||||
gatewayService: this.gatewayService,
|
||||
entityAssetService: this.entityAssetService,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.assetPurgeService = new AdminAssetPurgeService({
|
||||
guildRepository: this.guildRepository,
|
||||
gatewayService: this.gatewayService,
|
||||
assetDeletionQueue: this.assetDeletionQueue,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.messageService = new AdminMessageService({
|
||||
channelRepository: this.channelRepository,
|
||||
userCacheService: this.userCacheService,
|
||||
mediaService: this.mediaService,
|
||||
gatewayService: this.gatewayService,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.messageShredService = new AdminMessageShredService({
|
||||
workerService: this.workerService,
|
||||
cacheService: this.cacheService,
|
||||
snowflakeService: this.snowflakeService,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.messageDeletionService = new AdminMessageDeletionService({
|
||||
channelRepository: this.channelRepository,
|
||||
messageShredService: this.messageShredService,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.reportServiceAggregate = new AdminReportService({
|
||||
reportService: this.reportService,
|
||||
userRepository: this.userRepository,
|
||||
emailService: this.emailService,
|
||||
storageService: this.storageService,
|
||||
auditService: this.auditService,
|
||||
userCacheService: this.userCacheService,
|
||||
});
|
||||
this.voiceService = new AdminVoiceService({
|
||||
voiceRepository: this.voiceRepository,
|
||||
cacheService: this.cacheService,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.searchService = new AdminSearchService({
|
||||
guildRepository: this.guildRepository,
|
||||
userRepository: this.userRepository,
|
||||
workerService: this.workerService,
|
||||
cacheService: this.cacheService,
|
||||
snowflakeService: this.snowflakeService,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
this.codeGenerationService = new AdminCodeGenerationService(this.userRepository);
|
||||
}
|
||||
|
||||
async lookupUser(data: LookupUserRequest) {
|
||||
return this.userService.lookupUser(data);
|
||||
}
|
||||
|
||||
async updateUserFlags(args: {
|
||||
userId: UserID;
|
||||
data: {addFlags: Array<bigint>; removeFlags: Array<bigint>};
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
return this.userService.updateUserFlags(args);
|
||||
}
|
||||
|
||||
async disableMfa(data: DisableMfaRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.disableMfa(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async clearUserFields(data: ClearUserFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.clearUserFields(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserBotStatus(data: SetUserBotStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.setUserBotStatus(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserSystemStatus(data: SetUserSystemStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.setUserSystemStatus(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async verifyUserEmail(data: VerifyUserEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.verifyUserEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async sendPasswordReset(data: SendPasswordResetRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.sendPasswordReset(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeUsername(data: ChangeUsernameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.changeUsername(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeEmail(data: ChangeEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.changeEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async terminateSessions(data: TerminateSessionsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.terminateSessions(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async tempBanUser(data: TempBanUserRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.tempBanUser(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanUser(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.unbanUser(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async scheduleAccountDeletion(
|
||||
data:
|
||||
| BulkScheduleUserDeletionRequest
|
||||
| {user_id: bigint; reason_code: number; public_reason?: string | null; days_until_deletion: number},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
if ('user_ids' in data) {
|
||||
return this.userService.bulkScheduleUserDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
return this.userService.scheduleAccountDeletion(
|
||||
{
|
||||
user_id: data.user_id,
|
||||
reason_code: data.reason_code,
|
||||
public_reason: data.public_reason ?? undefined,
|
||||
days_until_deletion: data.days_until_deletion,
|
||||
},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
}
|
||||
|
||||
async cancelAccountDeletion(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.cancelAccountDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async cancelBulkMessageDeletion(
|
||||
data: CancelBulkMessageDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.userService.cancelBulkMessageDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async banIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.banIp(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.unbanIp(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async checkIpBan(data: {ip: string}) {
|
||||
return this.userService.checkIpBan(data);
|
||||
}
|
||||
|
||||
async banEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.banEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.unbanEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async checkEmailBan(data: {email: string}) {
|
||||
return this.userService.checkEmailBan(data);
|
||||
}
|
||||
|
||||
async banPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.banPhone(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.unbanPhone(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async checkPhoneBan(data: {phone: string}) {
|
||||
return this.userService.checkPhoneBan(data);
|
||||
}
|
||||
|
||||
async setUserAcls(data: SetUserAclsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.setUserAcls(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unlinkPhone(data: UnlinkPhoneRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.unlinkPhone(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeDob(data: ChangeDobRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.changeDob(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateSuspiciousActivityFlags(
|
||||
data: UpdateSuspiciousActivityFlagsRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.userService.updateSuspiciousActivityFlags(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async disableForSuspiciousActivity(
|
||||
data: DisableForSuspiciousActivityRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.userService.disableForSuspiciousActivity(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkUpdateUserFlags(data: BulkUpdateUserFlagsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.bulkUpdateUserFlags(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkScheduleUserDeletion(
|
||||
data: BulkScheduleUserDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.userService.bulkScheduleUserDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async listUserSessions(userId: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.listUserSessions(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async listUserChangeLog(data: ListUserChangeLogRequest) {
|
||||
return this.userService.listUserChangeLog(data);
|
||||
}
|
||||
|
||||
async listPendingVerifications(limit: number = 100) {
|
||||
return this.userService.listPendingVerifications(limit);
|
||||
}
|
||||
|
||||
async approveRegistration(userId: UserID, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.approveRegistration(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async rejectRegistration(userId: UserID, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.rejectRegistration(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkApproveRegistrations(userIds: Array<UserID>, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.bulkApproveRegistrations(userIds, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkRejectRegistrations(userIds: Array<UserID>, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.userService.bulkRejectRegistrations(userIds, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildFeatures(args: {
|
||||
guildId: GuildID;
|
||||
addFeatures: Array<string>;
|
||||
removeFeatures: Array<string>;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
return this.guildServiceAggregate.updateGuildFeatures(args);
|
||||
}
|
||||
|
||||
async forceAddUserToGuild({
|
||||
data,
|
||||
requestCache,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: ForceAddUserToGuildParams & {adminUserId: UserID; auditLogReason: string | null}) {
|
||||
return this.guildServiceAggregate.forceAddUserToGuild({data, requestCache, adminUserId, auditLogReason});
|
||||
}
|
||||
|
||||
async lookupGuild(data: LookupGuildRequest) {
|
||||
return this.guildServiceAggregate.lookupGuild(data);
|
||||
}
|
||||
|
||||
async listUserGuilds(data: ListUserGuildsRequest) {
|
||||
return this.guildServiceAggregate.listUserGuilds(data);
|
||||
}
|
||||
|
||||
async listGuildMembers(data: ListGuildMembersRequest) {
|
||||
return this.guildServiceAggregate.listGuildMembers(data);
|
||||
}
|
||||
|
||||
async listGuildEmojis(guildId: GuildID): Promise<ListGuildEmojisResponse> {
|
||||
return this.guildServiceAggregate.listGuildEmojis(guildId);
|
||||
}
|
||||
|
||||
async listGuildStickers(guildId: GuildID): Promise<ListGuildStickersResponse> {
|
||||
return this.guildServiceAggregate.listGuildStickers(guildId);
|
||||
}
|
||||
|
||||
async purgeGuildAssets(
|
||||
data: PurgeGuildAssetsRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<PurgeGuildAssetsResponse> {
|
||||
return this.assetPurgeService.purgeGuildAssets({
|
||||
ids: data.ids,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
});
|
||||
}
|
||||
|
||||
async clearGuildFields(data: ClearGuildFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.clearGuildFields(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildName(data: UpdateGuildNameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.updateGuildName(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildSettings(data: UpdateGuildSettingsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.updateGuildSettings(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildVanity(data: UpdateGuildVanityRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.updateGuildVanity(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async transferGuildOwnership(
|
||||
data: TransferGuildOwnershipRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.guildServiceAggregate.transferGuildOwnership(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkUpdateGuildFeatures(
|
||||
data: BulkUpdateGuildFeaturesRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.guildServiceAggregate.bulkUpdateGuildFeatures(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkAddGuildMembers(data: BulkAddGuildMembersRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.bulkAddGuildMembers(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async reloadGuild(guildId: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.reloadGuild(guildId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async shutdownGuild(guildId: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.shutdownGuild(guildId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteGuild(guildId: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.guildServiceAggregate.deleteGuild(guildId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async getGuildMemoryStats(limit: number) {
|
||||
return this.guildServiceAggregate.getGuildMemoryStats(limit);
|
||||
}
|
||||
|
||||
async reloadAllGuilds(guildIds: Array<GuildID>) {
|
||||
return this.guildServiceAggregate.reloadAllGuilds(guildIds);
|
||||
}
|
||||
|
||||
async getNodeStats() {
|
||||
return this.guildServiceAggregate.getNodeStats();
|
||||
}
|
||||
|
||||
async lookupAttachment(params: LookupAttachmentParams) {
|
||||
return this.messageService.lookupAttachment(params);
|
||||
}
|
||||
|
||||
async lookupMessage(data: LookupMessageRequest) {
|
||||
return this.messageService.lookupMessage(data);
|
||||
}
|
||||
|
||||
async lookupMessageByAttachment(data: LookupMessageByAttachmentRequest) {
|
||||
return this.messageService.lookupMessageByAttachment(data);
|
||||
}
|
||||
|
||||
async deleteMessage(data: DeleteMessageRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.messageService.deleteMessage(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteAllUserMessages(data: DeleteAllUserMessagesRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.messageDeletionService.deleteAllUserMessages(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async queueMessageShred(data: MessageShredRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.messageShredService.queueMessageShred(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async getMessageShredStatus(jobId: string) {
|
||||
return this.messageShredService.getMessageShredStatus(jobId);
|
||||
}
|
||||
|
||||
async listAuditLogs(data: ListAuditLogsRequest) {
|
||||
return this.auditService.listAuditLogs(data);
|
||||
}
|
||||
|
||||
async searchAuditLogs(data: {
|
||||
query?: string;
|
||||
adminUserId?: bigint;
|
||||
targetType?: string;
|
||||
targetId?: bigint;
|
||||
action?: string;
|
||||
sortBy?: 'createdAt' | 'relevance';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
return this.auditService.searchAuditLogs(data);
|
||||
}
|
||||
|
||||
async listReports(status: number, limit?: number, offset?: number) {
|
||||
return this.reportServiceAggregate.listReports(status, limit, offset);
|
||||
}
|
||||
|
||||
async getReport(reportId: ReportID) {
|
||||
return this.reportServiceAggregate.getReport(reportId);
|
||||
}
|
||||
|
||||
async resolveReport(
|
||||
reportId: ReportID,
|
||||
adminUserId: UserID,
|
||||
publicComment: string | null,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.reportServiceAggregate.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
|
||||
}
|
||||
|
||||
async searchReports(data: SearchReportsRequest) {
|
||||
return this.reportServiceAggregate.searchReports(data);
|
||||
}
|
||||
|
||||
async searchGuilds(data: {query?: string; limit: number; offset: number}) {
|
||||
return this.searchService.searchGuilds(data);
|
||||
}
|
||||
|
||||
async searchUsers(data: {query?: string; limit: number; offset: number}) {
|
||||
return this.searchService.searchUsers(data);
|
||||
}
|
||||
|
||||
async refreshSearchIndex(
|
||||
data: {
|
||||
index_type: 'guilds' | 'users' | 'reports' | 'audit_logs' | 'channel_messages' | 'favorite_memes';
|
||||
guild_id?: bigint;
|
||||
user_id?: bigint;
|
||||
},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.searchService.refreshSearchIndex(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async getIndexRefreshStatus(jobId: string) {
|
||||
return this.searchService.getIndexRefreshStatus(jobId);
|
||||
}
|
||||
|
||||
async listVoiceRegions(data: ListVoiceRegionsRequest, _adminUserId: UserID, _auditLogReason: string | null) {
|
||||
return this.voiceService.listVoiceRegions(data);
|
||||
}
|
||||
|
||||
async getVoiceRegion(data: GetVoiceRegionRequest, _adminUserId: UserID, _auditLogReason: string | null) {
|
||||
return this.voiceService.getVoiceRegion(data);
|
||||
}
|
||||
|
||||
async createVoiceRegion(data: CreateVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.voiceService.createVoiceRegion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateVoiceRegion(data: UpdateVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.voiceService.updateVoiceRegion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteVoiceRegion(data: DeleteVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.voiceService.deleteVoiceRegion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async listVoiceServers(data: ListVoiceServersRequest, _adminUserId: UserID, _auditLogReason: string | null) {
|
||||
return this.voiceService.listVoiceServers(data);
|
||||
}
|
||||
|
||||
async getVoiceServer(data: GetVoiceServerRequest, _adminUserId: UserID, _auditLogReason: string | null) {
|
||||
return this.voiceService.getVoiceServer(data);
|
||||
}
|
||||
|
||||
async createVoiceServer(data: CreateVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.voiceService.createVoiceServer(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateVoiceServer(data: UpdateVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.voiceService.updateVoiceServer(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteVoiceServer(data: DeleteVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.voiceService.deleteVoiceServer(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async generateBetaCodes(count: number) {
|
||||
return this.codeGenerationService.generateBetaCodes(count);
|
||||
}
|
||||
|
||||
async generateGiftCodes(count: number, durationMonths: number) {
|
||||
return this.codeGenerationService.generateGiftCodes(count, durationMonths);
|
||||
}
|
||||
}
|
||||
61
fluxer_api/src/admin/IAdminRepository.ts
Normal file
61
fluxer_api/src/admin/IAdminRepository.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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 {UserID} from '~/BrandedTypes';
|
||||
import type {AdminAuditLogRow} from '~/database/types/AdminArchiveTypes';
|
||||
|
||||
export type {AdminAuditLogRow};
|
||||
|
||||
export interface AdminAuditLog {
|
||||
logId: bigint;
|
||||
adminUserId: UserID;
|
||||
targetType: string;
|
||||
targetId: bigint;
|
||||
action: string;
|
||||
auditLogReason: string | null;
|
||||
metadata: Map<string, string>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export abstract class IAdminRepository {
|
||||
abstract createAuditLog(log: AdminAuditLogRow): Promise<AdminAuditLog>;
|
||||
abstract getAuditLog(logId: bigint): Promise<AdminAuditLog | null>;
|
||||
abstract listAuditLogsByIds(logIds: Array<bigint>): Promise<Array<AdminAuditLog>>;
|
||||
abstract listAllAuditLogsPaginated(limit: number, lastLogId?: bigint): Promise<Array<AdminAuditLog>>;
|
||||
|
||||
abstract isIpBanned(ip: string): Promise<boolean>;
|
||||
abstract banIp(ip: string): Promise<void>;
|
||||
abstract unbanIp(ip: string): Promise<void>;
|
||||
abstract listBannedIps(limit?: number): Promise<Array<string>>;
|
||||
|
||||
abstract isEmailBanned(email: string): Promise<boolean>;
|
||||
abstract banEmail(email: string): Promise<void>;
|
||||
abstract unbanEmail(email: string): Promise<void>;
|
||||
|
||||
abstract isPhoneBanned(phone: string): Promise<boolean>;
|
||||
abstract banPhone(phone: string): Promise<void>;
|
||||
abstract unbanPhone(phone: string): Promise<void>;
|
||||
|
||||
abstract loadAllBannedIps(): Promise<Set<string>>;
|
||||
|
||||
abstract listPendingVerifications(
|
||||
limit?: number,
|
||||
): Promise<Array<{userId: UserID; createdAt: Date; metadata: Map<string, string>}>>;
|
||||
abstract removePendingVerification(userId: UserID): Promise<void>;
|
||||
}
|
||||
155
fluxer_api/src/admin/controllers/ArchiveAdminController.ts
Normal file
155
fluxer_api/src/admin/controllers/ArchiveAdminController.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HonoApp} from '~/App';
|
||||
import {createGuildID, createUserID} from '~/BrandedTypes';
|
||||
import {AdminACLs} from '~/Constants';
|
||||
import {MissingACLError} from '~/Errors';
|
||||
import {requireAdminACL, requireAnyAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Validator} from '~/Validator';
|
||||
import type {ListArchivesRequest} from '../models';
|
||||
import {
|
||||
ListArchivesRequest as ListArchivesSchema,
|
||||
TriggerGuildArchiveRequest,
|
||||
TriggerUserArchiveRequest,
|
||||
} from '../models';
|
||||
|
||||
const canViewArchive = (adminAcls: Set<string>, subjectType: 'user' | 'guild'): boolean => {
|
||||
if (adminAcls.has(AdminACLs.WILDCARD) || adminAcls.has(AdminACLs.ARCHIVE_VIEW_ALL)) return true;
|
||||
if (subjectType === 'user') return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_USER);
|
||||
return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_GUILD);
|
||||
};
|
||||
|
||||
export const ArchiveAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/archives/user',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_USER),
|
||||
Validator('json', TriggerUserArchiveRequest),
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const body = ctx.req.valid('json');
|
||||
const result = await adminArchiveService.triggerUserArchive(createUserID(BigInt(body.user_id)), adminUserId);
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/archives/guild',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_GUILD),
|
||||
Validator('json', TriggerGuildArchiveRequest),
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const body = ctx.req.valid('json');
|
||||
const result = await adminArchiveService.triggerGuildArchive(createGuildID(BigInt(body.guild_id)), adminUserId);
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/archives/list',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
|
||||
Validator('json', ListArchivesSchema),
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminAcls = ctx.get('adminUserAcls');
|
||||
const body = ctx.req.valid('json') as ListArchivesRequest;
|
||||
|
||||
if (
|
||||
body.subject_type === 'all' &&
|
||||
!adminAcls.has(AdminACLs.ARCHIVE_VIEW_ALL) &&
|
||||
!adminAcls.has(AdminACLs.WILDCARD)
|
||||
) {
|
||||
throw new MissingACLError(AdminACLs.ARCHIVE_VIEW_ALL);
|
||||
}
|
||||
|
||||
if (
|
||||
body.subject_type !== 'all' &&
|
||||
!canViewArchive(adminAcls, body.subject_type) &&
|
||||
!adminAcls.has(AdminACLs.WILDCARD)
|
||||
) {
|
||||
throw new MissingACLError(
|
||||
body.subject_type === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await adminArchiveService.listArchives({
|
||||
subjectType: body.subject_type as 'user' | 'guild' | 'all',
|
||||
subjectId: body.subject_id ? BigInt(body.subject_id) : undefined,
|
||||
requestedBy: body.requested_by ? BigInt(body.requested_by) : undefined,
|
||||
limit: body.limit,
|
||||
includeExpired: body.include_expired,
|
||||
});
|
||||
|
||||
return ctx.json({archives: result}, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/archives/:subjectType/:subjectId/:archiveId',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminAcls = ctx.get('adminUserAcls');
|
||||
const subjectType = ctx.req.param('subjectType') as 'user' | 'guild';
|
||||
|
||||
if (!canViewArchive(adminAcls, subjectType) && !adminAcls.has(AdminACLs.WILDCARD)) {
|
||||
throw new MissingACLError(
|
||||
subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
|
||||
);
|
||||
}
|
||||
|
||||
const subjectId = BigInt(ctx.req.param('subjectId'));
|
||||
const archiveId = BigInt(ctx.req.param('archiveId'));
|
||||
|
||||
const archive = await adminArchiveService.getArchive(subjectType, subjectId, archiveId);
|
||||
return ctx.json({archive}, 200);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/archives/:subjectType/:subjectId/:archiveId/download',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminAcls = ctx.get('adminUserAcls');
|
||||
const subjectType = ctx.req.param('subjectType') as 'user' | 'guild';
|
||||
|
||||
if (!canViewArchive(adminAcls, subjectType) && !adminAcls.has(AdminACLs.WILDCARD)) {
|
||||
throw new MissingACLError(
|
||||
subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
|
||||
);
|
||||
}
|
||||
|
||||
const subjectId = BigInt(ctx.req.param('subjectId'));
|
||||
const archiveId = BigInt(ctx.req.param('archiveId'));
|
||||
|
||||
const result = await adminArchiveService.getDownloadUrl(subjectType, subjectId, archiveId);
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
};
|
||||
41
fluxer_api/src/admin/controllers/AssetAdminController.ts
Normal file
41
fluxer_api/src/admin/controllers/AssetAdminController.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 {AdminACLs} from '~/Constants';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {AdminRateLimitConfigs} from '~/rate_limit_configs/AdminRateLimitConfig';
|
||||
import {Validator} from '~/Validator';
|
||||
import {PurgeGuildAssetsRequest} from '../AdminModel';
|
||||
|
||||
export const AssetAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/assets/purge',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.ASSET_PURGE),
|
||||
Validator('json', PurgeGuildAssetsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.purgeGuildAssets(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
};
|
||||
50
fluxer_api/src/admin/controllers/AuditLogAdminController.ts
Normal file
50
fluxer_api/src/admin/controllers/AuditLogAdminController.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 {AdminACLs} from '~/Constants';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Validator} from '~/Validator';
|
||||
import {ListAuditLogsRequest, SearchAuditLogsRequest} from '../AdminModel';
|
||||
|
||||
export const AuditLogAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/audit-logs',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_AUDIT_LOG),
|
||||
requireAdminACL(AdminACLs.AUDIT_LOG_VIEW),
|
||||
Validator('json', ListAuditLogsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.listAuditLogs(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/audit-logs/search',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_AUDIT_LOG),
|
||||
requireAdminACL(AdminACLs.AUDIT_LOG_VIEW),
|
||||
Validator('json', SearchAuditLogsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.searchAuditLogs(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
};
|
||||
139
fluxer_api/src/admin/controllers/BanAdminController.ts
Normal file
139
fluxer_api/src/admin/controllers/BanAdminController.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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 {AdminACLs} from '~/Constants';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Validator} from '~/Validator';
|
||||
import {BanEmailRequest, BanIpRequest, BanPhoneRequest} from '../AdminModel';
|
||||
|
||||
export const BanAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/bans/ip/add',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_IP_ADD),
|
||||
Validator('json', BanIpRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.banIp(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/ip/remove',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_IP_REMOVE),
|
||||
Validator('json', BanIpRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.unbanIp(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/ip/check',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_IP_CHECK),
|
||||
Validator('json', BanIpRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.checkIpBan(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/email/add',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_EMAIL_ADD),
|
||||
Validator('json', BanEmailRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.banEmail(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/email/remove',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_EMAIL_REMOVE),
|
||||
Validator('json', BanEmailRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.unbanEmail(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/email/check',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_EMAIL_CHECK),
|
||||
Validator('json', BanEmailRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.checkEmailBan(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/phone/add',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_PHONE_ADD),
|
||||
Validator('json', BanPhoneRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.banPhone(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/phone/remove',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_PHONE_REMOVE),
|
||||
Validator('json', BanPhoneRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.unbanPhone(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bans/phone/check',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BAN_OPERATION),
|
||||
requireAdminACL(AdminACLs.BAN_PHONE_CHECK),
|
||||
Validator('json', BanPhoneRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.checkPhoneBan(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
};
|
||||
85
fluxer_api/src/admin/controllers/BulkAdminController.ts
Normal file
85
fluxer_api/src/admin/controllers/BulkAdminController.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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 {AdminACLs} from '~/Constants';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Validator} from '~/Validator';
|
||||
import {
|
||||
BulkAddGuildMembersRequest,
|
||||
BulkScheduleUserDeletionRequest,
|
||||
BulkUpdateGuildFeaturesRequest,
|
||||
BulkUpdateUserFlagsRequest,
|
||||
} from '../AdminModel';
|
||||
|
||||
export const BulkAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/bulk/update-user-flags',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_UPDATE_USER_FLAGS),
|
||||
Validator('json', BulkUpdateUserFlagsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.bulkUpdateUserFlags(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bulk/update-guild-features',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_UPDATE_GUILD_FEATURES),
|
||||
Validator('json', BulkUpdateGuildFeaturesRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.bulkUpdateGuildFeatures(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bulk/add-guild-members',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_ADD_GUILD_MEMBERS),
|
||||
Validator('json', BulkAddGuildMembersRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.bulkAddGuildMembers(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/bulk/schedule-user-deletion',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_DELETE_USERS),
|
||||
Validator('json', BulkScheduleUserDeletionRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.bulkScheduleUserDeletion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
};
|
||||
66
fluxer_api/src/admin/controllers/CodesAdminController.ts
Normal file
66
fluxer_api/src/admin/controllers/CodesAdminController.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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 {Config} from '~/Config';
|
||||
import {AdminACLs} from '~/Constants';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {ProductType} from '~/stripe/ProductRegistry';
|
||||
import {Validator} from '~/Validator';
|
||||
import {GenerateBetaCodesRequest, GenerateGiftCodesRequest, type GiftProductType} from '../models/CodeRequestTypes';
|
||||
|
||||
const trimTrailingSlash = (value: string): string => (value.endsWith('/') ? value.slice(0, -1) : value);
|
||||
|
||||
const giftDurations: Record<GiftProductType, number> = {
|
||||
[ProductType.GIFT_1_MONTH]: 1,
|
||||
[ProductType.GIFT_1_YEAR]: 12,
|
||||
[ProductType.GIFT_VISIONARY]: 0,
|
||||
};
|
||||
|
||||
export const CodesAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/codes/beta',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_CODE_GENERATION),
|
||||
requireAdminACL(AdminACLs.BETA_CODES_GENERATE),
|
||||
Validator('json', GenerateBetaCodesRequest),
|
||||
async (ctx) => {
|
||||
const {count} = ctx.req.valid('json');
|
||||
const codes = await ctx.get('adminService').generateBetaCodes(count);
|
||||
return ctx.json({codes});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/codes/gift',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_CODE_GENERATION),
|
||||
requireAdminACL(AdminACLs.GIFT_CODES_GENERATE),
|
||||
Validator('json', GenerateGiftCodesRequest),
|
||||
async (ctx) => {
|
||||
const {count, product_type} = ctx.req.valid('json');
|
||||
const durationMonths = giftDurations[product_type];
|
||||
const codes = await ctx.get('adminService').generateGiftCodes(count, durationMonths);
|
||||
const baseUrl = trimTrailingSlash(Config.endpoints.gift);
|
||||
return ctx.json({
|
||||
codes: codes.map((code) => `${baseUrl}/${code}`),
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
111
fluxer_api/src/admin/controllers/FeatureFlagAdminController.ts
Normal file
111
fluxer_api/src/admin/controllers/FeatureFlagAdminController.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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 {AdminACLs} from '~/Constants';
|
||||
import {ALL_FEATURE_FLAGS, FeatureFlags} from '~/constants/FeatureFlags';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
export const FeatureFlagAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/feature-flags/get',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.FEATURE_FLAG_VIEW),
|
||||
async (ctx) => {
|
||||
const featureFlagService = ctx.get('featureFlagService');
|
||||
const config = featureFlagService.getConfigForSession();
|
||||
|
||||
return ctx.json({
|
||||
feature_flags: config,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/feature-flags/update',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.FEATURE_FLAG_MANAGE),
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
flag: z.enum([FeatureFlags.MESSAGE_SCHEDULING, FeatureFlags.EXPRESSION_PACKS]),
|
||||
guild_ids: z.string(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const {flag, guild_ids} = ctx.req.valid('json');
|
||||
const featureFlagService = ctx.get('featureFlagService');
|
||||
|
||||
const guildIdArray = guild_ids
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id.length > 0);
|
||||
const guildIdSet = new Set(guildIdArray);
|
||||
|
||||
await featureFlagService.setFeatureGuildIds(flag, guildIdSet);
|
||||
|
||||
const updatedConfig = featureFlagService.getConfigForSession();
|
||||
|
||||
return ctx.json({
|
||||
feature_flags: updatedConfig,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/feature-flags/list',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.FEATURE_FLAG_VIEW),
|
||||
async (ctx) => {
|
||||
return ctx.json({
|
||||
flags: ALL_FEATURE_FLAGS.map((flag) => ({
|
||||
key: flag,
|
||||
label: getFeatureFlagLabel(flag),
|
||||
description: getFeatureFlagDescription(flag),
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
function getFeatureFlagLabel(flag: string): string {
|
||||
switch (flag) {
|
||||
case FeatureFlags.MESSAGE_SCHEDULING:
|
||||
return 'Message Scheduling';
|
||||
case FeatureFlags.EXPRESSION_PACKS:
|
||||
return 'Expression Packs';
|
||||
default:
|
||||
return flag;
|
||||
}
|
||||
}
|
||||
|
||||
function getFeatureFlagDescription(flag: string): string {
|
||||
switch (flag) {
|
||||
case FeatureFlags.MESSAGE_SCHEDULING:
|
||||
return 'Allows users to schedule messages to be sent later';
|
||||
case FeatureFlags.EXPRESSION_PACKS:
|
||||
return 'Allows users to create and share emoji/sticker packs';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
65
fluxer_api/src/admin/controllers/GatewayAdminController.ts
Normal file
65
fluxer_api/src/admin/controllers/GatewayAdminController.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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 {AdminACLs} from '~/Constants';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Int64Type, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
import {GetProcessMemoryStatsRequest} from '../AdminModel';
|
||||
|
||||
export const GatewayAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/gateway/memory-stats',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS),
|
||||
Validator('json', GetProcessMemoryStatsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.getGuildMemoryStats(body.limit));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/gateway/reload-all',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GATEWAY_RELOAD),
|
||||
requireAdminACL(AdminACLs.GATEWAY_RELOAD_ALL),
|
||||
Validator('json', z.object({guild_ids: z.array(Int64Type)})),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
const guildIds = body.guild_ids.map((id) => createGuildID(id));
|
||||
return ctx.json(await adminService.reloadAllGuilds(guildIds));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/gateway/stats',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.getNodeStats());
|
||||
},
|
||||
);
|
||||
};
|
||||
242
fluxer_api/src/admin/controllers/GuildAdminController.ts
Normal file
242
fluxer_api/src/admin/controllers/GuildAdminController.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* 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 z from 'zod';
|
||||
import type {HonoApp} from '~/App';
|
||||
import {createGuildID} from '~/BrandedTypes';
|
||||
import {AdminACLs} from '~/Constants';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {AdminRateLimitConfigs} from '~/rate_limit_configs/AdminRateLimitConfig';
|
||||
import {Int64Type} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
import {
|
||||
ClearGuildFieldsRequest,
|
||||
DeleteGuildRequest,
|
||||
ForceAddUserToGuildRequest,
|
||||
ListGuildMembersRequest,
|
||||
LookupGuildRequest,
|
||||
ReloadGuildRequest,
|
||||
ShutdownGuildRequest,
|
||||
TransferGuildOwnershipRequest,
|
||||
UpdateGuildFeaturesRequest,
|
||||
UpdateGuildNameRequest,
|
||||
UpdateGuildSettingsRequest,
|
||||
UpdateGuildVanityRequest,
|
||||
} from '../AdminModel';
|
||||
|
||||
export const GuildAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/guilds/lookup',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('json', LookupGuildRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.lookupGuild(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/list-members',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LIST_MEMBERS),
|
||||
Validator('json', ListGuildMembersRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.listGuildMembers(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/guilds/:guild_id/emojis',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.ASSET_PURGE),
|
||||
Validator('param', z.object({guild_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
return ctx.json(await adminService.listGuildEmojis(guildId));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/guilds/:guild_id/stickers',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.ASSET_PURGE),
|
||||
Validator('param', z.object({guild_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||
return ctx.json(await adminService.listGuildStickers(guildId));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/clear-fields',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_SETTINGS),
|
||||
Validator('json', ClearGuildFieldsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.clearGuildFields(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/update-features',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_FEATURES),
|
||||
Validator('json', UpdateGuildFeaturesRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
const guildId = createGuildID(body.guild_id);
|
||||
return ctx.json(
|
||||
await adminService.updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures: body.add_features,
|
||||
removeFeatures: body.remove_features,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/update-name',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_NAME),
|
||||
Validator('json', UpdateGuildNameRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.updateGuildName(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/update-settings',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_SETTINGS),
|
||||
Validator('json', UpdateGuildSettingsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.updateGuildSettings(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/transfer-ownership',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_TRANSFER_OWNERSHIP),
|
||||
Validator('json', TransferGuildOwnershipRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.transferGuildOwnership(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/update-vanity',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_VANITY),
|
||||
Validator('json', UpdateGuildVanityRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.updateGuildVanity(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/force-add-user',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_FORCE_ADD_MEMBER),
|
||||
Validator('json', ForceAddUserToGuildRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(
|
||||
await adminService.forceAddUserToGuild({
|
||||
data: ctx.req.valid('json'),
|
||||
requestCache,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/reload',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_RELOAD),
|
||||
Validator('json', ReloadGuildRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.reloadGuild(body.guild_id, adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/shutdown',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_SHUTDOWN),
|
||||
Validator('json', ShutdownGuildRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.shutdownGuild(body.guild_id, adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/guilds/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_DELETE),
|
||||
Validator('json', DeleteGuildRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.deleteGuild(body.guild_id, adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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 {AdminACLs} from '~/Constants';
|
||||
import {InstanceConfigRepository} from '~/instance/InstanceConfigRepository';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
const instanceConfigRepository = new InstanceConfigRepository();
|
||||
|
||||
export const InstanceConfigAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/instance-config/get',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.INSTANCE_CONFIG_VIEW),
|
||||
async (ctx) => {
|
||||
const config = await instanceConfigRepository.getInstanceConfig();
|
||||
const isActiveNow = instanceConfigRepository.isManualReviewActiveNow(config);
|
||||
|
||||
return ctx.json({
|
||||
manual_review_enabled: config.manualReviewEnabled,
|
||||
manual_review_schedule_enabled: config.manualReviewScheduleEnabled,
|
||||
manual_review_schedule_start_hour_utc: config.manualReviewScheduleStartHourUtc,
|
||||
manual_review_schedule_end_hour_utc: config.manualReviewScheduleEndHourUtc,
|
||||
manual_review_active_now: isActiveNow,
|
||||
registration_alerts_webhook_url: config.registrationAlertsWebhookUrl,
|
||||
system_alerts_webhook_url: config.systemAlertsWebhookUrl,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/instance-config/update',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
manual_review_enabled: z.boolean().optional(),
|
||||
manual_review_schedule_enabled: z.boolean().optional(),
|
||||
manual_review_schedule_start_hour_utc: z.number().min(0).max(23).optional(),
|
||||
manual_review_schedule_end_hour_utc: z.number().min(0).max(23).optional(),
|
||||
registration_alerts_webhook_url: z.string().url().nullable().optional(),
|
||||
system_alerts_webhook_url: z.string().url().nullable().optional(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
|
||||
if (data.manual_review_enabled !== undefined) {
|
||||
await instanceConfigRepository.setManualReviewEnabled(data.manual_review_enabled);
|
||||
}
|
||||
|
||||
if (
|
||||
data.manual_review_schedule_enabled !== undefined ||
|
||||
data.manual_review_schedule_start_hour_utc !== undefined ||
|
||||
data.manual_review_schedule_end_hour_utc !== undefined
|
||||
) {
|
||||
const currentConfig = await instanceConfigRepository.getInstanceConfig();
|
||||
|
||||
const scheduleEnabled = data.manual_review_schedule_enabled ?? currentConfig.manualReviewScheduleEnabled;
|
||||
const startHour = data.manual_review_schedule_start_hour_utc ?? currentConfig.manualReviewScheduleStartHourUtc;
|
||||
const endHour = data.manual_review_schedule_end_hour_utc ?? currentConfig.manualReviewScheduleEndHourUtc;
|
||||
|
||||
await instanceConfigRepository.setManualReviewSchedule(scheduleEnabled, startHour, endHour);
|
||||
}
|
||||
|
||||
if (data.registration_alerts_webhook_url !== undefined) {
|
||||
await instanceConfigRepository.setRegistrationAlertsWebhookUrl(data.registration_alerts_webhook_url);
|
||||
}
|
||||
|
||||
if (data.system_alerts_webhook_url !== undefined) {
|
||||
await instanceConfigRepository.setSystemAlertsWebhookUrl(data.system_alerts_webhook_url);
|
||||
}
|
||||
|
||||
const updatedConfig = await instanceConfigRepository.getInstanceConfig();
|
||||
const isActiveNow = instanceConfigRepository.isManualReviewActiveNow(updatedConfig);
|
||||
|
||||
return ctx.json({
|
||||
manual_review_enabled: updatedConfig.manualReviewEnabled,
|
||||
manual_review_schedule_enabled: updatedConfig.manualReviewScheduleEnabled,
|
||||
manual_review_schedule_start_hour_utc: updatedConfig.manualReviewScheduleStartHourUtc,
|
||||
manual_review_schedule_end_hour_utc: updatedConfig.manualReviewScheduleEndHourUtc,
|
||||
manual_review_active_now: isActiveNow,
|
||||
registration_alerts_webhook_url: updatedConfig.registrationAlertsWebhookUrl,
|
||||
system_alerts_webhook_url: updatedConfig.systemAlertsWebhookUrl,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
108
fluxer_api/src/admin/controllers/MessageAdminController.ts
Normal file
108
fluxer_api/src/admin/controllers/MessageAdminController.ts
Normal 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 {AdminACLs} from '~/Constants';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Validator} from '~/Validator';
|
||||
import {
|
||||
DeleteAllUserMessagesRequest,
|
||||
DeleteMessageRequest,
|
||||
LookupMessageByAttachmentRequest,
|
||||
LookupMessageRequest,
|
||||
MessageShredRequest,
|
||||
MessageShredStatusRequest,
|
||||
} from '../AdminModel';
|
||||
|
||||
export const MessageAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/messages/lookup',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_LOOKUP),
|
||||
Validator('json', LookupMessageRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.lookupMessage(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/messages/lookup-by-attachment',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_LOOKUP),
|
||||
Validator('json', LookupMessageByAttachmentRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.lookupMessageByAttachment(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/messages/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_DELETE),
|
||||
Validator('json', DeleteMessageRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.deleteMessage(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/messages/shred',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_SHRED),
|
||||
Validator('json', MessageShredRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.queueMessageShred(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/messages/delete-all',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_DELETE_ALL),
|
||||
Validator('json', DeleteAllUserMessagesRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.deleteAllUserMessages(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/messages/shred-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_SHRED),
|
||||
Validator('json', MessageShredStatusRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.getMessageShredStatus(body.job_id));
|
||||
},
|
||||
);
|
||||
};
|
||||
101
fluxer_api/src/admin/controllers/ReportAdminController.ts
Normal file
101
fluxer_api/src/admin/controllers/ReportAdminController.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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 {createReportID} from '~/BrandedTypes';
|
||||
import {AdminACLs} from '~/Constants';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {createStringType, Int64Type, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
import {SearchReportsRequest} from '../AdminModel';
|
||||
|
||||
export const ReportAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/reports/list',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.REPORT_VIEW),
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
status: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {status, limit, offset} = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.listReports(status ?? 0, limit, offset));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/admin/reports/:report_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.REPORT_VIEW),
|
||||
Validator('param', z.object({report_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const report = await adminService.getReport(createReportID(report_id));
|
||||
return ctx.json(report);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/reports/resolve',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.REPORT_RESOLVE),
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
report_id: Int64Type,
|
||||
public_comment: createStringType(0, 512).optional(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {report_id, public_comment} = ctx.req.valid('json');
|
||||
return ctx.json(
|
||||
await adminService.resolveReport(
|
||||
createReportID(report_id),
|
||||
adminUserId,
|
||||
public_comment || null,
|
||||
auditLogReason,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/reports/search',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.REPORT_VIEW),
|
||||
Validator('json', SearchReportsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.searchReports(body));
|
||||
},
|
||||
);
|
||||
};
|
||||
83
fluxer_api/src/admin/controllers/SearchAdminController.ts
Normal file
83
fluxer_api/src/admin/controllers/SearchAdminController.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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 {AdminACLs} from '~/Constants';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Validator} from '~/Validator';
|
||||
import {
|
||||
GetIndexRefreshStatusRequest,
|
||||
RefreshSearchIndexRequest,
|
||||
SearchGuildsRequest,
|
||||
SearchUsersRequest,
|
||||
} from '../AdminModel';
|
||||
|
||||
export const SearchAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/guilds/search',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('json', SearchGuildsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.searchGuilds(body));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/search',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.USER_LOOKUP),
|
||||
Validator('json', SearchUsersRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.searchUsers(body));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/search/refresh-index',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('json', RefreshSearchIndexRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.refreshSearchIndex(body, adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/search/refresh-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('json', GetIndexRefreshStatusRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.getIndexRefreshStatus(body.job_id));
|
||||
},
|
||||
);
|
||||
};
|
||||
374
fluxer_api/src/admin/controllers/UserAdminController.ts
Normal file
374
fluxer_api/src/admin/controllers/UserAdminController.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
/*
|
||||
* 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 {createUserID} from '~/BrandedTypes';
|
||||
import {AdminACLs} from '~/Constants';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Validator} from '~/Validator';
|
||||
import {
|
||||
CancelBulkMessageDeletionRequest,
|
||||
ChangeDobRequest,
|
||||
ChangeEmailRequest,
|
||||
ChangeUsernameRequest,
|
||||
ClearUserFieldsRequest,
|
||||
DisableForSuspiciousActivityRequest,
|
||||
DisableMfaRequest,
|
||||
ListUserChangeLogRequest,
|
||||
ListUserGuildsRequest,
|
||||
ListUserSessionsRequest,
|
||||
LookupUserRequest,
|
||||
ScheduleAccountDeletionRequest,
|
||||
SendPasswordResetRequest,
|
||||
SetUserAclsRequest,
|
||||
SetUserBotStatusRequest,
|
||||
SetUserSystemStatusRequest,
|
||||
TempBanUserRequest,
|
||||
TerminateSessionsRequest,
|
||||
UnlinkPhoneRequest,
|
||||
UpdateSuspiciousActivityFlagsRequest,
|
||||
UpdateUserFlagsRequest,
|
||||
VerifyUserEmailRequest,
|
||||
} from '../AdminModel';
|
||||
|
||||
export const UserAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/users/lookup',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.USER_LOOKUP),
|
||||
Validator('json', LookupUserRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.lookupUser(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/list-guilds',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.USER_LIST_GUILDS),
|
||||
Validator('json', ListUserGuildsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.listUserGuilds(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/disable-mfa',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_MFA),
|
||||
Validator('json', DisableMfaRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
await adminService.disableMfa(ctx.req.valid('json'), adminUserId, auditLogReason);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/clear-fields',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_PROFILE),
|
||||
Validator('json', ClearUserFieldsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.clearUserFields(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/set-bot-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
|
||||
Validator('json', SetUserBotStatusRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.setUserBotStatus(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/set-system-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
|
||||
Validator('json', SetUserSystemStatusRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.setUserSystemStatus(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/verify-email',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
|
||||
Validator('json', VerifyUserEmailRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.verifyUserEmail(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/send-password-reset',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
|
||||
Validator('json', SendPasswordResetRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
await adminService.sendPasswordReset(ctx.req.valid('json'), adminUserId, auditLogReason);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/change-username',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_USERNAME),
|
||||
Validator('json', ChangeUsernameRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.changeUsername(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/change-email',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_EMAIL),
|
||||
Validator('json', ChangeEmailRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.changeEmail(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/terminate-sessions',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
|
||||
Validator('json', TerminateSessionsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.terminateSessions(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/temp-ban',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_TEMP_BAN),
|
||||
Validator('json', TempBanUserRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.tempBanUser(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/unban',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_TEMP_BAN),
|
||||
Validator('json', DisableMfaRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.unbanUser(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/schedule-deletion',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_DELETE),
|
||||
Validator('json', ScheduleAccountDeletionRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.scheduleAccountDeletion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/cancel-deletion',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_DELETE),
|
||||
Validator('json', DisableMfaRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.cancelAccountDeletion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/cancel-bulk-message-deletion',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_CANCEL_BULK_MESSAGE_DELETION),
|
||||
Validator('json', CancelBulkMessageDeletionRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.cancelBulkMessageDeletion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/set-acls',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.ACL_SET_USER),
|
||||
Validator('json', SetUserAclsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.setUserAcls(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/update-flags',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
|
||||
Validator('json', UpdateUserFlagsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
const userId = createUserID(body.user_id);
|
||||
return ctx.json(
|
||||
await adminService.updateUserFlags({
|
||||
userId,
|
||||
data: {addFlags: body.add_flags, removeFlags: body.remove_flags},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/unlink-phone',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_PHONE),
|
||||
Validator('json', UnlinkPhoneRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.unlinkPhone(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/change-dob',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_DOB),
|
||||
Validator('json', ChangeDobRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.changeDob(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/update-suspicious-activity-flags',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_SUSPICIOUS_ACTIVITY),
|
||||
Validator('json', UpdateSuspiciousActivityFlagsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(
|
||||
await adminService.updateSuspiciousActivityFlags(ctx.req.valid('json'), adminUserId, auditLogReason),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/disable-suspicious',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_DISABLE_SUSPICIOUS),
|
||||
Validator('json', DisableForSuspiciousActivityRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(
|
||||
await adminService.disableForSuspiciousActivity(ctx.req.valid('json'), adminUserId, auditLogReason),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/list-sessions',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_LIST_SESSIONS),
|
||||
Validator('json', ListUserSessionsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.listUserSessions(body.user_id, adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/users/change-log',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.USER_LOOKUP),
|
||||
Validator('json', ListUserChangeLogRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.listUserChangeLog(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 {createUserID} from '~/BrandedTypes';
|
||||
import {AdminACLs} from '~/Constants';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Int64Type, z} from '~/Schema';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
export const VerificationAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/pending-verifications/list',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.USER_LOOKUP),
|
||||
Validator('json', z.object({limit: z.number().default(100)})),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {limit} = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.listPendingVerifications(limit));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/pending-verifications/approve',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
|
||||
Validator('json', z.object({user_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {user_id} = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.approveRegistration(createUserID(user_id), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/pending-verifications/reject',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
|
||||
Validator('json', z.object({user_id: Int64Type})),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {user_id} = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.rejectRegistration(createUserID(user_id), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/pending-verifications/bulk-approve',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
|
||||
Validator('json', z.object({user_ids: z.array(Int64Type).min(1)})),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {user_ids} = ctx.req.valid('json');
|
||||
const parsedUserIds = user_ids.map(createUserID);
|
||||
return ctx.json(await adminService.bulkApproveRegistrations(parsedUserIds, adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/pending-verifications/bulk-reject',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_FLAGS),
|
||||
Validator('json', z.object({user_ids: z.array(Int64Type).min(1)})),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {user_ids} = ctx.req.valid('json');
|
||||
const parsedUserIds = user_ids.map(createUserID);
|
||||
return ctx.json(await adminService.bulkRejectRegistrations(parsedUserIds, adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
};
|
||||
169
fluxer_api/src/admin/controllers/VoiceAdminController.ts
Normal file
169
fluxer_api/src/admin/controllers/VoiceAdminController.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* 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 {AdminACLs} from '~/Constants';
|
||||
import {requireAdminACL} from '~/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {Validator} from '~/Validator';
|
||||
import {
|
||||
CreateVoiceRegionRequest,
|
||||
CreateVoiceServerRequest,
|
||||
DeleteVoiceRegionRequest,
|
||||
DeleteVoiceServerRequest,
|
||||
GetVoiceRegionRequest,
|
||||
GetVoiceServerRequest,
|
||||
ListVoiceRegionsRequest,
|
||||
ListVoiceServersRequest,
|
||||
UpdateVoiceRegionRequest,
|
||||
UpdateVoiceServerRequest,
|
||||
} from '../AdminModel';
|
||||
|
||||
export const VoiceAdminController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/admin/voice/regions/list',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_LIST),
|
||||
Validator('json', ListVoiceRegionsRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.listVoiceRegions(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/regions/get',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_LIST),
|
||||
Validator('json', GetVoiceRegionRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.getVoiceRegion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/regions/create',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_CREATE),
|
||||
Validator('json', CreateVoiceRegionRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.createVoiceRegion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/regions/update',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_UPDATE),
|
||||
Validator('json', UpdateVoiceRegionRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.updateVoiceRegion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/regions/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_DELETE),
|
||||
Validator('json', DeleteVoiceRegionRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.deleteVoiceRegion(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/servers/list',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_LIST),
|
||||
Validator('json', ListVoiceServersRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.listVoiceServers(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/servers/get',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_LIST),
|
||||
Validator('json', GetVoiceServerRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.getVoiceServer(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/servers/create',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_CREATE),
|
||||
Validator('json', CreateVoiceServerRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.createVoiceServer(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/servers/update',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_UPDATE),
|
||||
Validator('json', UpdateVoiceServerRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.updateVoiceServer(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/admin/voice/servers/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_DELETE),
|
||||
Validator('json', DeleteVoiceServerRequest),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(await adminService.deleteVoiceServer(ctx.req.valid('json'), adminUserId, auditLogReason));
|
||||
},
|
||||
);
|
||||
};
|
||||
55
fluxer_api/src/admin/controllers/index.ts
Normal file
55
fluxer_api/src/admin/controllers/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 {ArchiveAdminController} from './ArchiveAdminController';
|
||||
import {AssetAdminController} from './AssetAdminController';
|
||||
import {AuditLogAdminController} from './AuditLogAdminController';
|
||||
import {BanAdminController} from './BanAdminController';
|
||||
import {BulkAdminController} from './BulkAdminController';
|
||||
import {CodesAdminController} from './CodesAdminController';
|
||||
import {FeatureFlagAdminController} from './FeatureFlagAdminController';
|
||||
import {GatewayAdminController} from './GatewayAdminController';
|
||||
import {GuildAdminController} from './GuildAdminController';
|
||||
import {InstanceConfigAdminController} from './InstanceConfigAdminController';
|
||||
import {MessageAdminController} from './MessageAdminController';
|
||||
import {ReportAdminController} from './ReportAdminController';
|
||||
import {SearchAdminController} from './SearchAdminController';
|
||||
import {UserAdminController} from './UserAdminController';
|
||||
import {VerificationAdminController} from './VerificationAdminController';
|
||||
import {VoiceAdminController} from './VoiceAdminController';
|
||||
|
||||
export const registerAdminControllers = (app: HonoApp) => {
|
||||
UserAdminController(app);
|
||||
CodesAdminController(app);
|
||||
GuildAdminController(app);
|
||||
AssetAdminController(app);
|
||||
BanAdminController(app);
|
||||
InstanceConfigAdminController(app);
|
||||
MessageAdminController(app);
|
||||
BulkAdminController(app);
|
||||
AuditLogAdminController(app);
|
||||
ArchiveAdminController(app);
|
||||
ReportAdminController(app);
|
||||
VoiceAdminController(app);
|
||||
GatewayAdminController(app);
|
||||
SearchAdminController(app);
|
||||
VerificationAdminController(app);
|
||||
FeatureFlagAdminController(app);
|
||||
};
|
||||
114
fluxer_api/src/admin/models/AdminArchiveModel.ts
Normal file
114
fluxer_api/src/admin/models/AdminArchiveModel.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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 {AdminArchiveRow} from '~/database/types/AdminArchiveTypes';
|
||||
|
||||
export type ArchiveSubjectType = 'user' | 'guild';
|
||||
|
||||
export class AdminArchive {
|
||||
subjectType: ArchiveSubjectType;
|
||||
subjectId: bigint;
|
||||
archiveId: bigint;
|
||||
requestedBy: bigint;
|
||||
requestedAt: Date;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
failedAt: Date | null;
|
||||
storageKey: string | null;
|
||||
fileSize: bigint | null;
|
||||
progressPercent: number;
|
||||
progressStep: string | null;
|
||||
errorMessage: string | null;
|
||||
downloadUrlExpiresAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
|
||||
constructor(row: AdminArchiveRow) {
|
||||
this.subjectType = row.subject_type;
|
||||
this.subjectId = row.subject_id;
|
||||
this.archiveId = row.archive_id;
|
||||
this.requestedBy = row.requested_by;
|
||||
this.requestedAt = row.requested_at;
|
||||
this.startedAt = row.started_at ?? null;
|
||||
this.completedAt = row.completed_at ?? null;
|
||||
this.failedAt = row.failed_at ?? null;
|
||||
this.storageKey = row.storage_key ?? null;
|
||||
this.fileSize = row.file_size ?? null;
|
||||
this.progressPercent = row.progress_percent;
|
||||
this.progressStep = row.progress_step ?? null;
|
||||
this.errorMessage = row.error_message ?? null;
|
||||
this.downloadUrlExpiresAt = row.download_url_expires_at ?? null;
|
||||
this.expiresAt = row.expires_at ?? null;
|
||||
}
|
||||
|
||||
toRow(): AdminArchiveRow {
|
||||
return {
|
||||
subject_type: this.subjectType,
|
||||
subject_id: this.subjectId,
|
||||
archive_id: this.archiveId,
|
||||
requested_by: this.requestedBy,
|
||||
requested_at: this.requestedAt,
|
||||
started_at: this.startedAt,
|
||||
completed_at: this.completedAt,
|
||||
failed_at: this.failedAt,
|
||||
storage_key: this.storageKey,
|
||||
file_size: this.fileSize,
|
||||
progress_percent: this.progressPercent,
|
||||
progress_step: this.progressStep,
|
||||
error_message: this.errorMessage,
|
||||
download_url_expires_at: this.downloadUrlExpiresAt,
|
||||
expires_at: this.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
toResponse(): {
|
||||
archive_id: string;
|
||||
subject_type: ArchiveSubjectType;
|
||||
subject_id: string;
|
||||
requested_by: string;
|
||||
requested_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
failed_at: string | null;
|
||||
file_size: string | null;
|
||||
progress_percent: number;
|
||||
progress_step: string | null;
|
||||
error_message: string | null;
|
||||
download_url_expires_at: string | null;
|
||||
expires_at: string | null;
|
||||
} {
|
||||
return {
|
||||
archive_id: this.archiveId.toString(),
|
||||
subject_type: this.subjectType,
|
||||
subject_id: this.subjectId.toString(),
|
||||
requested_by: this.requestedBy.toString(),
|
||||
requested_at: this.requestedAt.toISOString(),
|
||||
started_at: this.startedAt?.toISOString() ?? null,
|
||||
completed_at: this.completedAt?.toISOString() ?? null,
|
||||
failed_at: this.failedAt?.toISOString() ?? null,
|
||||
file_size: this.fileSize?.toString() ?? null,
|
||||
progress_percent: this.progressPercent,
|
||||
progress_step: this.progressStep,
|
||||
error_message: this.errorMessage,
|
||||
download_url_expires_at: this.downloadUrlExpiresAt?.toISOString() ?? null,
|
||||
expires_at: this.expiresAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AdminArchiveResponse = ReturnType<AdminArchive['toResponse']>;
|
||||
126
fluxer_api/src/admin/models/AdminTypes.ts
Normal file
126
fluxer_api/src/admin/models/AdminTypes.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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 {createStringType, Int64Type, z} from '~/Schema';
|
||||
|
||||
export const ListAuditLogsRequest = z.object({
|
||||
admin_user_id: Int64Type.optional(),
|
||||
target_type: createStringType(1, 64).optional(),
|
||||
target_id: Int64Type.optional(),
|
||||
limit: z.number().default(50),
|
||||
offset: z.number().default(0),
|
||||
});
|
||||
|
||||
export type ListAuditLogsRequest = z.infer<typeof ListAuditLogsRequest>;
|
||||
|
||||
export const SearchAuditLogsRequest = z.object({
|
||||
query: createStringType(1, 1024).optional(),
|
||||
admin_user_id: Int64Type.optional(),
|
||||
target_type: createStringType(1, 64).optional(),
|
||||
target_id: Int64Type.optional(),
|
||||
action: createStringType(1, 64).optional(),
|
||||
sort_by: z.enum(['createdAt', 'relevance']).default('createdAt'),
|
||||
sort_order: z.enum(['asc', 'desc']).default('desc'),
|
||||
limit: z.number().default(50),
|
||||
offset: z.number().default(0),
|
||||
});
|
||||
|
||||
export type SearchAuditLogsRequest = z.infer<typeof SearchAuditLogsRequest>;
|
||||
|
||||
export const SearchReportsRequest = z.object({
|
||||
query: createStringType(1, 1024).optional(),
|
||||
limit: z.number().default(50),
|
||||
offset: z.number().default(0),
|
||||
reporter_id: Int64Type.optional(),
|
||||
status: z.number().optional(),
|
||||
report_type: z.number().optional(),
|
||||
category: createStringType(1, 128).optional(),
|
||||
reported_user_id: Int64Type.optional(),
|
||||
reported_guild_id: Int64Type.optional(),
|
||||
reported_channel_id: Int64Type.optional(),
|
||||
guild_context_id: Int64Type.optional(),
|
||||
resolved_by_admin_id: Int64Type.optional(),
|
||||
sort_by: z.enum(['createdAt', 'reportedAt', 'resolvedAt']).default('reportedAt'),
|
||||
sort_order: z.enum(['asc', 'desc']).default('desc'),
|
||||
});
|
||||
|
||||
export type SearchReportsRequest = z.infer<typeof SearchReportsRequest>;
|
||||
|
||||
export const RefreshSearchIndexRequest = z.object({
|
||||
index_type: z.enum(['guilds', 'users', 'reports', 'audit_logs', 'channel_messages', 'favorite_memes']),
|
||||
guild_id: Int64Type.optional(),
|
||||
user_id: Int64Type.optional(),
|
||||
});
|
||||
|
||||
export type RefreshSearchIndexRequest = z.infer<typeof RefreshSearchIndexRequest>;
|
||||
|
||||
export const GetIndexRefreshStatusRequest = z.object({
|
||||
job_id: createStringType(1, 128),
|
||||
});
|
||||
|
||||
export type GetIndexRefreshStatusRequest = z.infer<typeof GetIndexRefreshStatusRequest>;
|
||||
|
||||
export const PurgeGuildAssetsRequest = z.object({
|
||||
ids: z.array(createStringType(1, 64)),
|
||||
});
|
||||
|
||||
export type PurgeGuildAssetsRequest = z.infer<typeof PurgeGuildAssetsRequest>;
|
||||
|
||||
export interface PurgeGuildAssetResult {
|
||||
id: string;
|
||||
asset_type: 'emoji' | 'sticker' | 'unknown';
|
||||
found_in_db: boolean;
|
||||
guild_id: string | null;
|
||||
}
|
||||
|
||||
export interface PurgeGuildAssetError {
|
||||
id: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface PurgeGuildAssetsResponse {
|
||||
processed: Array<PurgeGuildAssetResult>;
|
||||
errors: Array<PurgeGuildAssetError>;
|
||||
}
|
||||
|
||||
export interface GuildEmojiAsset {
|
||||
id: string;
|
||||
name: string;
|
||||
animated: boolean;
|
||||
creator_id: string;
|
||||
media_url: string;
|
||||
}
|
||||
|
||||
export interface ListGuildEmojisResponse {
|
||||
guild_id: string;
|
||||
emojis: Array<GuildEmojiAsset>;
|
||||
}
|
||||
|
||||
export interface GuildStickerAsset {
|
||||
id: string;
|
||||
name: string;
|
||||
format_type: number;
|
||||
creator_id: string;
|
||||
media_url: string;
|
||||
}
|
||||
|
||||
export interface ListGuildStickersResponse {
|
||||
guild_id: string;
|
||||
stickers: Array<GuildStickerAsset>;
|
||||
}
|
||||
42
fluxer_api/src/admin/models/ArchiveTypes.ts
Normal file
42
fluxer_api/src/admin/models/ArchiveTypes.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 {Int64Type, z} from '~/Schema';
|
||||
|
||||
export const TriggerUserArchiveRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
});
|
||||
|
||||
export type TriggerUserArchiveRequest = z.infer<typeof TriggerUserArchiveRequest>;
|
||||
|
||||
export const TriggerGuildArchiveRequest = z.object({
|
||||
guild_id: Int64Type,
|
||||
});
|
||||
|
||||
export type TriggerGuildArchiveRequest = z.infer<typeof TriggerGuildArchiveRequest>;
|
||||
|
||||
export const ListArchivesRequest = z.object({
|
||||
subject_type: z.enum(['user', 'guild', 'all']).default('all'),
|
||||
subject_id: Int64Type.optional(),
|
||||
requested_by: Int64Type.optional(),
|
||||
limit: z.number().min(1).max(200).default(50),
|
||||
include_expired: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type ListArchivesRequest = z.infer<typeof ListArchivesRequest>;
|
||||
42
fluxer_api/src/admin/models/BanTypes.ts
Normal file
42
fluxer_api/src/admin/models/BanTypes.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 {createStringType, EmailType, PhoneNumberType, z} from '~/Schema';
|
||||
import {isValidIpOrRange} from '~/utils/IpRangeUtils';
|
||||
|
||||
export const BanIpRequest = z.object({
|
||||
ip: createStringType(1, 45).refine(
|
||||
(value) => isValidIpOrRange(value),
|
||||
'Must be a valid IPv4/IPv6 address or CIDR range',
|
||||
),
|
||||
});
|
||||
|
||||
export type BanIpRequest = z.infer<typeof BanIpRequest>;
|
||||
|
||||
export const BanEmailRequest = z.object({
|
||||
email: EmailType,
|
||||
});
|
||||
|
||||
export type BanEmailRequest = z.infer<typeof BanEmailRequest>;
|
||||
|
||||
export const BanPhoneRequest = z.object({
|
||||
phone: PhoneNumberType,
|
||||
});
|
||||
|
||||
export type BanPhoneRequest = z.infer<typeof BanPhoneRequest>;
|
||||
42
fluxer_api/src/admin/models/CodeRequestTypes.ts
Normal file
42
fluxer_api/src/admin/models/CodeRequestTypes.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 {z} from '~/Schema';
|
||||
import {ProductType} from '~/stripe/ProductRegistry';
|
||||
|
||||
const GiftProductTypes = [ProductType.GIFT_1_MONTH, ProductType.GIFT_1_YEAR, ProductType.GIFT_VISIONARY] as const;
|
||||
|
||||
const MAX_CODES_PER_REQUEST = 100;
|
||||
|
||||
export const GiftProductTypeEnum = z.enum(GiftProductTypes);
|
||||
|
||||
export type GiftProductType = z.infer<typeof GiftProductTypeEnum>;
|
||||
|
||||
export const GenerateBetaCodesRequest = z.object({
|
||||
count: z.number().int().min(1).max(MAX_CODES_PER_REQUEST),
|
||||
});
|
||||
|
||||
export type GenerateBetaCodesRequest = z.infer<typeof GenerateBetaCodesRequest>;
|
||||
|
||||
export const GenerateGiftCodesRequest = z.object({
|
||||
count: z.number().int().min(1).max(MAX_CODES_PER_REQUEST),
|
||||
product_type: GiftProductTypeEnum,
|
||||
});
|
||||
|
||||
export type GenerateGiftCodesRequest = z.infer<typeof GenerateGiftCodesRequest>;
|
||||
99
fluxer_api/src/admin/models/GuildRequestTypes.ts
Normal file
99
fluxer_api/src/admin/models/GuildRequestTypes.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 {createStringType, Int64Type, VanityURLCodeType, z} from '~/Schema';
|
||||
|
||||
export const UpdateGuildFeaturesRequest = z.object({
|
||||
guild_id: Int64Type,
|
||||
add_features: z.array(createStringType(1, 64)).default([]),
|
||||
remove_features: z.array(createStringType(1, 64)).default([]),
|
||||
});
|
||||
|
||||
export type UpdateGuildFeaturesRequest = z.infer<typeof UpdateGuildFeaturesRequest>;
|
||||
|
||||
export const ForceAddUserToGuildRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
guild_id: Int64Type,
|
||||
});
|
||||
|
||||
export interface ForceAddUserToGuildRequest {
|
||||
user_id: bigint;
|
||||
guild_id: bigint;
|
||||
}
|
||||
|
||||
export const ClearGuildFieldsRequest = z.object({
|
||||
guild_id: Int64Type,
|
||||
fields: z.array(z.enum(['icon', 'banner', 'splash'])),
|
||||
});
|
||||
|
||||
export type ClearGuildFieldsRequest = z.infer<typeof ClearGuildFieldsRequest>;
|
||||
|
||||
export const DeleteGuildRequest = z.object({
|
||||
guild_id: Int64Type,
|
||||
});
|
||||
|
||||
export type DeleteGuildRequest = z.infer<typeof DeleteGuildRequest>;
|
||||
|
||||
export const UpdateGuildVanityRequest = z.object({
|
||||
guild_id: Int64Type,
|
||||
vanity_url_code: VanityURLCodeType.nullable(),
|
||||
});
|
||||
|
||||
export type UpdateGuildVanityRequest = z.infer<typeof UpdateGuildVanityRequest>;
|
||||
|
||||
export const UpdateGuildNameRequest = z.object({
|
||||
guild_id: Int64Type,
|
||||
name: createStringType(1, 100),
|
||||
});
|
||||
|
||||
export type UpdateGuildNameRequest = z.infer<typeof UpdateGuildNameRequest>;
|
||||
|
||||
export const UpdateGuildSettingsRequest = z.object({
|
||||
guild_id: Int64Type,
|
||||
verification_level: z.number().optional(),
|
||||
mfa_level: z.number().optional(),
|
||||
nsfw_level: z.number().optional(),
|
||||
explicit_content_filter: z.number().optional(),
|
||||
default_message_notifications: z.number().optional(),
|
||||
disabled_operations: z.number().optional(),
|
||||
});
|
||||
|
||||
export type UpdateGuildSettingsRequest = z.infer<typeof UpdateGuildSettingsRequest>;
|
||||
|
||||
export const TransferGuildOwnershipRequest = z.object({
|
||||
guild_id: Int64Type,
|
||||
new_owner_id: Int64Type,
|
||||
});
|
||||
|
||||
export type TransferGuildOwnershipRequest = z.infer<typeof TransferGuildOwnershipRequest>;
|
||||
|
||||
export const BulkUpdateGuildFeaturesRequest = z.object({
|
||||
guild_ids: z.array(Int64Type),
|
||||
add_features: z.array(createStringType(1, 64)).default([]),
|
||||
remove_features: z.array(createStringType(1, 64)).default([]),
|
||||
});
|
||||
|
||||
export type BulkUpdateGuildFeaturesRequest = z.infer<typeof BulkUpdateGuildFeaturesRequest>;
|
||||
|
||||
export const BulkAddGuildMembersRequest = z.object({
|
||||
guild_id: Int64Type,
|
||||
user_ids: z.array(Int64Type),
|
||||
});
|
||||
|
||||
export type BulkAddGuildMembersRequest = z.infer<typeof BulkAddGuildMembersRequest>;
|
||||
113
fluxer_api/src/admin/models/GuildTypes.ts
Normal file
113
fluxer_api/src/admin/models/GuildTypes.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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 {Guild} from '~/Models';
|
||||
import {createStringType, Int64Type, z} from '~/Schema';
|
||||
|
||||
export const mapGuildToAdminResponse = (guild: Guild): GuildAdminResponse => ({
|
||||
id: guild.id.toString(),
|
||||
name: guild.name,
|
||||
features: Array.from(guild.features),
|
||||
owner_id: guild.ownerId.toString(),
|
||||
icon: guild.iconHash,
|
||||
banner: guild.bannerHash,
|
||||
member_count: guild.memberCount,
|
||||
});
|
||||
|
||||
export const GuildAdminResponse = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
features: z.array(z.string()),
|
||||
owner_id: z.string(),
|
||||
icon: z.string().nullable(),
|
||||
banner: z.string().nullable(),
|
||||
member_count: z.number(),
|
||||
});
|
||||
|
||||
export type GuildAdminResponse = z.infer<typeof GuildAdminResponse>;
|
||||
|
||||
export const mapGuildsToAdminResponse = (guilds: Array<Guild>): GuildsAdminResponse => {
|
||||
return {
|
||||
guilds: [
|
||||
...guilds.map((guild) => {
|
||||
return {
|
||||
id: guild.id.toString(),
|
||||
name: guild.name,
|
||||
features: Array.from(guild.features),
|
||||
owner_id: guild.ownerId.toString(),
|
||||
icon: guild.iconHash,
|
||||
banner: guild.bannerHash,
|
||||
member_count: guild.memberCount,
|
||||
};
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const ListGuildsAdminResponse = z.object({
|
||||
guilds: z.array(GuildAdminResponse),
|
||||
});
|
||||
|
||||
type GuildsAdminResponse = z.infer<typeof ListGuildsAdminResponse>;
|
||||
|
||||
export const ListUserGuildsRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
});
|
||||
|
||||
export type ListUserGuildsRequest = z.infer<typeof ListUserGuildsRequest>;
|
||||
|
||||
export const LookupGuildRequest = z.object({
|
||||
guild_id: Int64Type,
|
||||
});
|
||||
|
||||
export type LookupGuildRequest = z.infer<typeof LookupGuildRequest>;
|
||||
|
||||
export const ListGuildMembersRequest = z.object({
|
||||
guild_id: Int64Type,
|
||||
limit: z.number().default(50),
|
||||
offset: z.number().default(0),
|
||||
});
|
||||
|
||||
export type ListGuildMembersRequest = z.infer<typeof ListGuildMembersRequest>;
|
||||
|
||||
export const SearchGuildsRequest = z.object({
|
||||
query: createStringType(1, 1024).optional(),
|
||||
limit: z.number().default(50),
|
||||
offset: z.number().default(0),
|
||||
});
|
||||
|
||||
export type SearchGuildsRequest = z.infer<typeof SearchGuildsRequest>;
|
||||
|
||||
export const ReloadGuildRequest = z.object({
|
||||
guild_id: Int64Type,
|
||||
});
|
||||
|
||||
export type ReloadGuildRequest = z.infer<typeof ReloadGuildRequest>;
|
||||
|
||||
export const ShutdownGuildRequest = z.object({
|
||||
guild_id: Int64Type,
|
||||
});
|
||||
|
||||
export type ShutdownGuildRequest = z.infer<typeof ShutdownGuildRequest>;
|
||||
|
||||
export const GetProcessMemoryStatsRequest = z.object({
|
||||
limit: z.number().int().min(1).max(100).default(25),
|
||||
});
|
||||
|
||||
export type GetProcessMemoryStatsRequest = z.infer<typeof GetProcessMemoryStatsRequest>;
|
||||
87
fluxer_api/src/admin/models/MessageTypes.ts
Normal file
87
fluxer_api/src/admin/models/MessageTypes.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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 {FilenameType, Int64Type, z} from '~/Schema';
|
||||
|
||||
export const LookupMessageRequest = z.object({
|
||||
channel_id: Int64Type,
|
||||
message_id: Int64Type,
|
||||
context_limit: z.number().default(50),
|
||||
});
|
||||
|
||||
export type LookupMessageRequest = z.infer<typeof LookupMessageRequest>;
|
||||
|
||||
export const LookupMessageByAttachmentRequest = z.object({
|
||||
channel_id: Int64Type,
|
||||
attachment_id: Int64Type,
|
||||
filename: FilenameType,
|
||||
context_limit: z.number().default(50),
|
||||
});
|
||||
|
||||
export type LookupMessageByAttachmentRequest = z.infer<typeof LookupMessageByAttachmentRequest>;
|
||||
|
||||
export const DeleteMessageRequest = z.object({
|
||||
channel_id: Int64Type,
|
||||
message_id: Int64Type,
|
||||
});
|
||||
|
||||
export type DeleteMessageRequest = z.infer<typeof DeleteMessageRequest>;
|
||||
|
||||
const MessageShredEntryType = z.object({
|
||||
channel_id: Int64Type,
|
||||
message_id: Int64Type,
|
||||
});
|
||||
|
||||
export const MessageShredRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
entries: z.array(MessageShredEntryType).min(1),
|
||||
});
|
||||
|
||||
export type MessageShredRequest = z.infer<typeof MessageShredRequest>;
|
||||
|
||||
export const MessageShredResponse = z.object({
|
||||
success: z.literal(true),
|
||||
job_id: z.string(),
|
||||
requested: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
export type MessageShredResponse = z.infer<typeof MessageShredResponse>;
|
||||
|
||||
export const MessageShredStatusRequest = z.object({
|
||||
job_id: z.string(),
|
||||
});
|
||||
|
||||
export type MessageShredStatusRequest = z.infer<typeof MessageShredStatusRequest>;
|
||||
|
||||
export const DeleteAllUserMessagesRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
dry_run: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type DeleteAllUserMessagesRequest = z.infer<typeof DeleteAllUserMessagesRequest>;
|
||||
|
||||
export const DeleteAllUserMessagesResponse = z.object({
|
||||
success: z.literal(true),
|
||||
dry_run: z.boolean(),
|
||||
channel_count: z.number().int(),
|
||||
message_count: z.number().int(),
|
||||
job_id: z.string().optional(),
|
||||
});
|
||||
|
||||
export type DeleteAllUserMessagesResponse = z.infer<typeof DeleteAllUserMessagesResponse>;
|
||||
170
fluxer_api/src/admin/models/UserRequestTypes.ts
Normal file
170
fluxer_api/src/admin/models/UserRequestTypes.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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 {createStringType, EmailType, Int64Type, UsernameType, z} from '~/Schema';
|
||||
|
||||
export const UpdateUserFlagsRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
add_flags: z.array(Int64Type).default([]),
|
||||
remove_flags: z.array(Int64Type).default([]),
|
||||
});
|
||||
|
||||
export type UpdateUserFlagsRequest = z.infer<typeof UpdateUserFlagsRequest>;
|
||||
|
||||
export const DisableMfaRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
});
|
||||
|
||||
export type DisableMfaRequest = z.infer<typeof DisableMfaRequest>;
|
||||
|
||||
export const CancelBulkMessageDeletionRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
});
|
||||
|
||||
export type CancelBulkMessageDeletionRequest = z.infer<typeof CancelBulkMessageDeletionRequest>;
|
||||
|
||||
export const ClearUserFieldsRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
fields: z.array(z.enum(['avatar', 'banner', 'bio', 'pronouns', 'global_name'])),
|
||||
});
|
||||
|
||||
export type ClearUserFieldsRequest = z.infer<typeof ClearUserFieldsRequest>;
|
||||
|
||||
export const SetUserBotStatusRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
bot: z.boolean(),
|
||||
});
|
||||
|
||||
export type SetUserBotStatusRequest = z.infer<typeof SetUserBotStatusRequest>;
|
||||
|
||||
export const SetUserSystemStatusRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
system: z.boolean(),
|
||||
});
|
||||
|
||||
export type SetUserSystemStatusRequest = z.infer<typeof SetUserSystemStatusRequest>;
|
||||
|
||||
export const VerifyUserEmailRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
});
|
||||
|
||||
export type VerifyUserEmailRequest = z.infer<typeof VerifyUserEmailRequest>;
|
||||
|
||||
export const SendPasswordResetRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
});
|
||||
|
||||
export type SendPasswordResetRequest = z.infer<typeof SendPasswordResetRequest>;
|
||||
|
||||
export const ChangeUsernameRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
username: UsernameType,
|
||||
discriminator: z.number().optional(),
|
||||
});
|
||||
|
||||
export type ChangeUsernameRequest = z.infer<typeof ChangeUsernameRequest>;
|
||||
|
||||
export const ChangeEmailRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
email: EmailType,
|
||||
});
|
||||
|
||||
export type ChangeEmailRequest = z.infer<typeof ChangeEmailRequest>;
|
||||
|
||||
export const TerminateSessionsRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
});
|
||||
|
||||
export type TerminateSessionsRequest = z.infer<typeof TerminateSessionsRequest>;
|
||||
|
||||
export const TempBanUserRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
duration_hours: z.number(),
|
||||
reason: createStringType(0, 512).optional(),
|
||||
});
|
||||
|
||||
export type TempBanUserRequest = z.infer<typeof TempBanUserRequest>;
|
||||
|
||||
export const ScheduleAccountDeletionRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
reason_code: z.number(),
|
||||
public_reason: createStringType(0, 512).optional(),
|
||||
days_until_deletion: z.number().default(60),
|
||||
});
|
||||
|
||||
export type ScheduleAccountDeletionRequest = z.infer<typeof ScheduleAccountDeletionRequest>;
|
||||
|
||||
export const SetUserAclsRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
acls: z.array(createStringType(1, 64)),
|
||||
});
|
||||
|
||||
export type SetUserAclsRequest = z.infer<typeof SetUserAclsRequest>;
|
||||
|
||||
export const UnlinkPhoneRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
});
|
||||
|
||||
export type UnlinkPhoneRequest = z.infer<typeof UnlinkPhoneRequest>;
|
||||
|
||||
export const ChangeDobRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
date_of_birth: createStringType(10, 10).refine((value) => /^\d{4}-\d{2}-\d{2}$/.test(value), 'Invalid date format'),
|
||||
});
|
||||
|
||||
export type ChangeDobRequest = z.infer<typeof ChangeDobRequest>;
|
||||
|
||||
export const UpdateSuspiciousActivityFlagsRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
flags: z.number(),
|
||||
});
|
||||
|
||||
export type UpdateSuspiciousActivityFlagsRequest = z.infer<typeof UpdateSuspiciousActivityFlagsRequest>;
|
||||
|
||||
export const DisableForSuspiciousActivityRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
flags: z.number(),
|
||||
});
|
||||
|
||||
export type DisableForSuspiciousActivityRequest = z.infer<typeof DisableForSuspiciousActivityRequest>;
|
||||
|
||||
export const BulkUpdateUserFlagsRequest = z.object({
|
||||
user_ids: z.array(Int64Type),
|
||||
add_flags: z.array(Int64Type).default([]),
|
||||
remove_flags: z.array(Int64Type).default([]),
|
||||
});
|
||||
|
||||
export type BulkUpdateUserFlagsRequest = z.infer<typeof BulkUpdateUserFlagsRequest>;
|
||||
|
||||
export const BulkScheduleUserDeletionRequest = z.object({
|
||||
user_ids: z.array(Int64Type),
|
||||
reason_code: z.number(),
|
||||
public_reason: createStringType(0, 512).optional(),
|
||||
days_until_deletion: z.number().default(60),
|
||||
});
|
||||
|
||||
export type BulkScheduleUserDeletionRequest = z.infer<typeof BulkScheduleUserDeletionRequest>;
|
||||
|
||||
export const ListUserChangeLogRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
limit: z.number().min(1).max(200).default(50),
|
||||
page_token: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ListUserChangeLogRequest = z.infer<typeof ListUserChangeLogRequest>;
|
||||
180
fluxer_api/src/admin/models/UserTypes.ts
Normal file
180
fluxer_api/src/admin/models/UserTypes.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* 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 dns from 'node:dns';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {User} from '~/Models';
|
||||
import {createStringType, Int64Type, z} from '~/Schema';
|
||||
import * as IpUtils from '~/utils/IpUtils';
|
||||
|
||||
const REVERSE_DNS_CACHE_TTL_SECONDS = 86400;
|
||||
|
||||
async function reverseDnsLookup(ip: string, cacheService?: ICacheService): Promise<string | null> {
|
||||
const cacheKey = `reverse-dns:${ip}`;
|
||||
|
||||
if (cacheService) {
|
||||
const cached = await cacheService.get<string | null>(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached === '' ? null : cached;
|
||||
}
|
||||
}
|
||||
|
||||
let result: string | null = null;
|
||||
try {
|
||||
const hostnames = await dns.promises.reverse(ip);
|
||||
result = hostnames[0] ?? null;
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (cacheService) {
|
||||
await cacheService.set(cacheKey, result ?? '', REVERSE_DNS_CACHE_TTL_SECONDS);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const mapUserToAdminResponse = async (user: User, cacheService?: ICacheService): Promise<UserAdminResponse> => {
|
||||
const lastActiveIpReverse = user.lastActiveIp ? await reverseDnsLookup(user.lastActiveIp, cacheService) : null;
|
||||
let lastActiveLocation: string | null = null;
|
||||
if (user.lastActiveIp) {
|
||||
try {
|
||||
const geoip = await IpUtils.getCountryCodeDetailed(user.lastActiveIp);
|
||||
const formattedLocation = IpUtils.formatGeoipLocation(geoip);
|
||||
lastActiveLocation = formattedLocation === IpUtils.UNKNOWN_LOCATION ? null : formattedLocation;
|
||||
} catch {
|
||||
lastActiveLocation = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
discriminator: user.discriminator,
|
||||
global_name: user.globalName,
|
||||
bot: user.isBot,
|
||||
system: user.isSystem,
|
||||
flags: user.flags.toString(),
|
||||
avatar: user.avatarHash,
|
||||
banner: user.bannerHash,
|
||||
bio: user.bio,
|
||||
pronouns: user.pronouns,
|
||||
accent_color: user.accentColor,
|
||||
email: user.email,
|
||||
email_verified: user.emailVerified,
|
||||
email_bounced: user.emailBounced,
|
||||
phone: user.phone,
|
||||
date_of_birth: user.dateOfBirth,
|
||||
locale: user.locale,
|
||||
premium_type: user.premiumType,
|
||||
premium_since: user.premiumSince?.toISOString() ?? null,
|
||||
premium_until: user.premiumUntil?.toISOString() ?? null,
|
||||
suspicious_activity_flags: user.suspiciousActivityFlags,
|
||||
temp_banned_until: user.tempBannedUntil?.toISOString() ?? null,
|
||||
pending_deletion_at: user.pendingDeletionAt?.toISOString() ?? null,
|
||||
pending_bulk_message_deletion_at: user.pendingBulkMessageDeletionAt?.toISOString() ?? null,
|
||||
deletion_reason_code: user.deletionReasonCode,
|
||||
deletion_public_reason: user.deletionPublicReason,
|
||||
acls: user.acls ? Array.from(user.acls) : [],
|
||||
has_totp: user.totpSecret !== null,
|
||||
authenticator_types: user.authenticatorTypes ? Array.from(user.authenticatorTypes) : [],
|
||||
last_active_at: user.lastActiveAt?.toISOString() ?? null,
|
||||
last_active_ip: user.lastActiveIp,
|
||||
last_active_ip_reverse: lastActiveIpReverse,
|
||||
last_active_location: lastActiveLocation,
|
||||
};
|
||||
};
|
||||
|
||||
export const UserAdminResponse = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
discriminator: z.number(),
|
||||
global_name: z.string().nullable(),
|
||||
bot: z.boolean(),
|
||||
system: z.boolean(),
|
||||
flags: z.string(),
|
||||
avatar: z.string().nullable(),
|
||||
banner: z.string().nullable(),
|
||||
bio: z.string().nullable(),
|
||||
pronouns: z.string().nullable(),
|
||||
accent_color: z.number().nullable(),
|
||||
email: z.string().nullable(),
|
||||
email_verified: z.boolean(),
|
||||
email_bounced: z.boolean(),
|
||||
phone: z.string().nullable(),
|
||||
date_of_birth: z.string().nullable(),
|
||||
locale: z.string().nullable(),
|
||||
premium_type: z.number().nullable(),
|
||||
premium_since: z.string().nullable(),
|
||||
premium_until: z.string().nullable(),
|
||||
suspicious_activity_flags: z.number(),
|
||||
temp_banned_until: z.string().nullable(),
|
||||
pending_deletion_at: z.string().nullable(),
|
||||
pending_bulk_message_deletion_at: z.string().nullable(),
|
||||
deletion_reason_code: z.number().nullable(),
|
||||
deletion_public_reason: z.string().nullable(),
|
||||
acls: z.array(z.string()),
|
||||
has_totp: z.boolean(),
|
||||
authenticator_types: z.array(z.number()),
|
||||
last_active_at: z.string().nullable(),
|
||||
last_active_ip: z.string().nullable(),
|
||||
last_active_ip_reverse: z.string().nullable(),
|
||||
last_active_location: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type UserAdminResponse = z.infer<typeof UserAdminResponse>;
|
||||
|
||||
export const LookupUserRequest = z.object({
|
||||
query: createStringType(1, 1024),
|
||||
});
|
||||
|
||||
export type LookupUserRequest = z.infer<typeof LookupUserRequest>;
|
||||
|
||||
export const SearchUsersRequest = z.object({
|
||||
query: createStringType(1, 1024).optional(),
|
||||
limit: z.number().default(50),
|
||||
offset: z.number().default(0),
|
||||
});
|
||||
|
||||
export type SearchUsersRequest = z.infer<typeof SearchUsersRequest>;
|
||||
|
||||
export const ListUserSessionsRequest = z.object({
|
||||
user_id: Int64Type,
|
||||
});
|
||||
|
||||
export type ListUserSessionsRequest = z.infer<typeof ListUserSessionsRequest>;
|
||||
|
||||
export const UserContactChangeLogEntry = z.object({
|
||||
event_id: z.string(),
|
||||
field: z.string(),
|
||||
old_value: z.string().nullable(),
|
||||
new_value: z.string().nullable(),
|
||||
reason: z.enum(['user_requested', 'admin_action']),
|
||||
actor_user_id: z.string().nullable(),
|
||||
event_at: z.string(),
|
||||
});
|
||||
|
||||
export type UserContactChangeLogEntry = z.infer<typeof UserContactChangeLogEntry>;
|
||||
|
||||
export const ListUserChangeLogResponse = z.object({
|
||||
entries: z.array(UserContactChangeLogEntry),
|
||||
next_page_token: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type ListUserChangeLogResponse = z.infer<typeof ListUserChangeLogResponse>;
|
||||
151
fluxer_api/src/admin/models/VoiceTypes.ts
Normal file
151
fluxer_api/src/admin/models/VoiceTypes.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 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 {createStringType, Int64Type, z} from '~/Schema';
|
||||
|
||||
export const VoiceRegionAdminResponse = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
emoji: z.string(),
|
||||
latitude: z.number(),
|
||||
longitude: z.number(),
|
||||
is_default: z.boolean(),
|
||||
vip_only: z.boolean(),
|
||||
required_guild_features: z.array(z.string()),
|
||||
allowed_guild_ids: z.array(z.string()),
|
||||
allowed_user_ids: z.array(z.string()),
|
||||
created_at: z.string().nullable(),
|
||||
updated_at: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type VoiceRegionAdminResponse = z.infer<typeof VoiceRegionAdminResponse>;
|
||||
|
||||
export const VoiceServerAdminResponse = z.object({
|
||||
region_id: z.string(),
|
||||
server_id: z.string(),
|
||||
endpoint: z.url(),
|
||||
is_active: z.boolean(),
|
||||
vip_only: z.boolean(),
|
||||
required_guild_features: z.array(z.string()),
|
||||
allowed_guild_ids: z.array(z.string()),
|
||||
allowed_user_ids: z.array(z.string()),
|
||||
created_at: z.string().nullable(),
|
||||
updated_at: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type VoiceServerAdminResponse = z.infer<typeof VoiceServerAdminResponse>;
|
||||
|
||||
export const CreateVoiceRegionRequest = z.object({
|
||||
id: createStringType(1, 64),
|
||||
name: createStringType(1, 100),
|
||||
emoji: createStringType(1, 64),
|
||||
latitude: z.number(),
|
||||
longitude: z.number(),
|
||||
is_default: z.boolean().optional().default(false),
|
||||
vip_only: z.boolean().optional().default(false),
|
||||
required_guild_features: z.array(createStringType(1, 64)).optional().default([]),
|
||||
allowed_guild_ids: z.array(Int64Type).optional().default([]),
|
||||
allowed_user_ids: z.array(Int64Type).optional().default([]),
|
||||
});
|
||||
|
||||
export type CreateVoiceRegionRequest = z.infer<typeof CreateVoiceRegionRequest>;
|
||||
|
||||
export const UpdateVoiceRegionRequest = z.object({
|
||||
id: createStringType(1, 64),
|
||||
name: createStringType(1, 100).optional(),
|
||||
emoji: createStringType(1, 64).optional(),
|
||||
latitude: z.number().optional(),
|
||||
longitude: z.number().optional(),
|
||||
is_default: z.boolean().optional(),
|
||||
vip_only: z.boolean().optional(),
|
||||
required_guild_features: z.array(createStringType(1, 64)).optional(),
|
||||
allowed_guild_ids: z.array(Int64Type).optional(),
|
||||
allowed_user_ids: z.array(Int64Type).optional(),
|
||||
});
|
||||
|
||||
export type UpdateVoiceRegionRequest = z.infer<typeof UpdateVoiceRegionRequest>;
|
||||
|
||||
export const DeleteVoiceRegionRequest = z.object({
|
||||
id: createStringType(1, 64),
|
||||
});
|
||||
|
||||
export type DeleteVoiceRegionRequest = z.infer<typeof DeleteVoiceRegionRequest>;
|
||||
|
||||
export const CreateVoiceServerRequest = z.object({
|
||||
region_id: createStringType(1, 64),
|
||||
server_id: createStringType(1, 64),
|
||||
endpoint: z.url(),
|
||||
api_key: createStringType(1, 256),
|
||||
api_secret: createStringType(1, 256),
|
||||
is_active: z.boolean().optional().default(true),
|
||||
vip_only: z.boolean().optional().default(false),
|
||||
required_guild_features: z.array(createStringType(1, 64)).optional().default([]),
|
||||
allowed_guild_ids: z.array(Int64Type).optional().default([]),
|
||||
allowed_user_ids: z.array(Int64Type).optional().default([]),
|
||||
});
|
||||
|
||||
export type CreateVoiceServerRequest = z.infer<typeof CreateVoiceServerRequest>;
|
||||
|
||||
export const UpdateVoiceServerRequest = z.object({
|
||||
region_id: createStringType(1, 64),
|
||||
server_id: createStringType(1, 64),
|
||||
endpoint: z.url().optional(),
|
||||
api_key: createStringType(1, 256).optional(),
|
||||
api_secret: createStringType(1, 256).optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
vip_only: z.boolean().optional(),
|
||||
required_guild_features: z.array(createStringType(1, 64)).optional(),
|
||||
allowed_guild_ids: z.array(Int64Type).optional(),
|
||||
allowed_user_ids: z.array(Int64Type).optional(),
|
||||
});
|
||||
|
||||
export type UpdateVoiceServerRequest = z.infer<typeof UpdateVoiceServerRequest>;
|
||||
|
||||
export const DeleteVoiceServerRequest = z.object({
|
||||
region_id: createStringType(1, 64),
|
||||
server_id: createStringType(1, 64),
|
||||
});
|
||||
|
||||
export type DeleteVoiceServerRequest = z.infer<typeof DeleteVoiceServerRequest>;
|
||||
|
||||
export const ListVoiceRegionsRequest = z.object({
|
||||
include_servers: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export type ListVoiceRegionsRequest = z.infer<typeof ListVoiceRegionsRequest>;
|
||||
|
||||
export const GetVoiceRegionRequest = z.object({
|
||||
id: createStringType(1, 64),
|
||||
include_servers: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export type GetVoiceRegionRequest = z.infer<typeof GetVoiceRegionRequest>;
|
||||
|
||||
export const ListVoiceServersRequest = z.object({
|
||||
region_id: createStringType(1, 64),
|
||||
});
|
||||
|
||||
export type ListVoiceServersRequest = z.infer<typeof ListVoiceServersRequest>;
|
||||
|
||||
export const GetVoiceServerRequest = z.object({
|
||||
region_id: createStringType(1, 64),
|
||||
server_id: createStringType(1, 64),
|
||||
});
|
||||
|
||||
export type GetVoiceServerRequest = z.infer<typeof GetVoiceServerRequest>;
|
||||
30
fluxer_api/src/admin/models/index.ts
Normal file
30
fluxer_api/src/admin/models/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export * from './AdminArchiveModel';
|
||||
export * from './AdminTypes';
|
||||
export * from './ArchiveTypes';
|
||||
export * from './BanTypes';
|
||||
export * from './CodeRequestTypes';
|
||||
export * from './GuildRequestTypes';
|
||||
export * from './GuildTypes';
|
||||
export * from './MessageTypes';
|
||||
export * from './UserRequestTypes';
|
||||
export * from './UserTypes';
|
||||
export * from './VoiceTypes';
|
||||
392
fluxer_api/src/admin/repositories/AdminArchiveRepository.ts
Normal file
392
fluxer_api/src/admin/repositories/AdminArchiveRepository.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
/*
|
||||
* 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 {BatchBuilder, Db, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {AdminArchiveRow} from '~/database/CassandraTypes';
|
||||
import {Logger} from '~/Logger';
|
||||
import {AdminArchivesByRequester, AdminArchivesBySubject, AdminArchivesByType} from '~/Tables';
|
||||
import {AdminArchive, type ArchiveSubjectType} from '../models/AdminArchiveModel';
|
||||
|
||||
const RETENTION_DAYS = 365;
|
||||
const SECONDS_PER_DAY = 24 * 60 * 60;
|
||||
const RETENTION_SECONDS = RETENTION_DAYS * SECONDS_PER_DAY;
|
||||
const DEFAULT_RETENTION_MS = RETENTION_SECONDS * 1000;
|
||||
|
||||
function computeTtlSeconds(expiresAt: Date): number {
|
||||
const diffSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
|
||||
return Math.max(diffSeconds, 1);
|
||||
}
|
||||
|
||||
function filterExpired(rows: Array<AdminArchiveRow>, includeExpired: boolean): Array<AdminArchiveRow> {
|
||||
if (includeExpired) return rows;
|
||||
const now = Date.now();
|
||||
return rows.filter((row) => !row.expires_at || row.expires_at.getTime() > now);
|
||||
}
|
||||
|
||||
export class AdminArchiveRepository {
|
||||
private ensureExpiry(archive: AdminArchive): AdminArchive {
|
||||
if (!archive.expiresAt) {
|
||||
archive.expiresAt = new Date(Date.now() + DEFAULT_RETENTION_MS);
|
||||
}
|
||||
return archive;
|
||||
}
|
||||
|
||||
async create(archive: AdminArchive): Promise<void> {
|
||||
const withExpiry = this.ensureExpiry(archive);
|
||||
const row = withExpiry.toRow();
|
||||
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
AdminArchivesBySubject.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByRequester.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByType.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
|
||||
);
|
||||
await batch.execute();
|
||||
|
||||
Logger.debug(
|
||||
{subjectType: withExpiry.subjectType, subjectId: withExpiry.subjectId, archiveId: withExpiry.archiveId},
|
||||
'Created admin archive record',
|
||||
);
|
||||
}
|
||||
|
||||
async update(archive: AdminArchive): Promise<void> {
|
||||
const withExpiry = this.ensureExpiry(archive);
|
||||
const row = withExpiry.toRow();
|
||||
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
AdminArchivesBySubject.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByRequester.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByType.insertWithTtlParam({...row, ttl_seconds: ttlSeconds} as AdminArchiveRow, 'ttl_seconds'),
|
||||
);
|
||||
await batch.execute();
|
||||
|
||||
Logger.debug(
|
||||
{subjectType: withExpiry.subjectType, subjectId: withExpiry.subjectId, archiveId: withExpiry.archiveId},
|
||||
'Updated admin archive record',
|
||||
);
|
||||
}
|
||||
|
||||
async markAsStarted(archive: AdminArchive, progressStep = 'Starting archive'): Promise<void> {
|
||||
const withExpiry = this.ensureExpiry(archive);
|
||||
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
AdminArchivesBySubject.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
subject_id: withExpiry.subjectId,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
started_at: Db.set(new Date()),
|
||||
progress_percent: Db.set(0),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByRequester.patchByPkWithTtlParam(
|
||||
{
|
||||
requested_by: withExpiry.requestedBy,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
started_at: Db.set(new Date()),
|
||||
progress_percent: Db.set(0),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByType.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
started_at: Db.set(new Date()),
|
||||
progress_percent: Db.set(0),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async updateProgress(archive: AdminArchive, progressPercent: number, progressStep: string): Promise<void> {
|
||||
const withExpiry = this.ensureExpiry(archive);
|
||||
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
AdminArchivesBySubject.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
subject_id: withExpiry.subjectId,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
progress_percent: Db.set(progressPercent),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByRequester.patchByPkWithTtlParam(
|
||||
{
|
||||
requested_by: withExpiry.requestedBy,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
progress_percent: Db.set(progressPercent),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByType.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
progress_percent: Db.set(progressPercent),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
await batch.execute();
|
||||
|
||||
Logger.debug({archiveId: withExpiry.archiveId, progressPercent, progressStep}, 'Updated admin archive progress');
|
||||
}
|
||||
|
||||
async markAsCompleted(
|
||||
archive: AdminArchive,
|
||||
storageKey: string,
|
||||
fileSize: bigint,
|
||||
downloadUrlExpiresAt: Date,
|
||||
): Promise<void> {
|
||||
const withExpiry = this.ensureExpiry(archive);
|
||||
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
AdminArchivesBySubject.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
subject_id: withExpiry.subjectId,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
completed_at: Db.set(new Date()),
|
||||
storage_key: Db.set(storageKey),
|
||||
file_size: Db.set(fileSize),
|
||||
download_url_expires_at: Db.set(downloadUrlExpiresAt),
|
||||
progress_percent: Db.set(100),
|
||||
progress_step: Db.set('Completed'),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByRequester.patchByPkWithTtlParam(
|
||||
{
|
||||
requested_by: withExpiry.requestedBy,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
completed_at: Db.set(new Date()),
|
||||
storage_key: Db.set(storageKey),
|
||||
file_size: Db.set(fileSize),
|
||||
download_url_expires_at: Db.set(downloadUrlExpiresAt),
|
||||
progress_percent: Db.set(100),
|
||||
progress_step: Db.set('Completed'),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByType.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
completed_at: Db.set(new Date()),
|
||||
storage_key: Db.set(storageKey),
|
||||
file_size: Db.set(fileSize),
|
||||
download_url_expires_at: Db.set(downloadUrlExpiresAt),
|
||||
progress_percent: Db.set(100),
|
||||
progress_step: Db.set('Completed'),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async markAsFailed(archive: AdminArchive, errorMessage: string): Promise<void> {
|
||||
const withExpiry = this.ensureExpiry(archive);
|
||||
const ttlSeconds = computeTtlSeconds(withExpiry.expiresAt!);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
AdminArchivesBySubject.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
subject_id: withExpiry.subjectId,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
failed_at: Db.set(new Date()),
|
||||
error_message: Db.set(errorMessage),
|
||||
progress_step: Db.set('Failed'),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByRequester.patchByPkWithTtlParam(
|
||||
{
|
||||
requested_by: withExpiry.requestedBy,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
failed_at: Db.set(new Date()),
|
||||
error_message: Db.set(errorMessage),
|
||||
progress_step: Db.set('Failed'),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AdminArchivesByType.patchByPkWithTtlParam(
|
||||
{
|
||||
subject_type: withExpiry.subjectType,
|
||||
archive_id: withExpiry.archiveId,
|
||||
},
|
||||
{
|
||||
failed_at: Db.set(new Date()),
|
||||
error_message: Db.set(errorMessage),
|
||||
progress_step: Db.set('Failed'),
|
||||
},
|
||||
'ttl_seconds',
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async findBySubjectAndArchiveId(
|
||||
subjectType: ArchiveSubjectType,
|
||||
subjectId: bigint,
|
||||
archiveId: bigint,
|
||||
): Promise<AdminArchive | null> {
|
||||
const query = AdminArchivesBySubject.select({
|
||||
where: [
|
||||
AdminArchivesBySubject.where.eq('subject_type'),
|
||||
AdminArchivesBySubject.where.eq('subject_id'),
|
||||
AdminArchivesBySubject.where.eq('archive_id'),
|
||||
],
|
||||
limit: 1,
|
||||
});
|
||||
const row = await fetchOne<AdminArchiveRow>(
|
||||
query.bind({
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId,
|
||||
archive_id: archiveId,
|
||||
}),
|
||||
);
|
||||
return row ? new AdminArchive(row) : null;
|
||||
}
|
||||
|
||||
async listBySubject(
|
||||
subjectType: ArchiveSubjectType,
|
||||
subjectId: bigint,
|
||||
limit = 20,
|
||||
includeExpired = false,
|
||||
): Promise<Array<AdminArchive>> {
|
||||
const query = AdminArchivesBySubject.select({
|
||||
where: [AdminArchivesBySubject.where.eq('subject_type'), AdminArchivesBySubject.where.eq('subject_id')],
|
||||
limit,
|
||||
});
|
||||
const rows = await fetchMany<AdminArchiveRow>(
|
||||
query.bind({
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId,
|
||||
}),
|
||||
);
|
||||
return filterExpired(rows, includeExpired).map((row) => new AdminArchive(row));
|
||||
}
|
||||
|
||||
async listByType(subjectType: ArchiveSubjectType, limit = 50, includeExpired = false): Promise<Array<AdminArchive>> {
|
||||
const query = AdminArchivesByType.select({
|
||||
where: AdminArchivesByType.where.eq('subject_type'),
|
||||
limit,
|
||||
});
|
||||
const rows = await fetchMany<AdminArchiveRow>(
|
||||
query.bind({
|
||||
subject_type: subjectType,
|
||||
}),
|
||||
);
|
||||
return filterExpired(rows, includeExpired).map((row) => new AdminArchive(row));
|
||||
}
|
||||
|
||||
async listByRequester(requestedBy: bigint, limit = 50, includeExpired = false): Promise<Array<AdminArchive>> {
|
||||
const query = AdminArchivesByRequester.select({
|
||||
where: AdminArchivesByRequester.where.eq('requested_by'),
|
||||
limit,
|
||||
});
|
||||
const rows = await fetchMany<AdminArchiveRow>(
|
||||
query.bind({
|
||||
requested_by: requestedBy,
|
||||
}),
|
||||
);
|
||||
return filterExpired(rows, includeExpired).map((row) => new AdminArchive(row));
|
||||
}
|
||||
}
|
||||
218
fluxer_api/src/admin/services/AdminArchiveService.ts
Normal file
218
fluxer_api/src/admin/services/AdminArchiveService.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* 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 {Config} from '~/Config';
|
||||
import {
|
||||
HarvestExpiredError,
|
||||
HarvestFailedError,
|
||||
HarvestNotReadyError,
|
||||
UnknownGuildError,
|
||||
UnknownHarvestError,
|
||||
UnknownUserError,
|
||||
} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {WorkerService} from '~/worker/WorkerService';
|
||||
import {AdminArchive, type AdminArchiveResponse, type ArchiveSubjectType} from '../models/AdminArchiveModel';
|
||||
import type {AdminArchiveRepository} from '../repositories/AdminArchiveRepository';
|
||||
|
||||
const ARCHIVE_RETENTION_DAYS = 365;
|
||||
const DOWNLOAD_LINK_DAYS = 7;
|
||||
const DOWNLOAD_LINK_SECONDS = DOWNLOAD_LINK_DAYS * 24 * 60 * 60;
|
||||
|
||||
interface ListArchivesParams {
|
||||
subjectType?: ArchiveSubjectType | 'all';
|
||||
subjectId?: bigint;
|
||||
requestedBy?: bigint;
|
||||
limit?: number;
|
||||
includeExpired?: boolean;
|
||||
}
|
||||
|
||||
export class AdminArchiveService {
|
||||
constructor(
|
||||
private readonly adminArchiveRepository: AdminArchiveRepository,
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly guildRepository: IGuildRepository,
|
||||
private readonly storageService: IStorageService,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
private readonly workerService: WorkerService,
|
||||
) {}
|
||||
|
||||
private computeExpiry(): Date {
|
||||
return new Date(Date.now() + ARCHIVE_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async triggerUserArchive(targetUserId: UserID, requestedBy: UserID): Promise<AdminArchiveResponse> {
|
||||
const user = await this.userRepository.findUnique(targetUserId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const archiveId = this.snowflakeService.generate();
|
||||
const archive = new AdminArchive({
|
||||
subject_type: 'user',
|
||||
subject_id: targetUserId,
|
||||
archive_id: archiveId,
|
||||
requested_by: requestedBy,
|
||||
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,
|
||||
expires_at: this.computeExpiry(),
|
||||
});
|
||||
|
||||
await this.adminArchiveRepository.create(archive);
|
||||
|
||||
await this.workerService.addJob('harvestUserData', {
|
||||
userId: targetUserId.toString(),
|
||||
harvestId: archive.archiveId.toString(),
|
||||
adminRequestedBy: requestedBy.toString(),
|
||||
});
|
||||
|
||||
return archive.toResponse();
|
||||
}
|
||||
|
||||
async triggerGuildArchive(targetGuildId: GuildID, requestedBy: UserID): Promise<AdminArchiveResponse> {
|
||||
const guild = await this.guildRepository.findUnique(targetGuildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const archiveId = this.snowflakeService.generate();
|
||||
const archive = new AdminArchive({
|
||||
subject_type: 'guild',
|
||||
subject_id: targetGuildId,
|
||||
archive_id: archiveId,
|
||||
requested_by: requestedBy,
|
||||
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,
|
||||
expires_at: this.computeExpiry(),
|
||||
});
|
||||
|
||||
await this.adminArchiveRepository.create(archive);
|
||||
|
||||
await this.workerService.addJob('harvestGuildData', {
|
||||
guildId: targetGuildId.toString(),
|
||||
archiveId: archive.archiveId.toString(),
|
||||
requestedBy: requestedBy.toString(),
|
||||
});
|
||||
|
||||
return archive.toResponse();
|
||||
}
|
||||
|
||||
async getArchive(
|
||||
subjectType: ArchiveSubjectType,
|
||||
subjectId: bigint,
|
||||
archiveId: bigint,
|
||||
): Promise<AdminArchiveResponse | null> {
|
||||
const archive = await this.adminArchiveRepository.findBySubjectAndArchiveId(subjectType, subjectId, archiveId);
|
||||
return archive ? archive.toResponse() : null;
|
||||
}
|
||||
|
||||
async listArchives(params: ListArchivesParams): Promise<Array<AdminArchiveResponse>> {
|
||||
const {subjectType = 'all', subjectId, requestedBy, limit = 50, includeExpired = false} = params;
|
||||
|
||||
if (subjectId !== undefined && subjectType === 'all') {
|
||||
throw new Error('subject_type must be specified when subject_id is provided');
|
||||
}
|
||||
|
||||
if (subjectId !== undefined) {
|
||||
const archives = await this.adminArchiveRepository.listBySubject(
|
||||
subjectType as ArchiveSubjectType,
|
||||
subjectId,
|
||||
limit,
|
||||
includeExpired,
|
||||
);
|
||||
return archives.map((a) => a.toResponse());
|
||||
}
|
||||
|
||||
if (requestedBy !== undefined) {
|
||||
const archives = await this.adminArchiveRepository.listByRequester(requestedBy, limit, includeExpired);
|
||||
return archives.map((a) => a.toResponse());
|
||||
}
|
||||
|
||||
if (subjectType === 'all') {
|
||||
const [users, guilds] = await Promise.all([
|
||||
this.adminArchiveRepository.listByType('user', limit, includeExpired),
|
||||
this.adminArchiveRepository.listByType('guild', limit, includeExpired),
|
||||
]);
|
||||
|
||||
return [...users, ...guilds]
|
||||
.sort((a, b) => b.requestedAt.getTime() - a.requestedAt.getTime())
|
||||
.slice(0, limit)
|
||||
.map((a) => a.toResponse());
|
||||
}
|
||||
|
||||
const archives = await this.adminArchiveRepository.listByType(
|
||||
subjectType as ArchiveSubjectType,
|
||||
limit,
|
||||
includeExpired,
|
||||
);
|
||||
return archives.map((a) => a.toResponse());
|
||||
}
|
||||
|
||||
async getDownloadUrl(
|
||||
subjectType: ArchiveSubjectType,
|
||||
subjectId: bigint,
|
||||
archiveId: bigint,
|
||||
): Promise<{downloadUrl: string; expiresAt: string}> {
|
||||
const archive = await this.adminArchiveRepository.findBySubjectAndArchiveId(subjectType, subjectId, archiveId);
|
||||
if (!archive) {
|
||||
throw new UnknownHarvestError();
|
||||
}
|
||||
|
||||
if (!archive.completedAt || !archive.storageKey) {
|
||||
throw new HarvestNotReadyError();
|
||||
}
|
||||
|
||||
if (archive.failedAt) {
|
||||
throw new HarvestFailedError();
|
||||
}
|
||||
|
||||
if (archive.expiresAt && archive.expiresAt < new Date()) {
|
||||
throw new HarvestExpiredError();
|
||||
}
|
||||
|
||||
const downloadUrl = await this.storageService.getPresignedDownloadURL({
|
||||
bucket: Config.s3.buckets.harvests,
|
||||
key: archive.storageKey,
|
||||
expiresIn: DOWNLOAD_LINK_SECONDS,
|
||||
});
|
||||
|
||||
const expiresAt = new Date(Date.now() + DOWNLOAD_LINK_SECONDS * 1000);
|
||||
return {downloadUrl, expiresAt: expiresAt.toISOString()};
|
||||
}
|
||||
}
|
||||
197
fluxer_api/src/admin/services/AdminAssetPurgeService.ts
Normal file
197
fluxer_api/src/admin/services/AdminAssetPurgeService.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* 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 {createEmojiID, createStickerID, type GuildID, type UserID} from '~/BrandedTypes';
|
||||
import {mapGuildEmojiToResponse, mapGuildStickerToResponse} from '~/guild/GuildModel';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import {ExpressionAssetPurger} from '~/guild/services/content/ExpressionAssetPurger';
|
||||
import type {IAssetDeletionQueue} from '~/infrastructure/IAssetDeletionQueue';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {PurgeGuildAssetError, PurgeGuildAssetResult, PurgeGuildAssetsResponse} from '../models/AdminTypes';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
|
||||
interface AdminAssetPurgeServiceDeps {
|
||||
guildRepository: IGuildRepository;
|
||||
gatewayService: IGatewayService;
|
||||
assetDeletionQueue: IAssetDeletionQueue;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminAssetPurgeService {
|
||||
private readonly assetPurger: ExpressionAssetPurger;
|
||||
|
||||
constructor(private readonly deps: AdminAssetPurgeServiceDeps) {
|
||||
this.assetPurger = new ExpressionAssetPurger(deps.assetDeletionQueue);
|
||||
}
|
||||
|
||||
async purgeGuildAssets(args: {
|
||||
ids: Array<string>;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}): Promise<PurgeGuildAssetsResponse> {
|
||||
const {ids, adminUserId, auditLogReason} = args;
|
||||
const processed: Array<PurgeGuildAssetResult> = [];
|
||||
const errors: Array<PurgeGuildAssetError> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const rawId of ids) {
|
||||
const trimmedId = rawId.trim();
|
||||
if (trimmedId === '' || seen.has(trimmedId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmedId);
|
||||
|
||||
let numericId: bigint;
|
||||
try {
|
||||
numericId = BigInt(trimmedId);
|
||||
} catch {
|
||||
errors.push({id: trimmedId, error: 'Invalid numeric ID'});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.processAssetId(numericId, trimmedId, adminUserId, auditLogReason);
|
||||
processed.push(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message !== '' ? error.message : 'Failed to purge asset';
|
||||
errors.push({id: trimmedId, error: message});
|
||||
}
|
||||
}
|
||||
|
||||
return {processed, errors};
|
||||
}
|
||||
|
||||
private async processAssetId(
|
||||
numericId: bigint,
|
||||
idString: string,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<PurgeGuildAssetResult> {
|
||||
const {guildRepository} = this.deps;
|
||||
|
||||
const emojiId = createEmojiID(numericId);
|
||||
const emoji = await guildRepository.getEmojiById(emojiId);
|
||||
if (emoji) {
|
||||
await guildRepository.deleteEmoji(emoji.guildId, emojiId);
|
||||
await this.dispatchGuildEmojisUpdate(emoji.guildId);
|
||||
await this.assetPurger.purgeEmoji(idString);
|
||||
await this.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild_emoji',
|
||||
targetId: numericId,
|
||||
action: 'purge_guild_emoji_asset',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['asset_type', 'emoji'],
|
||||
['guild_id', emoji.guildId.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
id: idString,
|
||||
asset_type: 'emoji',
|
||||
found_in_db: true,
|
||||
guild_id: emoji.guildId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
const stickerId = createStickerID(numericId);
|
||||
const sticker = await guildRepository.getStickerById(stickerId);
|
||||
if (sticker) {
|
||||
await guildRepository.deleteSticker(sticker.guildId, stickerId);
|
||||
await this.dispatchGuildStickersUpdate(sticker.guildId);
|
||||
await this.assetPurger.purgeSticker(idString);
|
||||
await this.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild_sticker',
|
||||
targetId: numericId,
|
||||
action: 'purge_guild_sticker_asset',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['asset_type', 'sticker'],
|
||||
['guild_id', sticker.guildId.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
id: idString,
|
||||
asset_type: 'sticker',
|
||||
found_in_db: true,
|
||||
guild_id: sticker.guildId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
await this.assetPurger.purgeEmoji(idString);
|
||||
await this.assetPurger.purgeSticker(idString);
|
||||
await this.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'asset',
|
||||
targetId: numericId,
|
||||
action: 'purge_asset',
|
||||
auditLogReason,
|
||||
metadata: new Map([['asset_type', 'unknown']]),
|
||||
});
|
||||
|
||||
return {
|
||||
id: idString,
|
||||
asset_type: 'unknown',
|
||||
found_in_db: false,
|
||||
guild_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async dispatchGuildEmojisUpdate(guildId: GuildID): Promise<void> {
|
||||
const {guildRepository, gatewayService} = this.deps;
|
||||
const emojis = await guildRepository.listEmojis(guildId);
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_EMOJIS_UPDATE',
|
||||
data: {emojis: emojis.map(mapGuildEmojiToResponse)},
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchGuildStickersUpdate(guildId: GuildID): Promise<void> {
|
||||
const {guildRepository, gatewayService} = this.deps;
|
||||
const stickers = await guildRepository.listStickers(guildId);
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_STICKERS_UPDATE',
|
||||
data: {stickers: stickers.map(mapGuildStickerToResponse)},
|
||||
});
|
||||
}
|
||||
|
||||
private async createAuditLog(params: {
|
||||
adminUserId: UserID;
|
||||
targetType: string;
|
||||
targetId: bigint;
|
||||
action: string;
|
||||
auditLogReason: string | null;
|
||||
metadata: Map<string, string>;
|
||||
}): Promise<void> {
|
||||
const {auditService} = this.deps;
|
||||
await auditService.createAuditLog({
|
||||
adminUserId: params.adminUserId,
|
||||
targetType: params.targetType,
|
||||
targetId: params.targetId,
|
||||
action: params.action,
|
||||
auditLogReason: params.auditLogReason,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
190
fluxer_api/src/admin/services/AdminAuditService.ts
Normal file
190
fluxer_api/src/admin/services/AdminAuditService.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* 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 {UserID} from '~/BrandedTypes';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {AdminAuditLog, IAdminRepository} from '../IAdminRepository';
|
||||
|
||||
interface CreateAdminAuditLogParams {
|
||||
adminUserId: UserID;
|
||||
targetType: string;
|
||||
targetId: bigint;
|
||||
action: string;
|
||||
auditLogReason: string | null;
|
||||
metadata?: Map<string, string>;
|
||||
}
|
||||
|
||||
export class AdminAuditService {
|
||||
constructor(
|
||||
private readonly adminRepository: IAdminRepository,
|
||||
private readonly snowflakeService: SnowflakeService,
|
||||
) {}
|
||||
|
||||
async createAuditLog({
|
||||
adminUserId,
|
||||
targetType,
|
||||
targetId,
|
||||
action,
|
||||
auditLogReason,
|
||||
metadata = new Map(),
|
||||
}: CreateAdminAuditLogParams): Promise<void> {
|
||||
const log = await this.adminRepository.createAuditLog({
|
||||
log_id: this.snowflakeService.generate(),
|
||||
admin_user_id: adminUserId,
|
||||
target_type: targetType,
|
||||
target_id: targetId,
|
||||
action,
|
||||
audit_log_reason: auditLogReason ?? null,
|
||||
metadata,
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const {getAuditLogSearchService} = await import('~/Meilisearch');
|
||||
const auditLogSearchService = getAuditLogSearchService();
|
||||
if (auditLogSearchService) {
|
||||
auditLogSearchService.indexAuditLog(log).catch((error) => {
|
||||
Logger.error({error, logId: log.logId}, 'Failed to index audit log to Meilisearch');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async listAuditLogs(data: {
|
||||
adminUserId?: bigint;
|
||||
targetType?: string;
|
||||
targetId?: bigint;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{logs: Array<AdminAuditLogResponse>; total: number}> {
|
||||
const auditLogSearchService = await this.requireAuditLogSearchService();
|
||||
|
||||
const limit = data.limit || 50;
|
||||
const filters: Record<string, string> = {};
|
||||
|
||||
if (data.adminUserId) {
|
||||
filters.adminUserId = data.adminUserId.toString();
|
||||
}
|
||||
if (data.targetType) {
|
||||
filters.targetType = data.targetType;
|
||||
}
|
||||
if (data.targetId) {
|
||||
filters.targetId = data.targetId.toString();
|
||||
}
|
||||
|
||||
const {hits, total} = await auditLogSearchService.searchAuditLogs('', filters, {
|
||||
limit,
|
||||
offset: data.offset || 0,
|
||||
});
|
||||
|
||||
const orderedLogs = await this.loadLogsInSearchOrder(hits.map((hit) => BigInt(hit.logId)));
|
||||
|
||||
return {
|
||||
logs: orderedLogs.map((log) => this.toResponse(log)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async searchAuditLogs(data: {
|
||||
query?: string;
|
||||
adminUserId?: bigint;
|
||||
targetType?: string;
|
||||
targetId?: bigint;
|
||||
action?: string;
|
||||
sortBy?: 'createdAt' | 'relevance';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{logs: Array<AdminAuditLogResponse>; total: number}> {
|
||||
const auditLogSearchService = await this.requireAuditLogSearchService();
|
||||
|
||||
const filters: Record<string, string> = {};
|
||||
|
||||
if (data.adminUserId) {
|
||||
filters.adminUserId = data.adminUserId.toString();
|
||||
}
|
||||
if (data.targetType) {
|
||||
filters.targetType = data.targetType;
|
||||
}
|
||||
if (data.targetId) {
|
||||
filters.targetId = data.targetId.toString();
|
||||
}
|
||||
if (data.action) {
|
||||
filters.action = data.action;
|
||||
}
|
||||
if (data.sortBy) {
|
||||
filters.sortBy = data.sortBy;
|
||||
}
|
||||
if (data.sortOrder) {
|
||||
filters.sortOrder = data.sortOrder;
|
||||
}
|
||||
|
||||
const {hits, total} = await auditLogSearchService.searchAuditLogs(data.query || '', filters, {
|
||||
limit: data.limit || 50,
|
||||
offset: data.offset || 0,
|
||||
});
|
||||
|
||||
const orderedLogs = await this.loadLogsInSearchOrder(hits.map((hit) => BigInt(hit.logId)));
|
||||
|
||||
return {
|
||||
logs: orderedLogs.map((log) => this.toResponse(log)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
private async requireAuditLogSearchService() {
|
||||
const {getAuditLogSearchService} = await import('~/Meilisearch');
|
||||
const auditLogSearchService = getAuditLogSearchService();
|
||||
|
||||
if (!auditLogSearchService) {
|
||||
throw new Error('Audit log search service not available');
|
||||
}
|
||||
|
||||
return auditLogSearchService;
|
||||
}
|
||||
|
||||
private async loadLogsInSearchOrder(logIds: Array<bigint>): Promise<Array<AdminAuditLog>> {
|
||||
const logs = await this.adminRepository.listAuditLogsByIds(logIds);
|
||||
const logMap = new Map(logs.map((log) => [log.logId.toString(), log]));
|
||||
return logIds.map((logId) => logMap.get(logId.toString())).filter((log): log is AdminAuditLog => log !== undefined);
|
||||
}
|
||||
|
||||
private toResponse(log: AdminAuditLog): AdminAuditLogResponse {
|
||||
return {
|
||||
log_id: log.logId.toString(),
|
||||
admin_user_id: log.adminUserId.toString(),
|
||||
target_type: log.targetType,
|
||||
target_id: log.targetId.toString(),
|
||||
action: log.action,
|
||||
audit_log_reason: log.auditLogReason,
|
||||
metadata: Object.fromEntries(log.metadata),
|
||||
created_at: log.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface AdminAuditLogResponse {
|
||||
log_id: string;
|
||||
admin_user_id: string;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
action: string;
|
||||
audit_log_reason: string | null;
|
||||
metadata: Record<string, string>;
|
||||
created_at: string;
|
||||
}
|
||||
135
fluxer_api/src/admin/services/AdminBanManagementService.ts
Normal file
135
fluxer_api/src/admin/services/AdminBanManagementService.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 {IAdminRepository} from '~/admin/IAdminRepository';
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {ipBanCache} from '~/middleware/IpBanMiddleware';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
|
||||
interface AdminBanManagementServiceDeps {
|
||||
adminRepository: IAdminRepository;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminBanManagementService {
|
||||
constructor(private readonly deps: AdminBanManagementServiceDeps) {}
|
||||
|
||||
async banIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.banIp(data.ip);
|
||||
ipBanCache.ban(data.ip);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'ip',
|
||||
targetId: BigInt(0),
|
||||
action: 'ban_ip',
|
||||
auditLogReason,
|
||||
metadata: new Map([['ip', data.ip]]),
|
||||
});
|
||||
}
|
||||
|
||||
async unbanIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.unbanIp(data.ip);
|
||||
ipBanCache.unban(data.ip);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'ip',
|
||||
targetId: BigInt(0),
|
||||
action: 'unban_ip',
|
||||
auditLogReason,
|
||||
metadata: new Map([['ip', data.ip]]),
|
||||
});
|
||||
}
|
||||
|
||||
async checkIpBan(data: {ip: string}): Promise<{banned: boolean}> {
|
||||
const banned = ipBanCache.isBanned(data.ip);
|
||||
return {banned};
|
||||
}
|
||||
|
||||
async banEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.banEmail(data.email);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'email',
|
||||
targetId: BigInt(0),
|
||||
action: 'ban_email',
|
||||
auditLogReason,
|
||||
metadata: new Map([['email', data.email]]),
|
||||
});
|
||||
}
|
||||
|
||||
async unbanEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.unbanEmail(data.email);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'email',
|
||||
targetId: BigInt(0),
|
||||
action: 'unban_email',
|
||||
auditLogReason,
|
||||
metadata: new Map([['email', data.email]]),
|
||||
});
|
||||
}
|
||||
|
||||
async checkEmailBan(data: {email: string}): Promise<{banned: boolean}> {
|
||||
const {adminRepository} = this.deps;
|
||||
const banned = await adminRepository.isEmailBanned(data.email);
|
||||
return {banned};
|
||||
}
|
||||
|
||||
async banPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.banPhone(data.phone);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'phone',
|
||||
targetId: BigInt(0),
|
||||
action: 'ban_phone',
|
||||
auditLogReason,
|
||||
metadata: new Map([['phone', data.phone]]),
|
||||
});
|
||||
}
|
||||
|
||||
async unbanPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {adminRepository, auditService} = this.deps;
|
||||
await adminRepository.unbanPhone(data.phone);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'phone',
|
||||
targetId: BigInt(0),
|
||||
action: 'unban_phone',
|
||||
auditLogReason,
|
||||
metadata: new Map([['phone', data.phone]]),
|
||||
});
|
||||
}
|
||||
|
||||
async checkPhoneBan(data: {phone: string}): Promise<{banned: boolean}> {
|
||||
const {adminRepository} = this.deps;
|
||||
const banned = await adminRepository.isPhoneBanned(data.phone);
|
||||
return {banned};
|
||||
}
|
||||
}
|
||||
92
fluxer_api/src/admin/services/AdminCodeGenerationService.ts
Normal file
92
fluxer_api/src/admin/services/AdminCodeGenerationService.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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 {createBetaCode} from '~/BrandedTypes';
|
||||
import {SYSTEM_USER_ID} from '~/Constants';
|
||||
import type {GiftCodeRow} from '~/database/CassandraTypes';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import * as RandomUtils from '~/utils/RandomUtils';
|
||||
|
||||
const CODE_LENGTH = 32;
|
||||
|
||||
export class AdminCodeGenerationService {
|
||||
constructor(private readonly userRepository: IUserRepository) {}
|
||||
|
||||
async generateBetaCodes(count: number): Promise<Array<string>> {
|
||||
const codes: Array<string> = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const code = await this.generateUniqueBetaCode();
|
||||
const betaCodeRow = {
|
||||
code: createBetaCode(code),
|
||||
creator_id: SYSTEM_USER_ID,
|
||||
created_at: new Date(),
|
||||
redeemer_id: null,
|
||||
redeemed_at: null,
|
||||
version: 1,
|
||||
};
|
||||
await this.userRepository.upsertBetaCode(betaCodeRow);
|
||||
codes.push(code);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
async generateGiftCodes(count: number, durationMonths: number): Promise<Array<string>> {
|
||||
const codes: Array<string> = [];
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const code = await this.generateUniqueGiftCode();
|
||||
const giftCodeRow: GiftCodeRow = {
|
||||
code,
|
||||
duration_months: durationMonths,
|
||||
created_at: new Date(),
|
||||
created_by_user_id: SYSTEM_USER_ID,
|
||||
redeemed_at: null,
|
||||
redeemed_by_user_id: null,
|
||||
stripe_payment_intent_id: null,
|
||||
visionary_sequence_number: null,
|
||||
checkout_session_id: null,
|
||||
version: 1,
|
||||
};
|
||||
await this.userRepository.createGiftCode(giftCodeRow);
|
||||
codes.push(code);
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
private async generateUniqueBetaCode(): Promise<string> {
|
||||
while (true) {
|
||||
const candidate = RandomUtils.randomString(CODE_LENGTH);
|
||||
const exists = await this.userRepository.getBetaCode(candidate);
|
||||
if (!exists) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async generateUniqueGiftCode(): Promise<string> {
|
||||
while (true) {
|
||||
const candidate = RandomUtils.randomString(CODE_LENGTH);
|
||||
const exists = await this.userRepository.findGiftCode(candidate);
|
||||
if (!exists) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
233
fluxer_api/src/admin/services/AdminGuildService.ts
Normal file
233
fluxer_api/src/admin/services/AdminGuildService.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* 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 {IChannelRepository} from '~/channel/IChannelRepository';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {GuildService} from '~/guild/services/GuildService';
|
||||
import type {EntityAssetService} from '~/infrastructure/EntityAssetService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {InviteRepository} from '~/invite/InviteRepository';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {
|
||||
BulkAddGuildMembersRequest,
|
||||
BulkUpdateGuildFeaturesRequest,
|
||||
ClearGuildFieldsRequest,
|
||||
ForceAddUserToGuildRequest,
|
||||
ListGuildEmojisResponse,
|
||||
ListGuildMembersRequest,
|
||||
ListGuildStickersResponse,
|
||||
ListUserGuildsRequest,
|
||||
LookupGuildRequest,
|
||||
TransferGuildOwnershipRequest,
|
||||
UpdateGuildNameRequest,
|
||||
UpdateGuildSettingsRequest,
|
||||
UpdateGuildVanityRequest,
|
||||
} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import {AdminGuildBulkService} from './guild/AdminGuildBulkService';
|
||||
import {AdminGuildLookupService} from './guild/AdminGuildLookupService';
|
||||
import {AdminGuildManagementService} from './guild/AdminGuildManagementService';
|
||||
import {AdminGuildMembershipService} from './guild/AdminGuildMembershipService';
|
||||
import {AdminGuildUpdatePropagator} from './guild/AdminGuildUpdatePropagator';
|
||||
import {AdminGuildUpdateService} from './guild/AdminGuildUpdateService';
|
||||
import {AdminGuildVanityService} from './guild/AdminGuildVanityService';
|
||||
|
||||
interface AdminGuildServiceDeps {
|
||||
guildRepository: IGuildRepository;
|
||||
userRepository: IUserRepository;
|
||||
channelRepository: IChannelRepository;
|
||||
inviteRepository: InviteRepository;
|
||||
guildService: GuildService;
|
||||
gatewayService: IGatewayService;
|
||||
entityAssetService: EntityAssetService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminGuildService {
|
||||
private readonly lookupService: AdminGuildLookupService;
|
||||
private readonly updateService: AdminGuildUpdateService;
|
||||
private readonly vanityService: AdminGuildVanityService;
|
||||
private readonly membershipService: AdminGuildMembershipService;
|
||||
private readonly bulkService: AdminGuildBulkService;
|
||||
private readonly managementService: AdminGuildManagementService;
|
||||
private readonly updatePropagator: AdminGuildUpdatePropagator;
|
||||
|
||||
constructor(deps: AdminGuildServiceDeps) {
|
||||
this.updatePropagator = new AdminGuildUpdatePropagator({
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
|
||||
this.lookupService = new AdminGuildLookupService({
|
||||
guildRepository: deps.guildRepository,
|
||||
userRepository: deps.userRepository,
|
||||
channelRepository: deps.channelRepository,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
|
||||
this.updateService = new AdminGuildUpdateService({
|
||||
guildRepository: deps.guildRepository,
|
||||
entityAssetService: deps.entityAssetService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
});
|
||||
|
||||
this.vanityService = new AdminGuildVanityService({
|
||||
guildRepository: deps.guildRepository,
|
||||
inviteRepository: deps.inviteRepository,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
});
|
||||
|
||||
this.membershipService = new AdminGuildMembershipService({
|
||||
userRepository: deps.userRepository,
|
||||
guildService: deps.guildService,
|
||||
auditService: deps.auditService,
|
||||
});
|
||||
|
||||
this.bulkService = new AdminGuildBulkService({
|
||||
guildUpdateService: this.updateService,
|
||||
auditService: deps.auditService,
|
||||
});
|
||||
|
||||
this.managementService = new AdminGuildManagementService({
|
||||
guildRepository: deps.guildRepository,
|
||||
gatewayService: deps.gatewayService,
|
||||
guildService: deps.guildService,
|
||||
auditService: deps.auditService,
|
||||
});
|
||||
}
|
||||
|
||||
async lookupGuild(data: LookupGuildRequest) {
|
||||
return this.lookupService.lookupGuild(data);
|
||||
}
|
||||
|
||||
async listUserGuilds(data: ListUserGuildsRequest) {
|
||||
return this.lookupService.listUserGuilds(data);
|
||||
}
|
||||
|
||||
async listGuildMembers(data: ListGuildMembersRequest) {
|
||||
return this.lookupService.listGuildMembers(data);
|
||||
}
|
||||
|
||||
async listGuildEmojis(guildId: GuildID): Promise<ListGuildEmojisResponse> {
|
||||
return this.lookupService.listGuildEmojis(guildId);
|
||||
}
|
||||
|
||||
async listGuildStickers(guildId: GuildID): Promise<ListGuildStickersResponse> {
|
||||
return this.lookupService.listGuildStickers(guildId);
|
||||
}
|
||||
|
||||
async updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures,
|
||||
removeFeatures,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
guildId: GuildID;
|
||||
addFeatures: Array<string>;
|
||||
removeFeatures: Array<string>;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
return this.updateService.updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures,
|
||||
removeFeatures,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
});
|
||||
}
|
||||
|
||||
async clearGuildFields(data: ClearGuildFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.updateService.clearGuildFields(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildName(data: UpdateGuildNameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.updateService.updateGuildName(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildSettings(data: UpdateGuildSettingsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.updateService.updateGuildSettings(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async transferGuildOwnership(
|
||||
data: TransferGuildOwnershipRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.updateService.transferGuildOwnership(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateGuildVanity(data: UpdateGuildVanityRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.vanityService.updateGuildVanity(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async forceAddUserToGuild({
|
||||
data,
|
||||
requestCache,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
data: ForceAddUserToGuildRequest;
|
||||
requestCache: RequestCache;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
return this.membershipService.forceAddUserToGuild({data, requestCache, adminUserId, auditLogReason});
|
||||
}
|
||||
|
||||
async bulkAddGuildMembers(data: BulkAddGuildMembersRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.membershipService.bulkAddGuildMembers(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkUpdateGuildFeatures(
|
||||
data: BulkUpdateGuildFeaturesRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.bulkService.bulkUpdateGuildFeatures(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async reloadGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.managementService.reloadGuild(guildIdRaw, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async shutdownGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.managementService.shutdownGuild(guildIdRaw, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async deleteGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.managementService.deleteGuild(guildIdRaw, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async getGuildMemoryStats(limit: number) {
|
||||
return this.managementService.getGuildMemoryStats(limit);
|
||||
}
|
||||
|
||||
async reloadAllGuilds(guildIds: Array<GuildID>) {
|
||||
return this.managementService.reloadAllGuilds(guildIds);
|
||||
}
|
||||
|
||||
async getNodeStats() {
|
||||
return this.managementService.getNodeStats();
|
||||
}
|
||||
}
|
||||
141
fluxer_api/src/admin/services/AdminMessageDeletionService.ts
Normal file
141
fluxer_api/src/admin/services/AdminMessageDeletionService.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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 {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import {createUserID} from '~/BrandedTypes';
|
||||
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {DeleteAllUserMessagesRequest, DeleteAllUserMessagesResponse} from '../models/MessageTypes';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminMessageShredService} from './AdminMessageShredService';
|
||||
|
||||
interface AdminMessageDeletionServiceDeps {
|
||||
channelRepository: IChannelRepository;
|
||||
messageShredService: AdminMessageShredService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
const FETCH_CHUNK_SIZE = 200;
|
||||
|
||||
export class AdminMessageDeletionService {
|
||||
constructor(private readonly deps: AdminMessageDeletionServiceDeps) {}
|
||||
|
||||
async deleteAllUserMessages(
|
||||
data: DeleteAllUserMessagesRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<DeleteAllUserMessagesResponse> {
|
||||
const authorId = createUserID(data.user_id);
|
||||
|
||||
const {entries, channelCount, messageCount} = await this.collectMessageRefs(authorId, !data.dry_run);
|
||||
|
||||
const metadata = new Map<string, string>([
|
||||
['user_id', data.user_id.toString()],
|
||||
['channel_count', channelCount.toString()],
|
||||
['message_count', messageCount.toString()],
|
||||
['dry_run', data.dry_run ? 'true' : 'false'],
|
||||
]);
|
||||
|
||||
const action = data.dry_run ? 'delete_all_user_messages_dry_run' : 'delete_all_user_messages';
|
||||
|
||||
await this.deps.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'message_deletion',
|
||||
targetId: data.user_id,
|
||||
action,
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
|
||||
Logger.debug(
|
||||
{user_id: data.user_id, channel_count: channelCount, message_count: messageCount, dry_run: data.dry_run},
|
||||
'Computed delete-all-messages stats',
|
||||
);
|
||||
|
||||
const response: DeleteAllUserMessagesResponse = {
|
||||
success: true,
|
||||
dry_run: data.dry_run,
|
||||
channel_count: channelCount,
|
||||
message_count: messageCount,
|
||||
};
|
||||
|
||||
if (data.dry_run || messageCount === 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const shredResult = await this.deps.messageShredService.queueMessageShred(
|
||||
{
|
||||
user_id: data.user_id,
|
||||
entries,
|
||||
},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
|
||||
response.job_id = shredResult.job_id;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async collectMessageRefs(authorId: UserID, includeEntries: boolean) {
|
||||
let lastChannelId: ChannelID | undefined;
|
||||
let lastMessageId: MessageID | undefined;
|
||||
const entries: Array<{channel_id: ChannelID; message_id: MessageID}> = [];
|
||||
const channels = new Set<string>();
|
||||
let messageCount = 0;
|
||||
|
||||
while (true) {
|
||||
const messageRefs = await this.deps.channelRepository.listMessagesByAuthor(
|
||||
authorId,
|
||||
FETCH_CHUNK_SIZE,
|
||||
lastChannelId,
|
||||
lastMessageId,
|
||||
);
|
||||
|
||||
if (messageRefs.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const {channelId, messageId} of messageRefs) {
|
||||
channels.add(channelId.toString());
|
||||
messageCount += 1;
|
||||
|
||||
if (includeEntries) {
|
||||
entries.push({
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
lastChannelId = messageRefs[messageRefs.length - 1].channelId;
|
||||
lastMessageId = messageRefs[messageRefs.length - 1].messageId;
|
||||
|
||||
if (messageRefs.length < FETCH_CHUNK_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
channelCount: channels.size,
|
||||
messageCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
204
fluxer_api/src/admin/services/AdminMessageService.ts
Normal file
204
fluxer_api/src/admin/services/AdminMessageService.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
type AttachmentID,
|
||||
type ChannelID,
|
||||
createChannelID,
|
||||
createMessageID,
|
||||
createUserID,
|
||||
type MessageID,
|
||||
type UserID,
|
||||
} from '~/BrandedTypes';
|
||||
import {type MessageResponse, mapMessageToResponse} from '~/channel/ChannelModel';
|
||||
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '~/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {DeleteMessageRequest, LookupMessageByAttachmentRequest, LookupMessageRequest} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
|
||||
interface AdminMessageServiceDeps {
|
||||
channelRepository: IChannelRepository;
|
||||
userCacheService: UserCacheService;
|
||||
mediaService: IMediaService;
|
||||
gatewayService: IGatewayService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminMessageService {
|
||||
constructor(private readonly deps: AdminMessageServiceDeps) {}
|
||||
|
||||
async lookupAttachment({
|
||||
channelId,
|
||||
attachmentId,
|
||||
filename,
|
||||
}: {
|
||||
channelId: ChannelID;
|
||||
attachmentId: AttachmentID;
|
||||
filename: string;
|
||||
}): Promise<{message_id: MessageID | null}> {
|
||||
const {channelRepository} = this.deps;
|
||||
const messageId = await channelRepository.lookupAttachmentByChannelAndFilename(channelId, attachmentId, filename);
|
||||
return {
|
||||
message_id: messageId,
|
||||
};
|
||||
}
|
||||
|
||||
async lookupMessage(data: LookupMessageRequest) {
|
||||
const {channelRepository, userCacheService, mediaService} = this.deps;
|
||||
const channelId = createChannelID(data.channel_id);
|
||||
const messageId = createMessageID(data.message_id);
|
||||
const contextPerSide = Math.floor(data.context_limit / 2);
|
||||
|
||||
const [targetMessage, messagesBefore, messagesAfter] = await Promise.all([
|
||||
channelRepository.getMessage(channelId, messageId),
|
||||
channelRepository.listMessages(channelId, messageId, contextPerSide),
|
||||
channelRepository.listMessages(channelId, undefined, contextPerSide, messageId),
|
||||
]);
|
||||
|
||||
const allMessages = [...messagesBefore.reverse(), ...(targetMessage ? [targetMessage] : []), ...messagesAfter];
|
||||
|
||||
const requestCache: RequestCache = {
|
||||
userPartials: new Map(),
|
||||
clear: () => {},
|
||||
};
|
||||
|
||||
const messageResponses = await Promise.all(
|
||||
allMessages.map((message) =>
|
||||
mapMessageToResponse({
|
||||
message,
|
||||
currentUserId: undefined,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
mediaService,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
messages: messageResponses.map((message) => this.mapMessageResponseToAdminMessage(message)),
|
||||
message_id: messageId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async lookupMessageByAttachment(data: LookupMessageByAttachmentRequest) {
|
||||
const channelId = createChannelID(data.channel_id);
|
||||
const attachmentId = data.attachment_id as AttachmentID;
|
||||
|
||||
const messageId = await this.deps.channelRepository.lookupAttachmentByChannelAndFilename(
|
||||
channelId,
|
||||
attachmentId,
|
||||
data.filename,
|
||||
);
|
||||
|
||||
if (!messageId) {
|
||||
return {
|
||||
messages: [],
|
||||
message_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.lookupMessage({
|
||||
channel_id: data.channel_id,
|
||||
message_id: BigInt(messageId),
|
||||
context_limit: data.context_limit,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: result.messages,
|
||||
message_id: messageId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteMessage(data: DeleteMessageRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {channelRepository, gatewayService, auditService} = this.deps;
|
||||
const channelId = createChannelID(data.channel_id);
|
||||
const messageId = createMessageID(data.message_id);
|
||||
|
||||
const channel = await channelRepository.findUnique(channelId);
|
||||
const message = await channelRepository.getMessage(channelId, messageId);
|
||||
|
||||
if (message) {
|
||||
await channelRepository.deleteMessage(
|
||||
channelId,
|
||||
messageId,
|
||||
message.authorId || createUserID(0n),
|
||||
message.pinnedTimestamp || undefined,
|
||||
);
|
||||
|
||||
if (channel) {
|
||||
if (channel.guildId) {
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId: channel.guildId,
|
||||
event: 'MESSAGE_DELETE',
|
||||
data: {
|
||||
channel_id: channelId.toString(),
|
||||
id: messageId.toString(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
for (const recipientId of channel.recipientIds) {
|
||||
await gatewayService.dispatchPresence({
|
||||
userId: recipientId,
|
||||
event: 'MESSAGE_DELETE',
|
||||
data: {
|
||||
channel_id: channelId.toString(),
|
||||
id: messageId.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'message',
|
||||
targetId: BigInt(messageId),
|
||||
action: 'delete_message',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['channel_id', channelId.toString()],
|
||||
['message_id', messageId.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private mapMessageResponseToAdminMessage(message: MessageResponse) {
|
||||
return {
|
||||
id: message.id,
|
||||
channel_id: message.channel_id ?? '',
|
||||
author_id: message.author.id,
|
||||
author_username: message.author.username,
|
||||
content: message.content ?? '',
|
||||
timestamp: message.timestamp,
|
||||
attachments:
|
||||
message.attachments?.map((attachment) => ({
|
||||
filename: attachment.filename,
|
||||
url: attachment.url,
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
125
fluxer_api/src/admin/services/AdminMessageShredService.ts
Normal file
125
fluxer_api/src/admin/services/AdminMessageShredService.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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 {UserID} from '~/BrandedTypes';
|
||||
import {InputValidationError} from '~/errors/InputValidationError';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {IWorkerService} from '~/worker/IWorkerService';
|
||||
import type {MessageShredRequest} from '../models/MessageTypes';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
|
||||
export type MessageShredStatusCacheEntry = {
|
||||
status: 'in_progress' | 'completed' | 'failed';
|
||||
requested: number;
|
||||
total: number;
|
||||
processed: number;
|
||||
skipped: number;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
failed_at?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type MessageShredStatusResult =
|
||||
| MessageShredStatusCacheEntry
|
||||
| {
|
||||
status: 'not_found';
|
||||
};
|
||||
|
||||
interface AdminMessageShredServiceDeps {
|
||||
workerService: IWorkerService;
|
||||
cacheService: ICacheService;
|
||||
snowflakeService: SnowflakeService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
interface QueueMessageShredJobPayload {
|
||||
job_id: string;
|
||||
admin_user_id: string;
|
||||
target_user_id: string;
|
||||
entries: Array<{channel_id: string; message_id: string}>;
|
||||
}
|
||||
|
||||
export class AdminMessageShredService {
|
||||
constructor(private readonly deps: AdminMessageShredServiceDeps) {}
|
||||
|
||||
async queueMessageShred(
|
||||
data: MessageShredRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<{success: true; job_id: string; requested: number}> {
|
||||
if (data.entries.length === 0) {
|
||||
throw InputValidationError.create('entries', 'At least one entry is required');
|
||||
}
|
||||
|
||||
const jobId = this.deps.snowflakeService.generate().toString();
|
||||
const payload: QueueMessageShredJobPayload = {
|
||||
job_id: jobId,
|
||||
admin_user_id: adminUserId.toString(),
|
||||
target_user_id: data.user_id.toString(),
|
||||
entries: data.entries.map((entry) => ({
|
||||
channel_id: entry.channel_id.toString(),
|
||||
message_id: entry.message_id.toString(),
|
||||
})),
|
||||
};
|
||||
|
||||
await this.deps.workerService.addJob('messageShred', payload, {
|
||||
jobKey: `message_shred_${data.user_id.toString()}_${jobId}`,
|
||||
maxAttempts: 1,
|
||||
});
|
||||
|
||||
Logger.debug({target_user_id: data.user_id, job_id: jobId}, 'Queued message shred job');
|
||||
|
||||
const metadata = new Map<string, string>([
|
||||
['user_id', data.user_id.toString()],
|
||||
['job_id', jobId],
|
||||
['requested_entries', data.entries.length.toString()],
|
||||
]);
|
||||
|
||||
await this.deps.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'message_shred',
|
||||
targetId: data.user_id,
|
||||
action: 'queue_message_shred',
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
job_id: jobId,
|
||||
requested: data.entries.length,
|
||||
};
|
||||
}
|
||||
|
||||
async getMessageShredStatus(jobId: string): Promise<MessageShredStatusResult> {
|
||||
const statusKey = `message_shred_status:${jobId}`;
|
||||
const status = await this.deps.cacheService.get<MessageShredStatusCacheEntry>(statusKey);
|
||||
|
||||
if (!status) {
|
||||
return {
|
||||
status: 'not_found',
|
||||
};
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
301
fluxer_api/src/admin/services/AdminReportService.ts
Normal file
301
fluxer_api/src/admin/services/AdminReportService.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* 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 {ChannelID, ReportID, UserID} from '~/BrandedTypes';
|
||||
import {createReportID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {makeAttachmentCdnKey} from '~/channel/services/message/MessageHelpers';
|
||||
import type {MessageAttachment} from '~/database/types/MessageTypes';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IStorageService} from '~/infrastructure/IStorageService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import {Logger} from '~/Logger';
|
||||
import {getReportSearchService} from '~/Meilisearch';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {createRequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IARMessageContext, IARSubmission} from '~/report/IReportRepository';
|
||||
import type {ReportService} from '~/report/ReportService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {SearchReportsRequest} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
|
||||
interface AdminReportServiceDeps {
|
||||
reportService: ReportService;
|
||||
userRepository: IUserRepository;
|
||||
emailService: IEmailService;
|
||||
storageService: IStorageService;
|
||||
auditService: AdminAuditService;
|
||||
userCacheService: UserCacheService;
|
||||
}
|
||||
|
||||
export class AdminReportService {
|
||||
constructor(private readonly deps: AdminReportServiceDeps) {}
|
||||
|
||||
async listReports(status: number, limit?: number, offset?: number) {
|
||||
const {reportService} = this.deps;
|
||||
const requestedLimit = limit || 50;
|
||||
const currentOffset = offset || 0;
|
||||
|
||||
const reports = await reportService.listReportsByStatus(status, requestedLimit, currentOffset);
|
||||
const requestCache = createRequestCache();
|
||||
const reportResponses = await Promise.all(
|
||||
reports.map((report: IARSubmission) => this.mapReportToResponse(report, false, requestCache)),
|
||||
);
|
||||
|
||||
return {
|
||||
reports: reportResponses,
|
||||
};
|
||||
}
|
||||
|
||||
async getReport(reportId: ReportID) {
|
||||
const {reportService} = this.deps;
|
||||
const report = await reportService.getReport(reportId);
|
||||
const requestCache = createRequestCache();
|
||||
return this.mapReportToResponse(report, true, requestCache);
|
||||
}
|
||||
|
||||
async resolveReport(
|
||||
reportId: ReportID,
|
||||
adminUserId: UserID,
|
||||
publicComment: string | null,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {reportService, userRepository, emailService, auditService} = this.deps;
|
||||
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'report',
|
||||
targetId: BigInt(reportId),
|
||||
action: 'resolve_report',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['report_id', reportId.toString()],
|
||||
['report_type', resolvedReport.reportType.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
if (resolvedReport.reporterId && publicComment) {
|
||||
const reporter = await userRepository.findUnique(resolvedReport.reporterId);
|
||||
if (reporter?.email) {
|
||||
await emailService.sendReportResolvedEmail(
|
||||
reporter.email,
|
||||
reporter.username,
|
||||
reportId.toString(),
|
||||
publicComment,
|
||||
reporter.locale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
report_id: resolvedReport.reportId.toString(),
|
||||
status: resolvedReport.status,
|
||||
resolved_at: resolvedReport.resolvedAt?.toISOString() ?? null,
|
||||
public_comment: resolvedReport.publicComment,
|
||||
};
|
||||
}
|
||||
|
||||
async searchReports(data: SearchReportsRequest) {
|
||||
const reportSearchService = getReportSearchService();
|
||||
if (!reportSearchService) {
|
||||
throw new Error('Search is not enabled');
|
||||
}
|
||||
|
||||
const filters: Record<string, string | number> = {};
|
||||
if (data.reporter_id !== undefined) {
|
||||
filters.reporterId = data.reporter_id.toString();
|
||||
}
|
||||
if (data.status !== undefined) {
|
||||
filters.status = data.status;
|
||||
}
|
||||
if (data.report_type !== undefined) {
|
||||
filters.reportType = data.report_type;
|
||||
}
|
||||
if (data.category !== undefined) {
|
||||
filters.category = data.category;
|
||||
}
|
||||
if (data.reported_user_id !== undefined) {
|
||||
filters.reportedUserId = data.reported_user_id.toString();
|
||||
}
|
||||
if (data.reported_guild_id !== undefined) {
|
||||
filters.reportedGuildId = data.reported_guild_id.toString();
|
||||
}
|
||||
if (data.reported_channel_id !== undefined) {
|
||||
filters.reportedChannelId = data.reported_channel_id.toString();
|
||||
}
|
||||
if (data.guild_context_id !== undefined) {
|
||||
filters.guildContextId = data.guild_context_id.toString();
|
||||
}
|
||||
if (data.resolved_by_admin_id !== undefined) {
|
||||
filters.resolvedByAdminId = data.resolved_by_admin_id.toString();
|
||||
}
|
||||
if (data.sort_by) {
|
||||
filters.sortBy = data.sort_by;
|
||||
}
|
||||
if (data.sort_order) {
|
||||
filters.sortOrder = data.sort_order;
|
||||
}
|
||||
|
||||
const {hits, total} = await reportSearchService.searchReports(data.query || '', filters, {
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
});
|
||||
|
||||
const requestCache = createRequestCache();
|
||||
const reports = await Promise.all(
|
||||
hits.map(async (hit) => {
|
||||
const report = await this.deps.reportService.getReport(createReportID(BigInt(hit.id)));
|
||||
return this.mapReportToResponse(report, false, requestCache);
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
reports,
|
||||
total,
|
||||
offset: data.offset,
|
||||
limit: data.limit,
|
||||
};
|
||||
}
|
||||
|
||||
private async mapReportToResponse(report: IARSubmission, includeContext: boolean, requestCache: RequestCache) {
|
||||
const reporterInfo = await this.buildUserTag(report.reporterId, requestCache);
|
||||
const reportedUserInfo = await this.buildUserTag(report.reportedUserId, requestCache);
|
||||
|
||||
const baseResponse = {
|
||||
report_id: report.reportId.toString(),
|
||||
reporter_id: report.reporterId?.toString() ?? null,
|
||||
reporter_tag: reporterInfo?.tag ?? null,
|
||||
reporter_username: reporterInfo?.username ?? null,
|
||||
reporter_discriminator: reporterInfo?.discriminator ?? null,
|
||||
reporter_email: report.reporterEmail,
|
||||
reporter_full_legal_name: report.reporterFullLegalName,
|
||||
reporter_country_of_residence: report.reporterCountryOfResidence,
|
||||
reported_at: report.reportedAt.toISOString(),
|
||||
status: report.status,
|
||||
report_type: report.reportType,
|
||||
category: report.category,
|
||||
additional_info: report.additionalInfo,
|
||||
reported_user_id: report.reportedUserId?.toString() ?? null,
|
||||
reported_user_tag: reportedUserInfo?.tag ?? null,
|
||||
reported_user_username: reportedUserInfo?.username ?? null,
|
||||
reported_user_discriminator: reportedUserInfo?.discriminator ?? null,
|
||||
reported_user_avatar_hash: report.reportedUserAvatarHash,
|
||||
reported_guild_id: report.reportedGuildId?.toString() ?? null,
|
||||
reported_guild_name: report.reportedGuildName,
|
||||
reported_message_id: report.reportedMessageId?.toString() ?? null,
|
||||
reported_channel_id: report.reportedChannelId?.toString() ?? null,
|
||||
reported_channel_name: report.reportedChannelName,
|
||||
reported_guild_invite_code: report.reportedGuildInviteCode,
|
||||
resolved_at: report.resolvedAt?.toISOString() ?? null,
|
||||
resolved_by_admin_id: report.resolvedByAdminId?.toString() ?? null,
|
||||
public_comment: report.publicComment,
|
||||
};
|
||||
|
||||
if (!includeContext) {
|
||||
return baseResponse;
|
||||
}
|
||||
|
||||
const messageContext =
|
||||
report.messageContext && report.messageContext.length > 0
|
||||
? await Promise.all(
|
||||
report.messageContext.map((message) =>
|
||||
this.mapReportMessageContextToResponse(message, report.reportedChannelId ?? null),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
...baseResponse,
|
||||
message_context: messageContext,
|
||||
};
|
||||
}
|
||||
|
||||
private async mapReportMessageContextToResponse(message: IARMessageContext, fallbackChannelId: ChannelID | null) {
|
||||
const channelId = message.channelId ?? fallbackChannelId;
|
||||
const attachments =
|
||||
message.attachments && message.attachments.length > 0
|
||||
? (
|
||||
await Promise.all(
|
||||
message.attachments.map((attachment) => this.mapReportAttachmentToResponse(attachment, channelId)),
|
||||
)
|
||||
).filter((attachment): attachment is {filename: string; url: string} => attachment !== null)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: message.messageId.toString(),
|
||||
channel_id: channelId ? channelId.toString() : '',
|
||||
content: message.content ?? '',
|
||||
timestamp: message.timestamp.toISOString(),
|
||||
attachments,
|
||||
author_id: message.authorId.toString(),
|
||||
author_username: message.authorUsername,
|
||||
};
|
||||
}
|
||||
|
||||
private async mapReportAttachmentToResponse(
|
||||
attachment: MessageAttachment,
|
||||
channelId: ChannelID | null,
|
||||
): Promise<{filename: string; url: string} | null> {
|
||||
if (!attachment || attachment.attachment_id == null || !attachment.filename || !channelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {storageService} = this.deps;
|
||||
const attachmentId = attachment.attachment_id;
|
||||
const filename = String(attachment.filename);
|
||||
const key = makeAttachmentCdnKey(channelId, attachmentId, filename);
|
||||
|
||||
try {
|
||||
const url = await storageService.getPresignedDownloadURL({
|
||||
bucket: Config.s3.buckets.reports,
|
||||
key,
|
||||
expiresIn: 300,
|
||||
});
|
||||
return {filename, url};
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, attachmentId, filename, channelId},
|
||||
'Failed to generate presigned URL for report attachment',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async buildUserTag(userId: UserID | null, requestCache: RequestCache): Promise<UserTagInfo | null> {
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
|
||||
const discriminator = user.discriminator?.padStart(4, '0') ?? '0000';
|
||||
return {tag: `${user.username}#${discriminator}`, username: user.username, discriminator};
|
||||
} catch (error) {
|
||||
Logger.warn({userId: userId.toString(), error}, 'Failed to resolve user tag for report');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface UserTagInfo {
|
||||
tag: string;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
}
|
||||
207
fluxer_api/src/admin/services/AdminSearchService.ts
Normal file
207
fluxer_api/src/admin/services/AdminSearchService.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* 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 {mapGuildToAdminResponse, mapUserToAdminResponse} from '~/admin/AdminModel';
|
||||
import {createGuildID, createUserID, type UserID} from '~/BrandedTypes';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import {Logger} from '~/Logger';
|
||||
import {getGuildSearchService, getUserSearchService} from '~/Meilisearch';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {IWorkerService} from '~/worker/IWorkerService';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
|
||||
interface RefreshSearchIndexJobPayload {
|
||||
index_type: 'guilds' | 'users' | 'reports' | 'audit_logs' | 'channel_messages' | 'favorite_memes';
|
||||
admin_user_id: string;
|
||||
audit_log_reason: string | null;
|
||||
job_id: string;
|
||||
guild_id?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
interface AdminSearchServiceDeps {
|
||||
guildRepository: IGuildRepository;
|
||||
userRepository: IUserRepository;
|
||||
workerService: IWorkerService;
|
||||
cacheService: ICacheService;
|
||||
snowflakeService: SnowflakeService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminSearchService {
|
||||
constructor(private readonly deps: AdminSearchServiceDeps) {}
|
||||
|
||||
async searchGuilds(data: {query?: string; limit: number; offset: number}) {
|
||||
const {guildRepository} = this.deps;
|
||||
Logger.debug(
|
||||
{query: data.query, limit: data.limit, offset: data.offset},
|
||||
'[AdminSearchService] searchGuilds called',
|
||||
);
|
||||
|
||||
const guildSearchService = getGuildSearchService();
|
||||
if (!guildSearchService) {
|
||||
Logger.error('[AdminSearchService] searchGuilds - Search service not enabled');
|
||||
throw new Error('Search is not enabled');
|
||||
}
|
||||
|
||||
Logger.debug('[AdminSearchService] searchGuilds - Calling Meilisearch');
|
||||
const {hits, total} = await guildSearchService.searchGuilds(
|
||||
data.query || '',
|
||||
{},
|
||||
{
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
},
|
||||
);
|
||||
|
||||
const guildIds = hits.map((hit) => createGuildID(BigInt(hit.id)));
|
||||
Logger.debug(
|
||||
{guild_ids: guildIds.map((id) => id.toString())},
|
||||
'[AdminSearchService] searchGuilds - Fetching from DB',
|
||||
);
|
||||
|
||||
const guilds = await guildRepository.listGuilds(guildIds);
|
||||
Logger.debug({guilds_count: guilds.length}, '[AdminSearchService] searchGuilds - Got guilds from DB');
|
||||
|
||||
const response = guilds.map((guild) => mapGuildToAdminResponse(guild));
|
||||
Logger.debug({response_count: response.length}, '[AdminSearchService] searchGuilds - Mapped to response');
|
||||
|
||||
return {
|
||||
guilds: response,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async searchUsers(data: {query?: string; limit: number; offset: number}) {
|
||||
const {userRepository} = this.deps;
|
||||
const userSearchService = getUserSearchService();
|
||||
if (!userSearchService) {
|
||||
throw new Error('Search is not enabled');
|
||||
}
|
||||
|
||||
const {hits, total} = await userSearchService.searchUsers(
|
||||
data.query || '',
|
||||
{},
|
||||
{
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
},
|
||||
);
|
||||
|
||||
const userIds = hits.map((hit) => createUserID(BigInt(hit.id)));
|
||||
const users = await userRepository.listUsers(userIds);
|
||||
|
||||
const {cacheService} = this.deps;
|
||||
return {
|
||||
users: await Promise.all(users.map((user) => mapUserToAdminResponse(user, cacheService))),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshSearchIndex(
|
||||
data: {
|
||||
index_type: 'guilds' | 'users' | 'reports' | 'audit_logs' | 'channel_messages' | 'favorite_memes';
|
||||
guild_id?: bigint;
|
||||
user_id?: bigint;
|
||||
},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {workerService, snowflakeService, auditService} = this.deps;
|
||||
const jobId = snowflakeService.generate().toString();
|
||||
|
||||
const payload: RefreshSearchIndexJobPayload = {
|
||||
index_type: data.index_type,
|
||||
admin_user_id: adminUserId.toString(),
|
||||
audit_log_reason: auditLogReason,
|
||||
job_id: jobId,
|
||||
};
|
||||
|
||||
if (data.index_type === 'channel_messages') {
|
||||
if (!data.guild_id) {
|
||||
throw new Error('guild_id is required for the channel_messages index type');
|
||||
}
|
||||
payload.guild_id = data.guild_id.toString();
|
||||
}
|
||||
|
||||
if (data.index_type === 'favorite_memes') {
|
||||
if (!data.user_id) {
|
||||
throw new Error('user_id is required for favorite_memes index type');
|
||||
}
|
||||
payload.user_id = data.user_id.toString();
|
||||
}
|
||||
|
||||
await workerService.addJob('refreshSearchIndex', payload, {
|
||||
jobKey: `refreshSearchIndex_${data.index_type}_${jobId}`,
|
||||
maxAttempts: 1,
|
||||
});
|
||||
|
||||
Logger.debug({index_type: data.index_type, job_id: jobId}, 'Queued search index refresh job');
|
||||
|
||||
const metadata = new Map([
|
||||
['index_type', data.index_type],
|
||||
['job_id', jobId],
|
||||
]);
|
||||
if (data.guild_id) {
|
||||
metadata.set('guild_id', data.guild_id.toString());
|
||||
}
|
||||
if (data.user_id) {
|
||||
metadata.set('user_id', data.user_id.toString());
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'search_index',
|
||||
targetId: BigInt(0),
|
||||
action: 'queue_refresh_index',
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
job_id: jobId,
|
||||
};
|
||||
}
|
||||
|
||||
async getIndexRefreshStatus(jobId: string) {
|
||||
const {cacheService} = this.deps;
|
||||
const statusKey = `index_refresh_status:${jobId}`;
|
||||
const status = await cacheService.get<{
|
||||
status: 'in_progress' | 'completed' | 'failed';
|
||||
index_type: string;
|
||||
total?: number;
|
||||
indexed?: number;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
failed_at?: string;
|
||||
error?: string;
|
||||
}>(statusKey);
|
||||
|
||||
if (!status) {
|
||||
return {
|
||||
status: 'not_found' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
117
fluxer_api/src/admin/services/AdminUserBanService.ts
Normal file
117
fluxer_api/src/admin/services/AdminUserBanService.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 {AuthService} from '~/auth/AuthService';
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {UserFlags} from '~/Constants';
|
||||
import {UnknownUserError} from '~/Errors';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {TempBanUserRequest} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
|
||||
interface AdminUserBanServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
authService: AuthService;
|
||||
emailService: IEmailService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminUserUpdatePropagator;
|
||||
}
|
||||
|
||||
export class AdminUserBanService {
|
||||
constructor(private readonly deps: AdminUserBanServiceDeps) {}
|
||||
|
||||
async tempBanUser(data: TempBanUserRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, authService, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const tempBannedUntil = new Date();
|
||||
tempBannedUntil.setHours(tempBannedUntil.getHours() + data.duration_hours);
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
temp_banned_until: tempBannedUntil,
|
||||
flags: user.flags | UserFlags.DISABLED,
|
||||
});
|
||||
|
||||
await authService.terminateAllUserSessions(userId);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendAccountTempBannedEmail(
|
||||
user.email,
|
||||
user.username,
|
||||
data.reason ?? null,
|
||||
data.duration_hours,
|
||||
tempBannedUntil,
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'temp_ban',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['duration_hours', data.duration_hours.toString()],
|
||||
['reason', data.reason ?? 'null'],
|
||||
['banned_until', tempBannedUntil.toISOString()],
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
async unbanUser(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
temp_banned_until: null,
|
||||
flags: user.flags & ~UserFlags.DISABLED,
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendUnbanNotification(
|
||||
user.email,
|
||||
user.username,
|
||||
auditLogReason || 'administrative action',
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'unban',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
}
|
||||
}
|
||||
187
fluxer_api/src/admin/services/AdminUserDeletionService.ts
Normal file
187
fluxer_api/src/admin/services/AdminUserDeletionService.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* 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 {AuthService} from '~/auth/AuthService';
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {DeletionReasons, UserFlags} from '~/Constants';
|
||||
import {UnknownUserError} from '~/Errors';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {BulkScheduleUserDeletionRequest, ScheduleAccountDeletionRequest} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
|
||||
interface AdminUserDeletionServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
authService: AuthService;
|
||||
emailService: IEmailService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminUserUpdatePropagator;
|
||||
}
|
||||
|
||||
const minUserRequestedDeletionDays = 14;
|
||||
const minStandardDeletionDays = 60;
|
||||
|
||||
export class AdminUserDeletionService {
|
||||
constructor(private readonly deps: AdminUserDeletionServiceDeps) {}
|
||||
|
||||
async scheduleAccountDeletion(
|
||||
data: ScheduleAccountDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {userRepository, authService, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const minDays =
|
||||
data.reason_code === DeletionReasons.USER_REQUESTED ? minUserRequestedDeletionDays : minStandardDeletionDays;
|
||||
const daysUntilDeletion = Math.max(data.days_until_deletion, minDays);
|
||||
const pendingDeletionAt = new Date();
|
||||
pendingDeletionAt.setDate(pendingDeletionAt.getDate() + daysUntilDeletion);
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
flags: user.flags | UserFlags.DELETED,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
deletion_reason_code: data.reason_code,
|
||||
deletion_public_reason: data.public_reason ?? null,
|
||||
deletion_audit_log_reason: auditLogReason,
|
||||
});
|
||||
|
||||
await userRepository.addPendingDeletion(userId, pendingDeletionAt, data.reason_code);
|
||||
|
||||
await authService.terminateAllUserSessions(userId);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendAccountScheduledForDeletionEmail(
|
||||
user.email,
|
||||
user.username,
|
||||
data.public_reason ?? null,
|
||||
pendingDeletionAt,
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: data.user_id,
|
||||
action: 'schedule_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map([['days', daysUntilDeletion.toString()]]),
|
||||
});
|
||||
}
|
||||
|
||||
async cancelAccountDeletion(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (user.pendingDeletionAt) {
|
||||
await userRepository.removePendingDeletion(userId, user.pendingDeletionAt);
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
flags: user.flags & ~UserFlags.DELETED & ~UserFlags.SELF_DELETED,
|
||||
pending_deletion_at: null,
|
||||
deletion_reason_code: null,
|
||||
deletion_public_reason: null,
|
||||
deletion_audit_log_reason: null,
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendUnbanNotification(
|
||||
user.email,
|
||||
user.username,
|
||||
auditLogReason || 'deletion canceled',
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'cancel_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async bulkScheduleUserDeletion(
|
||||
data: BulkScheduleUserDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{id: string; error: string}> = [];
|
||||
|
||||
for (const userIdBigInt of data.user_ids) {
|
||||
try {
|
||||
await this.scheduleAccountDeletion(
|
||||
{
|
||||
user_id: userIdBigInt,
|
||||
reason_code: data.reason_code,
|
||||
public_reason: data.public_reason,
|
||||
days_until_deletion: data.days_until_deletion,
|
||||
},
|
||||
adminUserId,
|
||||
null,
|
||||
);
|
||||
successful.push(userIdBigInt.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: userIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const bulkMinDays =
|
||||
data.reason_code === DeletionReasons.USER_REQUESTED ? minUserRequestedDeletionDays : minStandardDeletionDays;
|
||||
const bulkDaysUntilDeletion = Math.max(data.days_until_deletion, bulkMinDays);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_schedule_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['user_count', data.user_ids.length.toString()],
|
||||
['reason_code', data.reason_code.toString()],
|
||||
['days', bulkDaysUntilDeletion.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
}
|
||||
64
fluxer_api/src/admin/services/AdminUserLookupService.ts
Normal file
64
fluxer_api/src/admin/services/AdminUserLookupService.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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 {mapUserToAdminResponse} from '~/admin/AdminModel';
|
||||
import {createUserID} from '~/BrandedTypes';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
|
||||
interface LookupUserRequest {
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface AdminUserLookupServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
cacheService: ICacheService;
|
||||
}
|
||||
|
||||
export class AdminUserLookupService {
|
||||
constructor(private readonly deps: AdminUserLookupServiceDeps) {}
|
||||
|
||||
async lookupUser(data: LookupUserRequest) {
|
||||
const {userRepository, cacheService} = this.deps;
|
||||
let user = null;
|
||||
const query = data.query.trim();
|
||||
|
||||
const fluxerTagMatch = query.match(/^(.+)#(\d{1,4})$/);
|
||||
if (fluxerTagMatch) {
|
||||
const username = fluxerTagMatch[1];
|
||||
const discriminator = parseInt(fluxerTagMatch[2], 10);
|
||||
user = await userRepository.findByUsernameDiscriminator(username, discriminator);
|
||||
} else if (/^\d+$/.test(query)) {
|
||||
try {
|
||||
const userId = createUserID(BigInt(query));
|
||||
user = await userRepository.findUnique(userId);
|
||||
} catch {}
|
||||
} else if (/^\+\d{1,15}$/.test(query)) {
|
||||
user = await userRepository.findByPhone(query);
|
||||
} else if (query.includes('@')) {
|
||||
user = await userRepository.findByEmail(query);
|
||||
} else {
|
||||
user = await userRepository.findByStripeSubscriptionId(query);
|
||||
}
|
||||
|
||||
return {
|
||||
user: user ? await mapUserToAdminResponse(user, cacheService) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
293
fluxer_api/src/admin/services/AdminUserProfileService.ts
Normal file
293
fluxer_api/src/admin/services/AdminUserProfileService.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/*
|
||||
* 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 {types} from 'cassandra-driver';
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {InputValidationError, TagAlreadyTakenError, UnknownUserError} from '~/Errors';
|
||||
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService';
|
||||
import type {User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
|
||||
import type {
|
||||
ChangeDobRequest,
|
||||
ChangeEmailRequest,
|
||||
ChangeUsernameRequest,
|
||||
ClearUserFieldsRequest,
|
||||
SetUserBotStatusRequest,
|
||||
SetUserSystemStatusRequest,
|
||||
VerifyUserEmailRequest,
|
||||
} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
|
||||
interface AdminUserProfileServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
discriminatorService: IDiscriminatorService;
|
||||
entityAssetService: EntityAssetService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminUserUpdatePropagator;
|
||||
contactChangeLogService: UserContactChangeLogService;
|
||||
}
|
||||
|
||||
export class AdminUserProfileService {
|
||||
constructor(private readonly deps: AdminUserProfileServiceDeps) {}
|
||||
|
||||
async clearUserFields(data: ClearUserFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, entityAssetService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updates: Record<string, null | string> = {};
|
||||
const preparedAssets: Array<PreparedAssetUpload> = [];
|
||||
|
||||
for (const field of data.fields) {
|
||||
if (field === 'avatar') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'avatar',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
previousHash: user.avatarHash,
|
||||
base64Image: null,
|
||||
errorPath: 'avatar',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.avatar_hash = prepared.newHash;
|
||||
} else if (field === 'banner') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'banner',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
previousHash: user.bannerHash,
|
||||
base64Image: null,
|
||||
errorPath: 'banner',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.banner_hash = prepared.newHash;
|
||||
} else if (field === 'bio') {
|
||||
updates.bio = null;
|
||||
} else if (field === 'pronouns') {
|
||||
updates.pronouns = null;
|
||||
} else if (field === 'global_name') {
|
||||
updates.global_name = null;
|
||||
}
|
||||
}
|
||||
|
||||
let updatedUser: User | null = null;
|
||||
try {
|
||||
updatedUser = await userRepository.patchUpsert(userId, updates);
|
||||
} catch (error) {
|
||||
await Promise.allSettled(preparedAssets.map((p) => entityAssetService.rollbackAssetUpload(p)));
|
||||
throw error;
|
||||
}
|
||||
|
||||
await Promise.allSettled(preparedAssets.map((p) => entityAssetService.commitAssetChange({prepared: p})));
|
||||
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'clear_fields',
|
||||
auditLogReason,
|
||||
metadata: new Map([['fields', data.fields.join(',')]]),
|
||||
});
|
||||
}
|
||||
|
||||
async setUserBotStatus(data: SetUserBotStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updates: Record<string, boolean> = {bot: data.bot};
|
||||
if (!data.bot) {
|
||||
updates.system = false;
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, updates);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_bot_status',
|
||||
auditLogReason,
|
||||
metadata: new Map([['bot', data.bot.toString()]]),
|
||||
});
|
||||
}
|
||||
|
||||
async setUserSystemStatus(data: SetUserSystemStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (data.system && !user.isBot) {
|
||||
throw InputValidationError.create('system', 'User must be a bot to be marked as a system user');
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {system: data.system});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_system_status',
|
||||
auditLogReason,
|
||||
metadata: new Map([['system', data.system.toString()]]),
|
||||
});
|
||||
}
|
||||
|
||||
async verifyUserEmail(data: VerifyUserEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {email_verified: true});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'verify_email',
|
||||
auditLogReason,
|
||||
metadata: new Map([['email', user.email ?? 'null']]),
|
||||
});
|
||||
}
|
||||
|
||||
async changeUsername(data: ChangeUsernameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, discriminatorService, auditService, updatePropagator, contactChangeLogService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const discriminatorResult = await discriminatorService.generateDiscriminator({
|
||||
username: data.username,
|
||||
requestedDiscriminator: data.discriminator,
|
||||
isPremium: true,
|
||||
});
|
||||
|
||||
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
|
||||
throw new TagAlreadyTakenError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
username: data.username,
|
||||
discriminator: discriminatorResult.discriminator,
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser!,
|
||||
reason: 'admin_action',
|
||||
actorUserId: adminUserId,
|
||||
});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'change_username',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_username', user.username],
|
||||
['new_username', data.username],
|
||||
['discriminator', discriminatorResult.discriminator.toString()],
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
async changeEmail(data: ChangeEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator, contactChangeLogService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
email: data.email,
|
||||
email_verified: false,
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser!,
|
||||
reason: 'admin_action',
|
||||
actorUserId: adminUserId,
|
||||
});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'change_email',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_email', user.email ?? 'null'],
|
||||
['new_email', data.email],
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
async changeDob(data: ChangeDobRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
date_of_birth: types.LocalDate.fromString(data.date_of_birth),
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'change_dob',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_dob', user.dateOfBirth ?? 'null'],
|
||||
['new_dob', data.date_of_birth],
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
183
fluxer_api/src/admin/services/AdminUserRegistrationService.ts
Normal file
183
fluxer_api/src/admin/services/AdminUserRegistrationService.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* 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 {mapUserToAdminResponse} from '~/admin/AdminModel';
|
||||
import type {IAdminRepository} from '~/admin/IAdminRepository';
|
||||
import {createInviteCode, type UserID} from '~/BrandedTypes';
|
||||
import {UserFlags} from '~/Constants';
|
||||
import {InputValidationError, UnknownUserError} from '~/Errors';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteStore';
|
||||
import type {InviteService} from '~/invite/InviteService';
|
||||
import {Logger} from '~/Logger';
|
||||
import {createRequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
|
||||
interface AdminUserRegistrationServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
adminRepository: IAdminRepository;
|
||||
emailService: IEmailService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminUserUpdatePropagator;
|
||||
inviteService: InviteService;
|
||||
pendingJoinInviteStore: PendingJoinInviteStore;
|
||||
cacheService: ICacheService;
|
||||
}
|
||||
|
||||
export class AdminUserRegistrationService {
|
||||
constructor(private readonly deps: AdminUserRegistrationServiceDeps) {}
|
||||
|
||||
async listPendingVerifications(limit: number = 100) {
|
||||
const {adminRepository, userRepository, cacheService} = this.deps;
|
||||
const pendingVerifications = await adminRepository.listPendingVerifications(limit);
|
||||
const userIds = pendingVerifications.map((pv) => pv.userId);
|
||||
const users = await userRepository.listUsers(userIds);
|
||||
const userMap = new Map(users.map((u) => [u.id.toString(), u]));
|
||||
|
||||
const mappedVerifications = await Promise.all(
|
||||
pendingVerifications.map(async (pv) => {
|
||||
const metadataEntries = Array.from((pv.metadata ?? new Map()).entries()).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
|
||||
return {
|
||||
user_id: pv.userId.toString(),
|
||||
created_at: pv.createdAt.toISOString(),
|
||||
user: await mapUserToAdminResponse(userMap.get(pv.userId.toString())!, cacheService),
|
||||
metadata: metadataEntries,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
pending_verifications: mappedVerifications,
|
||||
};
|
||||
}
|
||||
|
||||
async approveRegistration(userId: UserID, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {
|
||||
userRepository,
|
||||
adminRepository,
|
||||
emailService,
|
||||
auditService,
|
||||
updatePropagator,
|
||||
inviteService,
|
||||
pendingJoinInviteStore,
|
||||
} = this.deps;
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if ((user.flags & UserFlags.PENDING_MANUAL_VERIFICATION) === 0n) {
|
||||
throw InputValidationError.create('user_id', 'User is not pending verification');
|
||||
}
|
||||
|
||||
await adminRepository.removePendingVerification(userId);
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
flags: user.flags & ~UserFlags.PENDING_MANUAL_VERIFICATION,
|
||||
});
|
||||
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendRegistrationApprovedEmail(user.email, user.username, user.locale);
|
||||
}
|
||||
|
||||
const pendingInviteCode = await pendingJoinInviteStore.getPendingInvite(userId);
|
||||
if (pendingInviteCode) {
|
||||
try {
|
||||
await inviteService.acceptInvite({
|
||||
userId,
|
||||
inviteCode: createInviteCode(pendingInviteCode),
|
||||
requestCache: createRequestCache(),
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{userId, inviteCode: pendingInviteCode, error},
|
||||
'Failed to auto-join invite after approving registration',
|
||||
);
|
||||
} finally {
|
||||
await pendingJoinInviteStore.deletePendingInvite(userId);
|
||||
}
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'approve_registration',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async rejectRegistration(userId: UserID, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, adminRepository, auditService} = this.deps;
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if ((user.flags & UserFlags.PENDING_MANUAL_VERIFICATION) === 0n) {
|
||||
throw InputValidationError.create('user_id', 'User is not pending verification');
|
||||
}
|
||||
|
||||
await adminRepository.removePendingVerification(userId);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'reject_registration',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async bulkApproveRegistrations(userIds: Array<UserID>, adminUserId: UserID, auditLogReason: string | null) {
|
||||
for (const userId of userIds) {
|
||||
await this.approveRegistration(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
processed: userIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
async bulkRejectRegistrations(userIds: Array<UserID>, adminUserId: UserID, auditLogReason: string | null) {
|
||||
for (const userId of userIds) {
|
||||
await this.rejectRegistration(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
processed: userIds.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
377
fluxer_api/src/admin/services/AdminUserSecurityService.ts
Normal file
377
fluxer_api/src/admin/services/AdminUserSecurityService.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/*
|
||||
* 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 {mapUserToAdminResponse} from '~/admin/AdminModel';
|
||||
import type {AuthService} from '~/auth/AuthService';
|
||||
import {createPasswordResetToken, createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {UserFlags} from '~/Constants';
|
||||
import {InputValidationError, UnknownUserError} from '~/Errors';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
|
||||
import type {
|
||||
BulkUpdateUserFlagsRequest,
|
||||
DisableForSuspiciousActivityRequest,
|
||||
DisableMfaRequest,
|
||||
SendPasswordResetRequest,
|
||||
SetUserAclsRequest,
|
||||
TerminateSessionsRequest,
|
||||
UnlinkPhoneRequest,
|
||||
UpdateSuspiciousActivityFlagsRequest,
|
||||
} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
|
||||
interface AdminUserSecurityServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
authService: AuthService;
|
||||
emailService: IEmailService;
|
||||
snowflakeService: SnowflakeService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminUserUpdatePropagator;
|
||||
botMfaMirrorService?: BotMfaMirrorService;
|
||||
contactChangeLogService: UserContactChangeLogService;
|
||||
cacheService: ICacheService;
|
||||
}
|
||||
|
||||
export class AdminUserSecurityService {
|
||||
constructor(private readonly deps: AdminUserSecurityServiceDeps) {}
|
||||
|
||||
async updateUserFlags({
|
||||
userId,
|
||||
data,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
userId: UserID;
|
||||
data: {addFlags: Array<bigint>; removeFlags: Array<bigint>};
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
let newFlags = user.flags;
|
||||
for (const flag of data.addFlags) {
|
||||
newFlags |= flag;
|
||||
}
|
||||
for (const flag of data.removeFlags) {
|
||||
newFlags &= ~flag;
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
flags: newFlags,
|
||||
});
|
||||
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'update_flags',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['add_flags', data.addFlags.map((f) => f.toString()).join(',')],
|
||||
['remove_flags', data.removeFlags.map((f) => f.toString()).join(',')],
|
||||
['new_flags', newFlags.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser!, this.deps.cacheService),
|
||||
};
|
||||
}
|
||||
|
||||
async disableMfa(data: DisableMfaRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
totp_secret: null,
|
||||
authenticator_types: null,
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
await this.deps.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
|
||||
await userRepository.clearMfaBackupCodes(userId);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'disable_mfa',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async sendPasswordReset(data: SendPasswordResetRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, emailService, snowflakeService, auditService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
throw InputValidationError.create('email', 'User does not have an email address');
|
||||
}
|
||||
|
||||
const token = createPasswordResetToken(snowflakeService.generate().toString());
|
||||
await userRepository.createPasswordResetToken({
|
||||
token_: token,
|
||||
user_id: userId,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
await emailService.sendPasswordResetEmail(user.email, user.username, token, user.locale);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'send_password_reset',
|
||||
auditLogReason,
|
||||
metadata: new Map([['email', user.email]]),
|
||||
});
|
||||
}
|
||||
|
||||
async terminateSessions(data: TerminateSessionsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, authService, auditService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
await authService.terminateAllUserSessions(userId);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'terminate_sessions',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async setUserAcls(data: SetUserAclsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
acls: new Set(data.acls),
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_acls',
|
||||
auditLogReason,
|
||||
metadata: new Map([['acls', data.acls.join(',')]]),
|
||||
});
|
||||
}
|
||||
|
||||
async unlinkPhone(data: UnlinkPhoneRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService, updatePropagator, contactChangeLogService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
phone: null,
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser!,
|
||||
reason: 'admin_action',
|
||||
actorUserId: adminUserId,
|
||||
});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'unlink_phone',
|
||||
auditLogReason,
|
||||
metadata: new Map([['phone', user.phone ?? 'null']]),
|
||||
});
|
||||
}
|
||||
|
||||
async updateSuspiciousActivityFlags(
|
||||
data: UpdateSuspiciousActivityFlagsRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {userRepository, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
suspicious_activity_flags: data.flags,
|
||||
});
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'update_suspicious_activity_flags',
|
||||
auditLogReason,
|
||||
metadata: new Map([['flags', data.flags.toString()]]),
|
||||
});
|
||||
}
|
||||
|
||||
async disableForSuspiciousActivity(
|
||||
data: DisableForSuspiciousActivityRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {userRepository, authService, emailService, auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {
|
||||
flags: user.flags | UserFlags.DISABLED_SUSPICIOUS_ACTIVITY,
|
||||
suspicious_activity_flags: data.flags,
|
||||
password_hash: null,
|
||||
});
|
||||
|
||||
await authService.terminateAllUserSessions(userId);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser!});
|
||||
|
||||
if (user.email) {
|
||||
await emailService.sendAccountDisabledForSuspiciousActivityEmail(user.email, user.username, null, user.locale);
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'disable_suspicious_activity',
|
||||
auditLogReason,
|
||||
metadata: new Map([['flags', data.flags.toString()]]),
|
||||
});
|
||||
}
|
||||
|
||||
async bulkUpdateUserFlags(data: BulkUpdateUserFlagsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{id: string; error: string}> = [];
|
||||
|
||||
for (const userIdBigInt of data.user_ids) {
|
||||
try {
|
||||
const userId = createUserID(userIdBigInt);
|
||||
await this.updateUserFlags({
|
||||
userId,
|
||||
data: {addFlags: data.add_flags, removeFlags: data.remove_flags},
|
||||
adminUserId,
|
||||
auditLogReason: null,
|
||||
});
|
||||
successful.push(userId.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: userIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_update_user_flags',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['user_count', data.user_ids.length.toString()],
|
||||
['add_flags', data.add_flags.map((f) => f.toString()).join(',')],
|
||||
['remove_flags', data.remove_flags.map((f) => f.toString()).join(',')],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
async listUserSessions(userId: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {userRepository, auditService} = this.deps;
|
||||
const userIdTyped = createUserID(userId);
|
||||
const user = await userRepository.findUnique(userIdTyped);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const sessions = await userRepository.listAuthSessions(userIdTyped);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
action: 'list_user_sessions',
|
||||
auditLogReason,
|
||||
metadata: new Map([['session_count', sessions.length.toString()]]),
|
||||
});
|
||||
|
||||
return {
|
||||
sessions: sessions.map((session) => ({
|
||||
session_id_hash: session.sessionIdHash.toString('base64url'),
|
||||
created_at: session.createdAt.toISOString(),
|
||||
approx_last_used_at: session.approximateLastUsedAt.toISOString(),
|
||||
client_ip: session.clientIp,
|
||||
client_os: session.clientOs,
|
||||
client_platform: session.clientPlatform,
|
||||
client_location: session.clientLocation ?? 'Unknown Location',
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
407
fluxer_api/src/admin/services/AdminUserService.ts
Normal file
407
fluxer_api/src/admin/services/AdminUserService.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/*
|
||||
* 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 {IAdminRepository} from '~/admin/IAdminRepository';
|
||||
import type {AuthService} from '~/auth/AuthService';
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {UnknownUserError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService} from '~/infrastructure/EntityAssetService';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteStore';
|
||||
import type {RedisBulkMessageDeletionQueueService} from '~/infrastructure/RedisBulkMessageDeletionQueueService';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {InviteService} from '~/invite/InviteService';
|
||||
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
|
||||
import type {
|
||||
BulkScheduleUserDeletionRequest,
|
||||
BulkUpdateUserFlagsRequest,
|
||||
CancelBulkMessageDeletionRequest,
|
||||
ChangeDobRequest,
|
||||
ChangeEmailRequest,
|
||||
ChangeUsernameRequest,
|
||||
ClearUserFieldsRequest,
|
||||
DisableForSuspiciousActivityRequest,
|
||||
DisableMfaRequest,
|
||||
ListUserChangeLogRequest,
|
||||
ScheduleAccountDeletionRequest,
|
||||
SendPasswordResetRequest,
|
||||
SetUserAclsRequest,
|
||||
SetUserBotStatusRequest,
|
||||
SetUserSystemStatusRequest,
|
||||
TempBanUserRequest,
|
||||
TerminateSessionsRequest,
|
||||
UnlinkPhoneRequest,
|
||||
UpdateSuspiciousActivityFlagsRequest,
|
||||
VerifyUserEmailRequest,
|
||||
} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import {AdminBanManagementService} from './AdminBanManagementService';
|
||||
import {AdminUserBanService} from './AdminUserBanService';
|
||||
import {AdminUserDeletionService} from './AdminUserDeletionService';
|
||||
import {AdminUserLookupService} from './AdminUserLookupService';
|
||||
import {AdminUserProfileService} from './AdminUserProfileService';
|
||||
import {AdminUserRegistrationService} from './AdminUserRegistrationService';
|
||||
import {AdminUserSecurityService} from './AdminUserSecurityService';
|
||||
import {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
|
||||
interface LookupUserRequest {
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface AdminUserServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
guildRepository: IGuildRepository;
|
||||
discriminatorService: IDiscriminatorService;
|
||||
snowflakeService: SnowflakeService;
|
||||
authService: AuthService;
|
||||
emailService: IEmailService;
|
||||
entityAssetService: EntityAssetService;
|
||||
auditService: AdminAuditService;
|
||||
gatewayService: IGatewayService;
|
||||
userCacheService: UserCacheService;
|
||||
adminRepository: IAdminRepository;
|
||||
botMfaMirrorService: BotMfaMirrorService;
|
||||
contactChangeLogService: UserContactChangeLogService;
|
||||
bulkMessageDeletionQueue: RedisBulkMessageDeletionQueueService;
|
||||
inviteService: InviteService;
|
||||
pendingJoinInviteStore: PendingJoinInviteStore;
|
||||
cacheService: ICacheService;
|
||||
}
|
||||
|
||||
export class AdminUserService {
|
||||
private readonly lookupService: AdminUserLookupService;
|
||||
private readonly profileService: AdminUserProfileService;
|
||||
private readonly securityService: AdminUserSecurityService;
|
||||
private readonly banService: AdminUserBanService;
|
||||
private readonly deletionService: AdminUserDeletionService;
|
||||
private readonly banManagementService: AdminBanManagementService;
|
||||
private readonly registrationService: AdminUserRegistrationService;
|
||||
private readonly updatePropagator: AdminUserUpdatePropagator;
|
||||
private readonly contactChangeLogService: UserContactChangeLogService;
|
||||
private readonly auditService: AdminAuditService;
|
||||
private readonly userRepository: IUserRepository;
|
||||
private readonly bulkMessageDeletionQueue: RedisBulkMessageDeletionQueueService;
|
||||
|
||||
constructor(deps: AdminUserServiceDeps) {
|
||||
this.updatePropagator = new AdminUserUpdatePropagator({
|
||||
userCacheService: deps.userCacheService,
|
||||
userRepository: deps.userRepository,
|
||||
guildRepository: deps.guildRepository,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
|
||||
this.userRepository = deps.userRepository;
|
||||
this.auditService = deps.auditService;
|
||||
this.bulkMessageDeletionQueue = deps.bulkMessageDeletionQueue;
|
||||
|
||||
this.lookupService = new AdminUserLookupService({
|
||||
userRepository: deps.userRepository,
|
||||
cacheService: deps.cacheService,
|
||||
});
|
||||
|
||||
this.profileService = new AdminUserProfileService({
|
||||
userRepository: deps.userRepository,
|
||||
discriminatorService: deps.discriminatorService,
|
||||
entityAssetService: deps.entityAssetService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
contactChangeLogService: deps.contactChangeLogService,
|
||||
});
|
||||
|
||||
this.securityService = new AdminUserSecurityService({
|
||||
userRepository: deps.userRepository,
|
||||
authService: deps.authService,
|
||||
emailService: deps.emailService,
|
||||
snowflakeService: deps.snowflakeService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
botMfaMirrorService: deps.botMfaMirrorService,
|
||||
contactChangeLogService: deps.contactChangeLogService,
|
||||
cacheService: deps.cacheService,
|
||||
});
|
||||
|
||||
this.banService = new AdminUserBanService({
|
||||
userRepository: deps.userRepository,
|
||||
authService: deps.authService,
|
||||
emailService: deps.emailService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
});
|
||||
|
||||
this.deletionService = new AdminUserDeletionService({
|
||||
userRepository: deps.userRepository,
|
||||
authService: deps.authService,
|
||||
emailService: deps.emailService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
});
|
||||
|
||||
this.banManagementService = new AdminBanManagementService({
|
||||
adminRepository: deps.adminRepository,
|
||||
auditService: deps.auditService,
|
||||
});
|
||||
|
||||
this.registrationService = new AdminUserRegistrationService({
|
||||
userRepository: deps.userRepository,
|
||||
adminRepository: deps.adminRepository,
|
||||
emailService: deps.emailService,
|
||||
auditService: deps.auditService,
|
||||
updatePropagator: this.updatePropagator,
|
||||
inviteService: deps.inviteService,
|
||||
pendingJoinInviteStore: deps.pendingJoinInviteStore,
|
||||
cacheService: deps.cacheService,
|
||||
});
|
||||
|
||||
this.contactChangeLogService = deps.contactChangeLogService;
|
||||
}
|
||||
|
||||
async lookupUser(data: LookupUserRequest) {
|
||||
return this.lookupService.lookupUser(data);
|
||||
}
|
||||
|
||||
async clearUserFields(data: ClearUserFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.clearUserFields(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserBotStatus(data: SetUserBotStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.setUserBotStatus(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserSystemStatus(data: SetUserSystemStatusRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.setUserSystemStatus(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async verifyUserEmail(data: VerifyUserEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.verifyUserEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeUsername(data: ChangeUsernameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.changeUsername(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeEmail(data: ChangeEmailRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.changeEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async changeDob(data: ChangeDobRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.profileService.changeDob(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateUserFlags({
|
||||
userId,
|
||||
data,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
userId: UserID;
|
||||
data: {addFlags: Array<bigint>; removeFlags: Array<bigint>};
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
return this.securityService.updateUserFlags({userId, data, adminUserId, auditLogReason});
|
||||
}
|
||||
|
||||
async disableMfa(data: DisableMfaRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.disableMfa(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async sendPasswordReset(data: SendPasswordResetRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.sendPasswordReset(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async terminateSessions(data: TerminateSessionsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.terminateSessions(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async setUserAcls(data: SetUserAclsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.setUserAcls(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unlinkPhone(data: UnlinkPhoneRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.unlinkPhone(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async updateSuspiciousActivityFlags(
|
||||
data: UpdateSuspiciousActivityFlagsRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.securityService.updateSuspiciousActivityFlags(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async disableForSuspiciousActivity(
|
||||
data: DisableForSuspiciousActivityRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.securityService.disableForSuspiciousActivity(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkUpdateUserFlags(data: BulkUpdateUserFlagsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.bulkUpdateUserFlags(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async listUserSessions(userId: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.securityService.listUserSessions(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async listUserChangeLog(data: ListUserChangeLogRequest) {
|
||||
const entries = await this.contactChangeLogService.listLogs({
|
||||
userId: createUserID(data.user_id),
|
||||
limit: data.limit,
|
||||
beforeEventId: data.page_token,
|
||||
});
|
||||
|
||||
const nextPageToken =
|
||||
entries.length === data.limit && entries.length > 0 ? entries.at(-1)!.event_id.toString() : null;
|
||||
|
||||
return {
|
||||
entries: entries.map((entry) => ({
|
||||
event_id: entry.event_id.toString(),
|
||||
field: entry.field,
|
||||
old_value: entry.old_value ?? null,
|
||||
new_value: entry.new_value ?? null,
|
||||
reason: entry.reason,
|
||||
actor_user_id: entry.actor_user_id ? entry.actor_user_id.toString() : null,
|
||||
event_at: entry.event_at.toISOString(),
|
||||
})),
|
||||
next_page_token: nextPageToken,
|
||||
};
|
||||
}
|
||||
|
||||
async tempBanUser(data: TempBanUserRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banService.tempBanUser(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanUser(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banService.unbanUser(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async scheduleAccountDeletion(
|
||||
data: ScheduleAccountDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.deletionService.scheduleAccountDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async cancelAccountDeletion(data: {user_id: bigint}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.deletionService.cancelAccountDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async cancelBulkMessageDeletion(
|
||||
data: CancelBulkMessageDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
await this.bulkMessageDeletionQueue.removeFromQueue(userId);
|
||||
|
||||
await this.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'cancel_bulk_message_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async bulkScheduleUserDeletion(
|
||||
data: BulkScheduleUserDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
return this.deletionService.bulkScheduleUserDeletion(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async banIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.banIp(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanIp(data: {ip: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.unbanIp(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async checkIpBan(data: {ip: string}) {
|
||||
return this.banManagementService.checkIpBan(data);
|
||||
}
|
||||
|
||||
async banEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.banEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanEmail(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.unbanEmail(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async checkEmailBan(data: {email: string}) {
|
||||
return this.banManagementService.checkEmailBan(data);
|
||||
}
|
||||
|
||||
async banPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.banPhone(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async unbanPhone(data: {phone: string}, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.banManagementService.unbanPhone(data, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async checkPhoneBan(data: {phone: string}) {
|
||||
return this.banManagementService.checkPhoneBan(data);
|
||||
}
|
||||
|
||||
async listPendingVerifications(limit: number = 100) {
|
||||
return this.registrationService.listPendingVerifications(limit);
|
||||
}
|
||||
|
||||
async approveRegistration(userId: UserID, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.registrationService.approveRegistration(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async rejectRegistration(userId: UserID, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.registrationService.rejectRegistration(userId, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkApproveRegistrations(userIds: Array<UserID>, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.registrationService.bulkApproveRegistrations(userIds, adminUserId, auditLogReason);
|
||||
}
|
||||
|
||||
async bulkRejectRegistrations(userIds: Array<UserID>, adminUserId: UserID, auditLogReason: string | null) {
|
||||
return this.registrationService.bulkRejectRegistrations(userIds, adminUserId, auditLogReason);
|
||||
}
|
||||
}
|
||||
94
fluxer_api/src/admin/services/AdminUserUpdatePropagator.ts
Normal file
94
fluxer_api/src/admin/services/AdminUserUpdatePropagator.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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 {UserID} from '~/BrandedTypes';
|
||||
import {mapGuildMemberToResponse} from '~/guild/GuildModel';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {User} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {BaseUserUpdatePropagator} from '~/user/services/BaseUserUpdatePropagator';
|
||||
import {hasPartialUserFieldsChanged} from '~/user/UserMappers';
|
||||
|
||||
interface AdminUserUpdatePropagatorDeps {
|
||||
userCacheService: UserCacheService;
|
||||
userRepository: IUserRepository;
|
||||
guildRepository: IGuildRepository;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
export class AdminUserUpdatePropagator extends BaseUserUpdatePropagator {
|
||||
constructor(private readonly deps: AdminUserUpdatePropagatorDeps) {
|
||||
super({
|
||||
userCacheService: deps.userCacheService,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
}
|
||||
|
||||
async propagateUserUpdate({
|
||||
userId,
|
||||
oldUser,
|
||||
updatedUser,
|
||||
}: {
|
||||
userId: UserID;
|
||||
oldUser: User;
|
||||
updatedUser: User;
|
||||
}): Promise<void> {
|
||||
await this.dispatchUserUpdate(updatedUser);
|
||||
|
||||
if (hasPartialUserFieldsChanged(oldUser, updatedUser)) {
|
||||
await this.invalidateUserCache(userId);
|
||||
await this.propagateToGuilds(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async propagateToGuilds(userId: UserID): Promise<void> {
|
||||
const {userRepository, guildRepository, gatewayService, userCacheService} = this.deps;
|
||||
|
||||
const guildIds = await userRepository.getUserGuildIds(userId);
|
||||
if (guildIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestCache: RequestCache = {
|
||||
userPartials: new Map(),
|
||||
clear() {
|
||||
this.userPartials.clear();
|
||||
},
|
||||
};
|
||||
|
||||
for (const guildId of guildIds) {
|
||||
const member = await guildRepository.getMember(guildId, userId);
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const memberResponse = await mapGuildMemberToResponse(member, userCacheService, requestCache);
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_MEMBER_UPDATE',
|
||||
data: memberResponse,
|
||||
});
|
||||
}
|
||||
|
||||
requestCache.clear();
|
||||
}
|
||||
}
|
||||
401
fluxer_api/src/admin/services/AdminVoiceService.ts
Normal file
401
fluxer_api/src/admin/services/AdminVoiceService.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/*
|
||||
* 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 {createGuildIDSet, createUserIDSet, type UserID} from '~/BrandedTypes';
|
||||
import {UnknownVoiceRegionError, UnknownVoiceServerError} from '~/Errors';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import {VOICE_CONFIGURATION_CHANNEL} from '~/voice/VoiceConstants';
|
||||
import type {VoiceRegionRecord, VoiceRegionWithServers, VoiceServerRecord} from '~/voice/VoiceModel';
|
||||
import type {VoiceRepository} from '~/voice/VoiceRepository';
|
||||
import type {
|
||||
CreateVoiceRegionRequest,
|
||||
CreateVoiceServerRequest,
|
||||
DeleteVoiceRegionRequest,
|
||||
DeleteVoiceServerRequest,
|
||||
GetVoiceRegionRequest,
|
||||
GetVoiceServerRequest,
|
||||
ListVoiceRegionsRequest,
|
||||
ListVoiceServersRequest,
|
||||
UpdateVoiceRegionRequest,
|
||||
UpdateVoiceServerRequest,
|
||||
VoiceRegionAdminResponse,
|
||||
VoiceServerAdminResponse,
|
||||
} from '../AdminModel';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
|
||||
interface AdminVoiceServiceDeps {
|
||||
voiceRepository: VoiceRepository;
|
||||
cacheService: ICacheService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminVoiceService {
|
||||
constructor(private readonly deps: AdminVoiceServiceDeps) {}
|
||||
|
||||
async listVoiceRegions(data: ListVoiceRegionsRequest) {
|
||||
const {voiceRepository} = this.deps;
|
||||
const regions = data.include_servers
|
||||
? await voiceRepository.listRegionsWithServers()
|
||||
: await voiceRepository.listRegions();
|
||||
|
||||
regions.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (data.include_servers) {
|
||||
const regionsWithServers = regions as Array<VoiceRegionWithServers>;
|
||||
return {
|
||||
regions: regionsWithServers.map((region) => ({
|
||||
...this.mapVoiceRegionToAdminResponse(region),
|
||||
servers: region.servers
|
||||
.sort((a, b) => a.serverId.localeCompare(b.serverId))
|
||||
.map((server) => this.mapVoiceServerToAdminResponse(server)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
regions: regions.map((region) => this.mapVoiceRegionToAdminResponse(region)),
|
||||
};
|
||||
}
|
||||
|
||||
async getVoiceRegion(data: GetVoiceRegionRequest) {
|
||||
const {voiceRepository} = this.deps;
|
||||
const region = data.include_servers
|
||||
? await voiceRepository.getRegionWithServers(data.id)
|
||||
: await voiceRepository.getRegion(data.id);
|
||||
|
||||
if (!region) {
|
||||
return {region: null};
|
||||
}
|
||||
|
||||
if (data.include_servers && 'servers' in region) {
|
||||
const regionWithServers = region as VoiceRegionWithServers;
|
||||
return {
|
||||
region: {
|
||||
...this.mapVoiceRegionToAdminResponse(regionWithServers),
|
||||
servers: regionWithServers.servers.map((server) => this.mapVoiceServerToAdminResponse(server)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
region: this.mapVoiceRegionToAdminResponse(region),
|
||||
};
|
||||
}
|
||||
|
||||
async createVoiceRegion(data: CreateVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const region = await voiceRepository.createRegion({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
emoji: data.emoji,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
isDefault: data.is_default ?? false,
|
||||
restrictions: {
|
||||
vipOnly: data.vip_only ?? false,
|
||||
requiredGuildFeatures: new Set(data.required_guild_features ?? []),
|
||||
allowedGuildIds: createGuildIDSet(new Set((data.allowed_guild_ids ?? []).map(BigInt))),
|
||||
allowedUserIds: createUserIDSet(new Set((data.allowed_user_ids ?? []).map(BigInt))),
|
||||
},
|
||||
});
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'region_created', regionId: region.id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_region',
|
||||
targetId: BigInt(0),
|
||||
action: 'create_voice_region',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', region.id],
|
||||
['name', region.name],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
region: this.mapVoiceRegionToAdminResponse(region),
|
||||
};
|
||||
}
|
||||
|
||||
async updateVoiceRegion(data: UpdateVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const existing = await voiceRepository.getRegion(data.id);
|
||||
if (!existing) {
|
||||
throw new UnknownVoiceRegionError();
|
||||
}
|
||||
|
||||
const updates: VoiceRegionRecord = {...existing};
|
||||
|
||||
if (data.name !== undefined) updates.name = data.name;
|
||||
if (data.emoji !== undefined) updates.emoji = data.emoji;
|
||||
if (data.latitude !== undefined) updates.latitude = data.latitude;
|
||||
if (data.longitude !== undefined) updates.longitude = data.longitude;
|
||||
if (data.is_default !== undefined) updates.isDefault = data.is_default;
|
||||
|
||||
if (
|
||||
data.vip_only !== undefined ||
|
||||
data.required_guild_features !== undefined ||
|
||||
data.allowed_guild_ids !== undefined ||
|
||||
data.allowed_user_ids !== undefined
|
||||
) {
|
||||
updates.restrictions = {...existing.restrictions};
|
||||
if (data.vip_only !== undefined) updates.restrictions.vipOnly = data.vip_only;
|
||||
if (data.required_guild_features !== undefined)
|
||||
updates.restrictions.requiredGuildFeatures = new Set(data.required_guild_features);
|
||||
if (data.allowed_guild_ids !== undefined) {
|
||||
updates.restrictions.allowedGuildIds = createGuildIDSet(new Set(data.allowed_guild_ids.map(BigInt)));
|
||||
}
|
||||
if (data.allowed_user_ids !== undefined) {
|
||||
updates.restrictions.allowedUserIds = createUserIDSet(new Set(data.allowed_user_ids.map(BigInt)));
|
||||
}
|
||||
}
|
||||
|
||||
updates.updatedAt = new Date();
|
||||
|
||||
await voiceRepository.upsertRegion(updates);
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'region_updated', regionId: data.id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_region',
|
||||
targetId: BigInt(0),
|
||||
action: 'update_voice_region',
|
||||
auditLogReason,
|
||||
metadata: new Map([['region_id', data.id]]),
|
||||
});
|
||||
|
||||
return {
|
||||
region: this.mapVoiceRegionToAdminResponse(updates),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteVoiceRegion(data: DeleteVoiceRegionRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const existing = await voiceRepository.getRegion(data.id);
|
||||
if (!existing) {
|
||||
throw new UnknownVoiceRegionError();
|
||||
}
|
||||
|
||||
await voiceRepository.deleteRegion(data.id);
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'region_deleted', regionId: data.id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_region',
|
||||
targetId: BigInt(0),
|
||||
action: 'delete_voice_region',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', data.id],
|
||||
['name', existing.name],
|
||||
]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async listVoiceServers(data: ListVoiceServersRequest) {
|
||||
const {voiceRepository} = this.deps;
|
||||
const servers = await voiceRepository.listServers(data.region_id);
|
||||
|
||||
return {
|
||||
servers: servers.map((server) => this.mapVoiceServerToAdminResponse(server)),
|
||||
};
|
||||
}
|
||||
|
||||
async getVoiceServer(data: GetVoiceServerRequest) {
|
||||
const {voiceRepository} = this.deps;
|
||||
const server = await voiceRepository.getServer(data.region_id, data.server_id);
|
||||
|
||||
return {
|
||||
server: server ? this.mapVoiceServerToAdminResponse(server) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async createVoiceServer(data: CreateVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const server = await voiceRepository.createServer({
|
||||
regionId: data.region_id,
|
||||
serverId: data.server_id,
|
||||
endpoint: data.endpoint,
|
||||
isActive: data.is_active ?? true,
|
||||
apiKey: data.api_key ?? null,
|
||||
apiSecret: data.api_secret ?? null,
|
||||
restrictions: {
|
||||
vipOnly: data.vip_only ?? false,
|
||||
requiredGuildFeatures: new Set(data.required_guild_features ?? []),
|
||||
allowedGuildIds: createGuildIDSet(new Set((data.allowed_guild_ids ?? []).map(BigInt))),
|
||||
allowedUserIds: createUserIDSet(new Set((data.allowed_user_ids ?? []).map(BigInt))),
|
||||
},
|
||||
});
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'server_created', regionId: data.region_id, serverId: data.server_id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_server',
|
||||
targetId: BigInt(0),
|
||||
action: 'create_voice_server',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', server.regionId],
|
||||
['server_id', server.serverId],
|
||||
['endpoint', server.endpoint],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
server: this.mapVoiceServerToAdminResponse(server),
|
||||
};
|
||||
}
|
||||
|
||||
async updateVoiceServer(data: UpdateVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const existing = await voiceRepository.getServer(data.region_id, data.server_id);
|
||||
if (!existing) {
|
||||
throw new UnknownVoiceServerError();
|
||||
}
|
||||
|
||||
const updates: VoiceServerRecord = {...existing};
|
||||
if (data.endpoint !== undefined) updates.endpoint = data.endpoint;
|
||||
if (data.api_key !== undefined && data.api_key !== '') updates.apiKey = data.api_key;
|
||||
if (data.api_secret !== undefined && data.api_secret !== '') updates.apiSecret = data.api_secret;
|
||||
if (data.is_active !== undefined) updates.isActive = data.is_active;
|
||||
|
||||
if (
|
||||
data.vip_only !== undefined ||
|
||||
data.required_guild_features !== undefined ||
|
||||
data.allowed_guild_ids !== undefined ||
|
||||
data.allowed_user_ids !== undefined
|
||||
) {
|
||||
updates.restrictions = {...existing.restrictions};
|
||||
if (data.vip_only !== undefined) updates.restrictions.vipOnly = data.vip_only;
|
||||
if (data.required_guild_features !== undefined)
|
||||
updates.restrictions.requiredGuildFeatures = new Set(data.required_guild_features);
|
||||
if (data.allowed_guild_ids !== undefined) {
|
||||
updates.restrictions.allowedGuildIds = createGuildIDSet(new Set(data.allowed_guild_ids.map(BigInt)));
|
||||
}
|
||||
if (data.allowed_user_ids !== undefined) {
|
||||
updates.restrictions.allowedUserIds = createUserIDSet(new Set(data.allowed_user_ids.map(BigInt)));
|
||||
}
|
||||
}
|
||||
|
||||
updates.updatedAt = new Date();
|
||||
|
||||
await voiceRepository.upsertServer(updates);
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'server_updated', regionId: data.region_id, serverId: data.server_id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_server',
|
||||
targetId: BigInt(0),
|
||||
action: 'update_voice_server',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', data.region_id],
|
||||
['server_id', data.server_id],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
server: this.mapVoiceServerToAdminResponse(updates),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteVoiceServer(data: DeleteVoiceServerRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {voiceRepository, cacheService, auditService} = this.deps;
|
||||
const existing = await voiceRepository.getServer(data.region_id, data.server_id);
|
||||
if (!existing) {
|
||||
throw new UnknownVoiceServerError();
|
||||
}
|
||||
|
||||
await voiceRepository.deleteServer(data.region_id, data.server_id);
|
||||
|
||||
await cacheService.publish(
|
||||
VOICE_CONFIGURATION_CHANNEL,
|
||||
JSON.stringify({type: 'server_deleted', regionId: data.region_id, serverId: data.server_id}),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'voice_server',
|
||||
targetId: BigInt(0),
|
||||
action: 'delete_voice_server',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['region_id', data.region_id],
|
||||
['server_id', data.server_id],
|
||||
['endpoint', existing.endpoint],
|
||||
]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
private mapVoiceRegionToAdminResponse(region: VoiceRegionRecord): VoiceRegionAdminResponse {
|
||||
return {
|
||||
id: region.id,
|
||||
name: region.name,
|
||||
emoji: region.emoji,
|
||||
latitude: region.latitude,
|
||||
longitude: region.longitude,
|
||||
is_default: region.isDefault,
|
||||
vip_only: region.restrictions.vipOnly,
|
||||
required_guild_features: Array.from(region.restrictions.requiredGuildFeatures),
|
||||
allowed_guild_ids: Array.from(region.restrictions.allowedGuildIds).map((id) => id.toString()),
|
||||
allowed_user_ids: Array.from(region.restrictions.allowedUserIds).map((id) => id.toString()),
|
||||
created_at: region.createdAt?.toISOString() ?? null,
|
||||
updated_at: region.updatedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private mapVoiceServerToAdminResponse(server: VoiceServerRecord): VoiceServerAdminResponse {
|
||||
return {
|
||||
region_id: server.regionId,
|
||||
server_id: server.serverId,
|
||||
endpoint: server.endpoint,
|
||||
is_active: server.isActive,
|
||||
vip_only: server.restrictions.vipOnly,
|
||||
required_guild_features: Array.from(server.restrictions.requiredGuildFeatures),
|
||||
allowed_guild_ids: Array.from(server.restrictions.allowedGuildIds).map((id) => id.toString()),
|
||||
allowed_user_ids: Array.from(server.restrictions.allowedUserIds).map((id) => id.toString()),
|
||||
created_at: server.createdAt?.toISOString() ?? null,
|
||||
updated_at: server.updatedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
79
fluxer_api/src/admin/services/guild/AdminGuildBulkService.ts
Normal file
79
fluxer_api/src/admin/services/guild/AdminGuildBulkService.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 {createGuildID, type UserID} from '~/BrandedTypes';
|
||||
import type {BulkUpdateGuildFeaturesRequest} from '../../AdminModel';
|
||||
import type {AdminAuditService} from '../AdminAuditService';
|
||||
import type {AdminGuildUpdateService} from './AdminGuildUpdateService';
|
||||
|
||||
interface AdminGuildBulkServiceDeps {
|
||||
guildUpdateService: AdminGuildUpdateService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminGuildBulkService {
|
||||
constructor(private readonly deps: AdminGuildBulkServiceDeps) {}
|
||||
|
||||
async bulkUpdateGuildFeatures(
|
||||
data: BulkUpdateGuildFeaturesRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {guildUpdateService, auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{id: string; error: string}> = [];
|
||||
|
||||
for (const guildIdBigInt of data.guild_ids) {
|
||||
try {
|
||||
const guildId = createGuildID(guildIdBigInt);
|
||||
await guildUpdateService.updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures: data.add_features,
|
||||
removeFeatures: data.remove_features,
|
||||
adminUserId,
|
||||
auditLogReason: null,
|
||||
});
|
||||
successful.push(guildId.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: guildIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_update_guild_features',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['guild_count', data.guild_ids.length.toString()],
|
||||
['add_features', data.add_features.join(',')],
|
||||
['remove_features', data.remove_features.join(',')],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
}
|
||||
183
fluxer_api/src/admin/services/guild/AdminGuildLookupService.ts
Normal file
183
fluxer_api/src/admin/services/guild/AdminGuildLookupService.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* 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} from '~/BrandedTypes';
|
||||
import {createGuildID, createUserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import type {IChannelRepository} from '~/channel/IChannelRepository';
|
||||
import {StickerFormatTypes} from '~/constants/Guild';
|
||||
import {UnknownUserError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {
|
||||
ListGuildEmojisResponse,
|
||||
ListGuildMembersRequest,
|
||||
ListGuildStickersResponse,
|
||||
ListUserGuildsRequest,
|
||||
LookupGuildRequest,
|
||||
} from '../../AdminModel';
|
||||
import {mapGuildsToAdminResponse} from '../../AdminModel';
|
||||
|
||||
interface AdminGuildLookupServiceDeps {
|
||||
guildRepository: IGuildRepository;
|
||||
userRepository: IUserRepository;
|
||||
channelRepository: IChannelRepository;
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
export class AdminGuildLookupService {
|
||||
constructor(private readonly deps: AdminGuildLookupServiceDeps) {}
|
||||
|
||||
async lookupGuild(data: LookupGuildRequest) {
|
||||
const {guildRepository, channelRepository} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
|
||||
if (!guild) {
|
||||
return {guild: null};
|
||||
}
|
||||
|
||||
const channels = await channelRepository.listGuildChannels(guildId);
|
||||
const roles = await guildRepository.listRoles(guildId);
|
||||
|
||||
return {
|
||||
guild: {
|
||||
id: guild.id.toString(),
|
||||
owner_id: guild.ownerId.toString(),
|
||||
name: guild.name,
|
||||
vanity_url_code: guild.vanityUrlCode,
|
||||
icon: guild.iconHash,
|
||||
banner: guild.bannerHash,
|
||||
splash: guild.splashHash,
|
||||
features: Array.from(guild.features),
|
||||
verification_level: guild.verificationLevel,
|
||||
mfa_level: guild.mfaLevel,
|
||||
nsfw_level: guild.nsfwLevel,
|
||||
explicit_content_filter: guild.explicitContentFilter,
|
||||
default_message_notifications: guild.defaultMessageNotifications,
|
||||
afk_channel_id: guild.afkChannelId?.toString() ?? null,
|
||||
afk_timeout: guild.afkTimeout,
|
||||
system_channel_id: guild.systemChannelId?.toString() ?? null,
|
||||
system_channel_flags: guild.systemChannelFlags,
|
||||
rules_channel_id: guild.rulesChannelId?.toString() ?? null,
|
||||
disabled_operations: guild.disabledOperations,
|
||||
member_count: guild.memberCount,
|
||||
channels: channels.map((c) => ({
|
||||
id: c.id.toString(),
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
position: c.position,
|
||||
parent_id: c.parentId?.toString() ?? null,
|
||||
})),
|
||||
roles: roles.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
position: r.position,
|
||||
permissions: r.permissions.toString(),
|
||||
hoist: r.isHoisted,
|
||||
mentionable: r.isMentionable,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listUserGuilds(data: ListUserGuildsRequest) {
|
||||
const {userRepository, guildRepository} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
const guildIds = await userRepository.getUserGuildIds(userId);
|
||||
const guilds = await guildRepository.listGuilds(guildIds);
|
||||
|
||||
return mapGuildsToAdminResponse(guilds);
|
||||
}
|
||||
|
||||
async listGuildMembers(data: ListGuildMembersRequest) {
|
||||
const {gatewayService} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const limit = data.limit ?? 50;
|
||||
const offset = data.offset ?? 0;
|
||||
|
||||
const result = await gatewayService.listGuildMembers({
|
||||
guildId,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return {
|
||||
members: result.members,
|
||||
total: result.total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
async listGuildEmojis(guildId: GuildID): Promise<ListGuildEmojisResponse> {
|
||||
const {guildRepository} = this.deps;
|
||||
const emojis = await guildRepository.listEmojis(guildId);
|
||||
|
||||
return {
|
||||
guild_id: guildId.toString(),
|
||||
emojis: emojis.map((emoji) => {
|
||||
const emojiId = emoji.id.toString();
|
||||
return {
|
||||
id: emojiId,
|
||||
name: emoji.name,
|
||||
animated: emoji.isAnimated,
|
||||
creator_id: emoji.creatorId.toString(),
|
||||
media_url: this.buildEmojiMediaUrl(emojiId, emoji.isAnimated),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async listGuildStickers(guildId: GuildID): Promise<ListGuildStickersResponse> {
|
||||
const {guildRepository} = this.deps;
|
||||
const stickers = await guildRepository.listStickers(guildId);
|
||||
|
||||
return {
|
||||
guild_id: guildId.toString(),
|
||||
stickers: stickers.map((sticker) => {
|
||||
const stickerId = sticker.id.toString();
|
||||
return {
|
||||
id: stickerId,
|
||||
name: sticker.name,
|
||||
format_type: sticker.formatType,
|
||||
creator_id: sticker.creatorId.toString(),
|
||||
media_url: this.buildStickerMediaUrl(stickerId, sticker.formatType),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private buildEmojiMediaUrl(id: string, animated: boolean): string {
|
||||
const format = animated ? 'gif' : 'webp';
|
||||
return `${Config.endpoints.media}/emojis/${id}.${format}?size=160`;
|
||||
}
|
||||
|
||||
private buildStickerMediaUrl(id: string, formatType: number): string {
|
||||
const ext = formatType === StickerFormatTypes.GIF ? 'gif' : 'webp';
|
||||
return `${Config.endpoints.media}/stickers/${id}.${ext}?size=160`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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 {createGuildID, type GuildID, type UserID} from '~/BrandedTypes';
|
||||
import {UnknownGuildError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {GuildService} from '~/guild/services/GuildService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {AdminAuditService} from '../AdminAuditService';
|
||||
|
||||
interface AdminGuildManagementServiceDeps {
|
||||
guildRepository: IGuildRepository;
|
||||
gatewayService: IGatewayService;
|
||||
guildService: GuildService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminGuildManagementService {
|
||||
constructor(private readonly deps: AdminGuildManagementServiceDeps) {}
|
||||
|
||||
async reloadGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, gatewayService, auditService} = this.deps;
|
||||
const guildId = createGuildID(guildIdRaw);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
await gatewayService.reloadGuild(guildId);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: guildIdRaw,
|
||||
action: 'reload_guild',
|
||||
auditLogReason,
|
||||
metadata: new Map([['guild_id', guildIdRaw.toString()]]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async shutdownGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, gatewayService, auditService} = this.deps;
|
||||
const guildId = createGuildID(guildIdRaw);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
await gatewayService.shutdownGuild(guildId);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: guildIdRaw,
|
||||
action: 'shutdown_guild',
|
||||
auditLogReason,
|
||||
metadata: new Map([['guild_id', guildIdRaw.toString()]]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async deleteGuild(guildIdRaw: bigint, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildService, auditService} = this.deps;
|
||||
const guildId = createGuildID(guildIdRaw);
|
||||
|
||||
await guildService.deleteGuildAsAdmin(guildId, auditLogReason);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: guildIdRaw,
|
||||
action: 'delete_guild',
|
||||
auditLogReason,
|
||||
metadata: new Map([['guild_id', guildIdRaw.toString()]]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async getGuildMemoryStats(limit: number) {
|
||||
const {gatewayService} = this.deps;
|
||||
return await gatewayService.getGuildMemoryStats(limit);
|
||||
}
|
||||
|
||||
async reloadAllGuilds(guildIds: Array<GuildID>) {
|
||||
const {gatewayService} = this.deps;
|
||||
return await gatewayService.reloadAllGuilds(guildIds);
|
||||
}
|
||||
|
||||
async getNodeStats() {
|
||||
const {gatewayService} = this.deps;
|
||||
return await gatewayService.getNodeStats();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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 {createGuildID, createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {UnknownUserError} from '~/Errors';
|
||||
import type {GuildService} from '~/guild/services/GuildService';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {BulkAddGuildMembersRequest, ForceAddUserToGuildRequest} from '../../AdminModel';
|
||||
import type {AdminAuditService} from '../AdminAuditService';
|
||||
|
||||
interface AdminGuildMembershipServiceDeps {
|
||||
userRepository: IUserRepository;
|
||||
guildService: GuildService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminGuildMembershipService {
|
||||
constructor(private readonly deps: AdminGuildMembershipServiceDeps) {}
|
||||
|
||||
async forceAddUserToGuild({
|
||||
data,
|
||||
requestCache,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
data: ForceAddUserToGuildRequest;
|
||||
requestCache: RequestCache;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
const {userRepository, guildService, auditService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
|
||||
await guildService.addUserToGuild({
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: true,
|
||||
skipBanCheck: true,
|
||||
requestCache,
|
||||
initiatorId: adminUserId,
|
||||
});
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'force_add_to_guild',
|
||||
auditLogReason,
|
||||
metadata: new Map([['guild_id', String(guildId)]]),
|
||||
});
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async bulkAddGuildMembers(data: BulkAddGuildMembersRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildService, auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{id: string; error: string}> = [];
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
|
||||
for (const userIdBigInt of data.user_ids) {
|
||||
try {
|
||||
const userId = createUserID(userIdBigInt);
|
||||
await guildService.addUserToGuild({
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: false,
|
||||
skipBanCheck: true,
|
||||
requestCache: {} as RequestCache,
|
||||
initiatorId: adminUserId,
|
||||
});
|
||||
successful.push(userId.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: userIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'bulk_add_guild_members',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['guild_id', guildId.toString()],
|
||||
['user_count', data.user_ids.length.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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} from '~/BrandedTypes';
|
||||
import {mapGuildToGuildResponse} from '~/guild/GuildModel';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {Guild} from '~/Models';
|
||||
|
||||
interface AdminGuildUpdatePropagatorDeps {
|
||||
gatewayService: IGatewayService;
|
||||
}
|
||||
|
||||
export class AdminGuildUpdatePropagator {
|
||||
constructor(private readonly deps: AdminGuildUpdatePropagatorDeps) {}
|
||||
|
||||
async dispatchGuildUpdate(guildId: GuildID, updatedGuild: Guild): Promise<void> {
|
||||
const {gatewayService} = this.deps;
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_UPDATE',
|
||||
data: mapGuildToGuildResponse(updatedGuild),
|
||||
});
|
||||
}
|
||||
}
|
||||
314
fluxer_api/src/admin/services/guild/AdminGuildUpdateService.ts
Normal file
314
fluxer_api/src/admin/services/guild/AdminGuildUpdateService.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
* 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 {createGuildID, createUserID, type GuildID, type UserID} from '~/BrandedTypes';
|
||||
import {UnknownGuildError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {EntityAssetService, PreparedAssetUpload} from '~/infrastructure/EntityAssetService';
|
||||
import type {Guild} from '~/Models';
|
||||
import type {
|
||||
ClearGuildFieldsRequest,
|
||||
TransferGuildOwnershipRequest,
|
||||
UpdateGuildNameRequest,
|
||||
UpdateGuildSettingsRequest,
|
||||
} from '../../AdminModel';
|
||||
import {mapGuildToAdminResponse} from '../../AdminModel';
|
||||
import type {AdminAuditService} from '../AdminAuditService';
|
||||
import type {AdminGuildUpdatePropagator} from './AdminGuildUpdatePropagator';
|
||||
|
||||
interface AdminGuildUpdateServiceDeps {
|
||||
guildRepository: IGuildRepository;
|
||||
entityAssetService: EntityAssetService;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminGuildUpdatePropagator;
|
||||
}
|
||||
|
||||
export class AdminGuildUpdateService {
|
||||
constructor(private readonly deps: AdminGuildUpdateServiceDeps) {}
|
||||
|
||||
async updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures,
|
||||
removeFeatures,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}: {
|
||||
guildId: GuildID;
|
||||
addFeatures: Array<string>;
|
||||
removeFeatures: Array<string>;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}) {
|
||||
const {guildRepository, auditService, updatePropagator} = this.deps;
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const newFeatures = new Set(guild.features);
|
||||
for (const feature of addFeatures) {
|
||||
newFeatures.add(feature);
|
||||
}
|
||||
for (const feature of removeFeatures) {
|
||||
newFeatures.delete(feature);
|
||||
}
|
||||
|
||||
const guildRow = guild.toRow();
|
||||
const updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
features: newFeatures,
|
||||
});
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'update_features',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['add_features', addFeatures.join(',')],
|
||||
['remove_features', removeFeatures.join(',')],
|
||||
['new_features', Array.from(newFeatures).join(',')],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
|
||||
async clearGuildFields(data: ClearGuildFieldsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, entityAssetService, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const guildRow = guild.toRow();
|
||||
const updates: Partial<typeof guildRow> = {};
|
||||
const preparedAssets: Array<PreparedAssetUpload> = [];
|
||||
|
||||
for (const field of data.fields) {
|
||||
if (field === 'icon') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'icon',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: guild.iconHash,
|
||||
base64Image: null,
|
||||
errorPath: 'icon',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.icon_hash = prepared.newHash;
|
||||
} else if (field === 'banner') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'banner',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: guild.bannerHash,
|
||||
base64Image: null,
|
||||
errorPath: 'banner',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.banner_hash = prepared.newHash;
|
||||
} else if (field === 'splash') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'splash',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: guild.splashHash,
|
||||
base64Image: null,
|
||||
errorPath: 'splash',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.splash_hash = prepared.newHash;
|
||||
} else if (field === 'embed_splash') {
|
||||
const prepared = await entityAssetService.prepareAssetUpload({
|
||||
assetType: 'embed_splash',
|
||||
entityType: 'guild',
|
||||
entityId: guildId,
|
||||
previousHash: guild.embedSplashHash,
|
||||
base64Image: null,
|
||||
errorPath: 'embed_splash',
|
||||
});
|
||||
preparedAssets.push(prepared);
|
||||
updates.embed_splash_hash = prepared.newHash;
|
||||
}
|
||||
}
|
||||
|
||||
let updatedGuild: Guild;
|
||||
try {
|
||||
updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
...updates,
|
||||
});
|
||||
} catch (error) {
|
||||
await Promise.allSettled(preparedAssets.map((p) => entityAssetService.rollbackAssetUpload(p)));
|
||||
throw error;
|
||||
}
|
||||
|
||||
await Promise.allSettled(preparedAssets.map((p) => entityAssetService.commitAssetChange({prepared: p})));
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'clear_fields',
|
||||
auditLogReason,
|
||||
metadata: new Map([['fields', data.fields.join(',')]]),
|
||||
});
|
||||
}
|
||||
|
||||
async updateGuildName(data: UpdateGuildNameRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const oldName = guild.name;
|
||||
const guildRow = guild.toRow();
|
||||
const updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
name: data.name,
|
||||
});
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'update_name',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_name', oldName],
|
||||
['new_name', data.name],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
|
||||
async updateGuildSettings(data: UpdateGuildSettingsRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const guildRow = guild.toRow();
|
||||
const updates: Partial<typeof guildRow> = {};
|
||||
const metadata = new Map<string, string>();
|
||||
|
||||
if (data.verification_level !== undefined) {
|
||||
updates.verification_level = data.verification_level;
|
||||
metadata.set('verification_level', data.verification_level.toString());
|
||||
}
|
||||
if (data.mfa_level !== undefined) {
|
||||
updates.mfa_level = data.mfa_level;
|
||||
metadata.set('mfa_level', data.mfa_level.toString());
|
||||
}
|
||||
if (data.nsfw_level !== undefined) {
|
||||
updates.nsfw_level = data.nsfw_level;
|
||||
metadata.set('nsfw_level', data.nsfw_level.toString());
|
||||
}
|
||||
if (data.explicit_content_filter !== undefined) {
|
||||
updates.explicit_content_filter = data.explicit_content_filter;
|
||||
metadata.set('explicit_content_filter', data.explicit_content_filter.toString());
|
||||
}
|
||||
if (data.default_message_notifications !== undefined) {
|
||||
updates.default_message_notifications = data.default_message_notifications;
|
||||
metadata.set('default_message_notifications', data.default_message_notifications.toString());
|
||||
}
|
||||
if (data.disabled_operations !== undefined) {
|
||||
updates.disabled_operations = data.disabled_operations;
|
||||
metadata.set('disabled_operations', data.disabled_operations.toString());
|
||||
}
|
||||
|
||||
const updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
...updates,
|
||||
});
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'update_settings',
|
||||
auditLogReason,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
|
||||
async transferGuildOwnership(
|
||||
data: TransferGuildOwnershipRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {guildRepository, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const newOwnerId = createUserID(data.new_owner_id);
|
||||
|
||||
const oldOwnerId = guild.ownerId;
|
||||
const guildRow = guild.toRow();
|
||||
const updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
owner_id: newOwnerId,
|
||||
});
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'transfer_ownership',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_owner_id', oldOwnerId.toString()],
|
||||
['new_owner_id', newOwnerId.toString()],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
}
|
||||
106
fluxer_api/src/admin/services/guild/AdminGuildVanityService.ts
Normal file
106
fluxer_api/src/admin/services/guild/AdminGuildVanityService.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* 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 {
|
||||
createGuildID,
|
||||
createInviteCode,
|
||||
createVanityURLCode,
|
||||
type UserID,
|
||||
vanityCodeToInviteCode,
|
||||
} from '~/BrandedTypes';
|
||||
import {InviteTypes} from '~/Constants';
|
||||
import {InputValidationError, UnknownGuildError} from '~/Errors';
|
||||
import type {IGuildRepository} from '~/guild/IGuildRepository';
|
||||
import type {InviteRepository} from '~/invite/InviteRepository';
|
||||
import type {UpdateGuildVanityRequest} from '../../AdminModel';
|
||||
import {mapGuildToAdminResponse} from '../../AdminModel';
|
||||
import type {AdminAuditService} from '../AdminAuditService';
|
||||
import type {AdminGuildUpdatePropagator} from './AdminGuildUpdatePropagator';
|
||||
|
||||
interface AdminGuildVanityServiceDeps {
|
||||
guildRepository: IGuildRepository;
|
||||
inviteRepository: InviteRepository;
|
||||
auditService: AdminAuditService;
|
||||
updatePropagator: AdminGuildUpdatePropagator;
|
||||
}
|
||||
|
||||
export class AdminGuildVanityService {
|
||||
constructor(private readonly deps: AdminGuildVanityServiceDeps) {}
|
||||
|
||||
async updateGuildVanity(data: UpdateGuildVanityRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildRepository, inviteRepository, auditService, updatePropagator} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
|
||||
const oldVanity = guild.vanityUrlCode;
|
||||
const guildRow = guild.toRow();
|
||||
|
||||
if (data.vanity_url_code) {
|
||||
const inviteCode = createInviteCode(data.vanity_url_code);
|
||||
const existingInvite = await inviteRepository.findUnique(inviteCode);
|
||||
if (existingInvite) {
|
||||
throw InputValidationError.create('vanity_url_code', 'This vanity URL is already taken');
|
||||
}
|
||||
|
||||
if (oldVanity) {
|
||||
await inviteRepository.delete(vanityCodeToInviteCode(oldVanity));
|
||||
}
|
||||
|
||||
await inviteRepository.create({
|
||||
code: inviteCode,
|
||||
type: InviteTypes.GUILD,
|
||||
guild_id: guildId,
|
||||
channel_id: null,
|
||||
inviter_id: null,
|
||||
uses: 0,
|
||||
max_uses: 0,
|
||||
max_age: 0,
|
||||
temporary: false,
|
||||
});
|
||||
} else if (oldVanity) {
|
||||
await inviteRepository.delete(vanityCodeToInviteCode(oldVanity));
|
||||
}
|
||||
|
||||
const updatedGuild = await guildRepository.upsert({
|
||||
...guildRow,
|
||||
vanity_url_code: data.vanity_url_code ? createVanityURLCode(data.vanity_url_code) : null,
|
||||
});
|
||||
|
||||
await updatePropagator.dispatchGuildUpdate(guildId, updatedGuild);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'update_vanity',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['old_vanity', oldVanity ?? ''],
|
||||
['new_vanity', data.vanity_url_code ?? ''],
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
guild: mapGuildToAdminResponse(updatedGuild),
|
||||
};
|
||||
}
|
||||
}
|
||||
BIN
fluxer_api/src/assets/github.webp
Normal file
BIN
fluxer_api/src/assets/github.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
133
fluxer_api/src/attachment/AttachmentDecayRepository.ts
Normal file
133
fluxer_api/src/attachment/AttachmentDecayRepository.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AttachmentID, ChannelID, MessageID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import {AttachmentDecayByExpiry, AttachmentDecayById} from '~/Tables';
|
||||
import type {AttachmentDecayRow} from '~/types/AttachmentDecayTypes';
|
||||
|
||||
interface AttachmentDecayExpiryRow {
|
||||
expiry_bucket: number;
|
||||
expires_at: Date;
|
||||
attachment_id: AttachmentID;
|
||||
channel_id: ChannelID;
|
||||
message_id: MessageID;
|
||||
}
|
||||
|
||||
const FETCH_BY_ID_CQL = AttachmentDecayById.selectCql({
|
||||
where: AttachmentDecayById.where.eq('attachment_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const createFetchExpiredByBucketQuery = (limit: number) =>
|
||||
AttachmentDecayByExpiry.selectCql({
|
||||
where: [
|
||||
AttachmentDecayByExpiry.where.eq('expiry_bucket'),
|
||||
AttachmentDecayByExpiry.where.lte('expires_at', 'current_time'),
|
||||
],
|
||||
limit,
|
||||
});
|
||||
|
||||
export class AttachmentDecayRepository {
|
||||
async upsert(record: AttachmentDecayRow & {expiry_bucket: number}): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(AttachmentDecayById.upsertAll(record));
|
||||
batch.addPrepared(
|
||||
AttachmentDecayByExpiry.upsertAll({
|
||||
expiry_bucket: record.expiry_bucket,
|
||||
expires_at: record.expires_at,
|
||||
attachment_id: record.attachment_id,
|
||||
channel_id: record.channel_id,
|
||||
message_id: record.message_id,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async fetchById(attachmentId: AttachmentID): Promise<AttachmentDecayRow | null> {
|
||||
const row = await fetchOne<AttachmentDecayRow>(FETCH_BY_ID_CQL, {attachment_id: attachmentId});
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async fetchExpiredByBucket(bucket: number, currentTime: Date, limit = 200): Promise<Array<AttachmentDecayExpiryRow>> {
|
||||
const query = createFetchExpiredByBucketQuery(limit);
|
||||
return fetchMany(query, {expiry_bucket: bucket, current_time: currentTime});
|
||||
}
|
||||
|
||||
async deleteRecords(params: {expiry_bucket: number; expires_at: Date; attachment_id: AttachmentID}): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
AttachmentDecayByExpiry.deleteByPk({
|
||||
expiry_bucket: params.expiry_bucket,
|
||||
expires_at: params.expires_at,
|
||||
attachment_id: params.attachment_id,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(AttachmentDecayById.deleteByPk({attachment_id: params.attachment_id}));
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async fetchAllByBucket(bucket: number, limit = 200): Promise<Array<AttachmentDecayExpiryRow>> {
|
||||
const query = AttachmentDecayByExpiry.selectCql({
|
||||
where: [AttachmentDecayByExpiry.where.eq('expiry_bucket')],
|
||||
limit,
|
||||
});
|
||||
return fetchMany<AttachmentDecayExpiryRow>(query, {expiry_bucket: bucket});
|
||||
}
|
||||
|
||||
async deleteAllByBucket(bucket: number): Promise<number> {
|
||||
const records = await this.fetchAllByBucket(bucket);
|
||||
if (records.length === 0) return 0;
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const record of records) {
|
||||
batch.addPrepared(
|
||||
AttachmentDecayByExpiry.deleteByPk({
|
||||
expiry_bucket: record.expiry_bucket,
|
||||
expires_at: record.expires_at,
|
||||
attachment_id: record.attachment_id,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(AttachmentDecayById.deleteByPk({attachment_id: record.attachment_id}));
|
||||
}
|
||||
await batch.execute();
|
||||
return records.length;
|
||||
}
|
||||
|
||||
async clearAll(days = 30): Promise<number> {
|
||||
let totalDeleted = 0;
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date();
|
||||
date.setUTCDate(date.getUTCDate() - i);
|
||||
const bucket = parseInt(
|
||||
`${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}${String(date.getUTCDate()).padStart(
|
||||
2,
|
||||
'0',
|
||||
)}`,
|
||||
10,
|
||||
);
|
||||
|
||||
const deletedInBucket = await this.deleteAllByBucket(bucket);
|
||||
totalDeleted += deletedInBucket;
|
||||
}
|
||||
|
||||
return totalDeleted;
|
||||
}
|
||||
}
|
||||
142
fluxer_api/src/attachment/AttachmentDecayService.ts
Normal file
142
fluxer_api/src/attachment/AttachmentDecayService.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AttachmentID, ChannelID, MessageID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import type {AttachmentDecayRow} from '~/types/AttachmentDecayTypes';
|
||||
import {
|
||||
computeCost,
|
||||
computeDecay,
|
||||
DEFAULT_DECAY_CONSTANTS,
|
||||
DEFAULT_RENEWAL_CONSTANTS,
|
||||
extendExpiry,
|
||||
getExpiryBucket,
|
||||
maybeRenewExpiry,
|
||||
} from '~/utils/AttachmentDecay';
|
||||
import {AttachmentDecayRepository} from './AttachmentDecayRepository';
|
||||
|
||||
export interface AttachmentDecayPayload {
|
||||
attachmentId: AttachmentID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
filename: string;
|
||||
sizeBytes: bigint;
|
||||
uploadedAt: Date;
|
||||
currentExpiresAt?: Date | null;
|
||||
}
|
||||
|
||||
export class AttachmentDecayService {
|
||||
constructor(private readonly repo: AttachmentDecayRepository = new AttachmentDecayRepository()) {}
|
||||
|
||||
async upsertMany(payloads: Array<AttachmentDecayPayload>): Promise<void> {
|
||||
if (!Config.attachmentDecayEnabled) return;
|
||||
|
||||
for (const payload of payloads) {
|
||||
const decay = computeDecay({sizeBytes: payload.sizeBytes, uploadedAt: payload.uploadedAt});
|
||||
if (!decay) continue;
|
||||
|
||||
const expiresAt = extendExpiry(payload.currentExpiresAt ?? null, decay.expiresAt);
|
||||
const record: AttachmentDecayRow & {expiry_bucket: number} = {
|
||||
attachment_id: payload.attachmentId,
|
||||
channel_id: payload.channelId,
|
||||
message_id: payload.messageId,
|
||||
filename: payload.filename,
|
||||
size_bytes: payload.sizeBytes,
|
||||
uploaded_at: payload.uploadedAt,
|
||||
expires_at: expiresAt,
|
||||
last_accessed_at: payload.uploadedAt,
|
||||
cost: decay.cost,
|
||||
lifetime_days: decay.days,
|
||||
status: null,
|
||||
expiry_bucket: getExpiryBucket(expiresAt),
|
||||
};
|
||||
await this.repo.upsert(record);
|
||||
}
|
||||
}
|
||||
|
||||
async extendForAttachments(attachments: Array<AttachmentDecayPayload>): Promise<void> {
|
||||
if (!Config.attachmentDecayEnabled) return;
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const existing = await this.repo.fetchById(attachment.attachmentId);
|
||||
if (!existing) continue;
|
||||
const now = new Date();
|
||||
if (existing.expires_at.getTime() <= now.getTime()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const uploadedAt = existing.uploaded_at;
|
||||
const decay = computeDecay({sizeBytes: attachment.sizeBytes, uploadedAt});
|
||||
if (!decay) continue;
|
||||
|
||||
let expiresAt = extendExpiry(existing.expires_at ?? null, decay.expiresAt);
|
||||
|
||||
const windowDays = DEFAULT_RENEWAL_CONSTANTS.RENEW_WINDOW_DAYS;
|
||||
const thresholdDays = DEFAULT_RENEWAL_CONSTANTS.RENEW_THRESHOLD_DAYS;
|
||||
|
||||
const renewed = maybeRenewExpiry({
|
||||
currentExpiry: expiresAt,
|
||||
now,
|
||||
thresholdDays,
|
||||
windowDays,
|
||||
});
|
||||
|
||||
if (renewed) {
|
||||
expiresAt = renewed;
|
||||
}
|
||||
|
||||
const lifetimeDays = Math.round((expiresAt.getTime() - uploadedAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const cost = computeCost({
|
||||
sizeBytes: attachment.sizeBytes,
|
||||
lifetimeDays,
|
||||
pricePerTBPerMonth: DEFAULT_DECAY_CONSTANTS.PRICE_PER_TB_PER_MONTH,
|
||||
});
|
||||
|
||||
await this.repo.upsert({
|
||||
attachment_id: attachment.attachmentId,
|
||||
channel_id: attachment.channelId,
|
||||
message_id: attachment.messageId,
|
||||
filename: attachment.filename,
|
||||
size_bytes: attachment.sizeBytes,
|
||||
uploaded_at: uploadedAt,
|
||||
expires_at: expiresAt,
|
||||
last_accessed_at: now,
|
||||
cost,
|
||||
lifetime_days: lifetimeDays,
|
||||
status: existing.status ?? null,
|
||||
expiry_bucket: getExpiryBucket(expiresAt),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMetadata(
|
||||
attachments: Array<Pick<AttachmentDecayPayload, 'attachmentId'>>,
|
||||
): Promise<Map<string, AttachmentDecayRow>> {
|
||||
if (!Config.attachmentDecayEnabled) return new Map();
|
||||
|
||||
const map = new Map<string, AttachmentDecayRow>();
|
||||
for (const att of attachments) {
|
||||
const row = await this.repo.fetchById(att.attachmentId);
|
||||
if (row) {
|
||||
map.set(att.attachmentId.toString(), row);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
478
fluxer_api/src/auth/AuthController.ts
Normal file
478
fluxer_api/src/auth/AuthController.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
/*
|
||||
* 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 {AuthenticationResponseJSON} from '@simplewebauthn/server';
|
||||
import {Redis} from 'ioredis';
|
||||
import type {HonoApp} from '~/App';
|
||||
import {
|
||||
EmailRevertRequest,
|
||||
ForgotPasswordRequest,
|
||||
LoginRequest,
|
||||
LogoutAuthSessionsRequest,
|
||||
RegisterRequest,
|
||||
ResetPasswordRequest,
|
||||
UsernameSuggestionsRequest,
|
||||
VerifyEmailRequest,
|
||||
} from '~/auth/AuthModel';
|
||||
import {requireSudoMode} from '~/auth/services/SudoVerificationService';
|
||||
import {Config} from '~/Config';
|
||||
import {InputValidationError} from '~/Errors';
|
||||
import {DefaultUserOnly, LoginRequiredAllowSuspicious} from '~/middleware/AuthMiddleware';
|
||||
import {CaptchaMiddleware} from '~/middleware/CaptchaMiddleware';
|
||||
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
|
||||
import {SudoModeMiddleware} from '~/middleware/SudoModeMiddleware';
|
||||
import {RateLimitConfigs} from '~/RateLimitConfig';
|
||||
import {createStringType, SudoVerificationSchema, z} from '~/Schema';
|
||||
import {generateUsernameSuggestions} from '~/utils/UsernameSuggestionUtils';
|
||||
import {Validator} from '~/Validator';
|
||||
|
||||
export const AuthController = (app: HonoApp) => {
|
||||
app.post(
|
||||
'/auth/register',
|
||||
CaptchaMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_REGISTER),
|
||||
Validator('json', RegisterRequest),
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
const request = ctx.req.raw;
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const result = await ctx.get('authService').register({
|
||||
data,
|
||||
request,
|
||||
requestCache,
|
||||
});
|
||||
if (typeof result === 'string') {
|
||||
return ctx.json({token: result});
|
||||
}
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/login',
|
||||
CaptchaMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN),
|
||||
Validator('json', LoginRequest),
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
const request = ctx.req.raw;
|
||||
const result = await ctx.get('authService').login({
|
||||
data,
|
||||
request,
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/login/mfa/totp',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
code: createStringType(),
|
||||
ticket: createStringType(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const {code, ticket} = ctx.req.valid('json');
|
||||
const request = ctx.req.raw;
|
||||
const result = await ctx.get('authService').loginMfaTotp({
|
||||
code,
|
||||
ticket,
|
||||
request,
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/login/mfa/sms/send',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
|
||||
Validator('json', z.object({ticket: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {ticket} = ctx.req.valid('json');
|
||||
await ctx.get('authService').sendSmsMfaCodeForTicket(ticket);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/login/mfa/sms',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
code: createStringType(),
|
||||
ticket: createStringType(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const {code, ticket} = ctx.req.valid('json');
|
||||
const request = ctx.req.raw;
|
||||
const result = await ctx.get('authService').loginMfaSms({
|
||||
code,
|
||||
ticket,
|
||||
request,
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post('/auth/logout', RateLimitMiddleware(RateLimitConfigs.AUTH_LOGOUT), async (ctx) => {
|
||||
const token = ctx.req.header('Authorization') ?? ctx.get('authToken');
|
||||
if (token) {
|
||||
await ctx.get('authService').revokeToken(token);
|
||||
}
|
||||
return ctx.body(null, 204);
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/auth/verify',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_VERIFY_EMAIL),
|
||||
Validator('json', VerifyEmailRequest),
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
const success = await ctx.get('authService').verifyEmail(data);
|
||||
if (!success) {
|
||||
throw InputValidationError.create('token', 'Invalid or expired verification token');
|
||||
}
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/verify/resend',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_RESEND_VERIFICATION),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
await ctx.get('authService').resendVerificationEmail(user);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/forgot',
|
||||
CaptchaMiddleware,
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_FORGOT_PASSWORD),
|
||||
Validator('json', ForgotPasswordRequest),
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
const request = ctx.req.raw;
|
||||
await ctx.get('authService').forgotPassword({
|
||||
data,
|
||||
request,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/reset',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_RESET_PASSWORD),
|
||||
Validator('json', ResetPasswordRequest),
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
const request = ctx.req.raw;
|
||||
const result = await ctx.get('authService').resetPassword({
|
||||
data,
|
||||
request,
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/email-revert',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_EMAIL_REVERT),
|
||||
Validator('json', EmailRevertRequest),
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
const request = ctx.req.raw;
|
||||
const result = await ctx.get('authService').revertEmailChange({
|
||||
data,
|
||||
request,
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/auth/sessions',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_SESSIONS_GET),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
return ctx.json(await ctx.get('authService').getAuthSessions(userId));
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/sessions/logout',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_SESSIONS_LOGOUT),
|
||||
LoginRequiredAllowSuspicious,
|
||||
DefaultUserOnly,
|
||||
SudoModeMiddleware,
|
||||
Validator('json', LogoutAuthSessionsRequest.merge(SudoVerificationSchema)),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.req.valid('json');
|
||||
await requireSudoMode(ctx, user, body, ctx.get('authService'), ctx.get('authMfaService'));
|
||||
await ctx.get('authService').logoutAuthSessions({
|
||||
user,
|
||||
sessionIdHashes: body.session_id_hashes,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/authorize-ip',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_AUTHORIZE_IP),
|
||||
Validator('json', z.object({token: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {token} = ctx.req.valid('json');
|
||||
const result = await ctx.get('authService').completeIpAuthorization(token);
|
||||
await ctx
|
||||
.get('cacheService')
|
||||
.publish(`ip-auth:${result.ticket}`, JSON.stringify({token: result.token, user_id: result.user_id}));
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/ip-authorization/resend',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_IP_AUTHORIZATION_RESEND),
|
||||
Validator('json', z.object({ticket: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {ticket} = ctx.req.valid('json');
|
||||
await ctx.get('authService').resendIpAuthorization(ticket);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/auth/ip-authorization/stream',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_IP_AUTHORIZATION_STREAM),
|
||||
Validator('query', z.object({ticket: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {ticket} = ctx.req.valid('query');
|
||||
const cacheKey = `ip-auth-ticket:${ticket}`;
|
||||
const cacheService = ctx.get('cacheService');
|
||||
const payload = await cacheService.get(cacheKey);
|
||||
if (!payload) {
|
||||
throw InputValidationError.create('ticket', 'Invalid or expired authorization ticket');
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const subscriber = new Redis(Config.redis.url);
|
||||
const channel = `ip-auth:${ticket}`;
|
||||
|
||||
let closed = false;
|
||||
const close = async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
await subscriber.unsubscribe(channel);
|
||||
await subscriber.quit();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: string) => controller.enqueue(encoder.encode(data));
|
||||
send(': connected\n\n');
|
||||
|
||||
const keepAlive = setInterval(() => {
|
||||
if (closed) return;
|
||||
send(': keepalive\n\n');
|
||||
}, 15000);
|
||||
|
||||
await subscriber.subscribe(channel);
|
||||
subscriber.on('message', (_ch, message) => {
|
||||
send(`data: ${message}\n\n`);
|
||||
clearInterval(keepAlive);
|
||||
close().catch(() => {});
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
close().catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/webauthn/authentication-options',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_WEBAUTHN_OPTIONS),
|
||||
async (ctx) => {
|
||||
const options = await ctx.get('authService').generateWebAuthnAuthenticationOptionsDiscoverable();
|
||||
return ctx.json(options);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/webauthn/authenticate',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_WEBAUTHN_AUTHENTICATE),
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
response: z.custom<AuthenticationResponseJSON>(),
|
||||
challenge: createStringType(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const {response, challenge} = ctx.req.valid('json');
|
||||
const user = await ctx.get('authService').verifyWebAuthnAuthenticationDiscoverable(response, challenge);
|
||||
const request = ctx.req.raw;
|
||||
const [token] = await ctx.get('authService').createAuthSession({user, request});
|
||||
return ctx.json({token, user_id: user.id.toString()});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/login/mfa/webauthn/authentication-options',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
|
||||
Validator('json', z.object({ticket: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {ticket} = ctx.req.valid('json');
|
||||
const options = await ctx.get('authService').generateWebAuthnAuthenticationOptionsForMfa(ticket);
|
||||
return ctx.json(options);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/login/mfa/webauthn',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN_MFA),
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
response: z.custom<AuthenticationResponseJSON>(),
|
||||
challenge: createStringType(),
|
||||
ticket: createStringType(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const {response, challenge, ticket} = ctx.req.valid('json');
|
||||
const request = ctx.req.raw;
|
||||
const result = await ctx.get('authService').loginMfaWebAuthn({
|
||||
response,
|
||||
challenge,
|
||||
ticket,
|
||||
request,
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/redeem-beta-code',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_REGISTER),
|
||||
LoginRequiredAllowSuspicious,
|
||||
Validator('json', z.object({beta_code: createStringType()})),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const {beta_code} = ctx.req.valid('json');
|
||||
await ctx.get('authService').redeemBetaCode(user.id, beta_code);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/auth/username-suggestions',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_REGISTER),
|
||||
Validator('json', UsernameSuggestionsRequest),
|
||||
async (ctx) => {
|
||||
const {global_name} = ctx.req.valid('json');
|
||||
const suggestions = generateUsernameSuggestions(global_name, 5);
|
||||
return ctx.json({suggestions});
|
||||
},
|
||||
);
|
||||
|
||||
app.post('/auth/handoff/initiate', RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_INITIATE), async (ctx) => {
|
||||
const userAgent = ctx.req.header('User-Agent');
|
||||
const result = await ctx.get('desktopHandoffService').initiateHandoff(userAgent);
|
||||
return ctx.json({
|
||||
code: result.code,
|
||||
expires_at: result.expiresAt.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/auth/handoff/complete',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_COMPLETE),
|
||||
Validator(
|
||||
'json',
|
||||
z.object({
|
||||
code: createStringType(),
|
||||
token: createStringType(),
|
||||
user_id: createStringType(),
|
||||
}),
|
||||
),
|
||||
async (ctx) => {
|
||||
const {code, token, user_id} = ctx.req.valid('json');
|
||||
const {token: handoffToken, userId} = await ctx.get('authService').createAdditionalAuthSessionFromToken({
|
||||
token,
|
||||
expectedUserId: user_id,
|
||||
request: ctx.req.raw,
|
||||
});
|
||||
|
||||
await ctx.get('desktopHandoffService').completeHandoff(code, handoffToken, userId);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/auth/handoff/:code/status',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_STATUS),
|
||||
Validator('param', z.object({code: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {code} = ctx.req.valid('param');
|
||||
const result = await ctx.get('desktopHandoffService').getHandoffStatus(code);
|
||||
return ctx.json({
|
||||
status: result.status,
|
||||
token: result.token,
|
||||
user_id: result.userId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/auth/handoff/:code',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_CANCEL),
|
||||
Validator('param', z.object({code: createStringType()})),
|
||||
async (ctx) => {
|
||||
const {code} = ctx.req.valid('param');
|
||||
await ctx.get('desktopHandoffService').cancelHandoff(code);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
};
|
||||
113
fluxer_api/src/auth/AuthModel.ts
Normal file
113
fluxer_api/src/auth/AuthModel.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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 {uint8ArrayToBase64} from 'uint8array-extras';
|
||||
import type {AuthSession} from '~/Models';
|
||||
import {createStringType, EmailType, GlobalNameType, PasswordType, UsernameType, z} from '~/Schema';
|
||||
import {UNKNOWN_LOCATION} from '~/utils/IpUtils';
|
||||
|
||||
export const RegisterRequest = z.object({
|
||||
email: EmailType.optional(),
|
||||
username: UsernameType.optional(),
|
||||
global_name: GlobalNameType.optional(),
|
||||
password: PasswordType.optional(),
|
||||
beta_code: createStringType(0, 256).nullish(),
|
||||
date_of_birth: createStringType(10, 10).refine((value) => /^\d{4}-\d{2}-\d{2}$/.test(value), 'Invalid date format'),
|
||||
consent: z.boolean(),
|
||||
invite_code: createStringType(0, 256).nullish(),
|
||||
});
|
||||
|
||||
export const UsernameSuggestionsRequest = z.object({
|
||||
global_name: GlobalNameType,
|
||||
});
|
||||
|
||||
export type RegisterRequest = z.infer<typeof RegisterRequest>;
|
||||
|
||||
export type UsernameSuggestionsRequest = z.infer<typeof UsernameSuggestionsRequest>;
|
||||
|
||||
export const LoginRequest = z.object({
|
||||
email: EmailType,
|
||||
password: PasswordType,
|
||||
invite_code: createStringType(0, 256).nullish(),
|
||||
});
|
||||
|
||||
export type LoginRequest = z.infer<typeof LoginRequest>;
|
||||
|
||||
export const LogoutAuthSessionsRequest = z.object({
|
||||
session_id_hashes: z.array(createStringType()).max(100),
|
||||
password: PasswordType.optional(),
|
||||
});
|
||||
|
||||
export const ForgotPasswordRequest = z.object({
|
||||
email: EmailType,
|
||||
});
|
||||
|
||||
export type ForgotPasswordRequest = z.infer<typeof ForgotPasswordRequest>;
|
||||
|
||||
export const ResetPasswordRequest = z.object({
|
||||
token: createStringType(64, 64),
|
||||
password: PasswordType,
|
||||
});
|
||||
|
||||
export type ResetPasswordRequest = z.infer<typeof ResetPasswordRequest>;
|
||||
|
||||
export const EmailRevertRequest = z.object({
|
||||
token: createStringType(64, 64),
|
||||
password: PasswordType,
|
||||
});
|
||||
|
||||
export type EmailRevertRequest = z.infer<typeof EmailRevertRequest>;
|
||||
|
||||
export const VerifyEmailRequest = z.object({
|
||||
token: createStringType(64, 64),
|
||||
});
|
||||
|
||||
export type VerifyEmailRequest = z.infer<typeof VerifyEmailRequest>;
|
||||
|
||||
export const mapAuthSessionsToResponse = ({
|
||||
authSessions,
|
||||
}: {
|
||||
authSessions: Array<AuthSession>;
|
||||
}): Array<AuthSessionResponse> => {
|
||||
return authSessions
|
||||
.sort((a, b) => {
|
||||
const aTime = a.approximateLastUsedAt?.getTime() || 0;
|
||||
const bTime = b.approximateLastUsedAt?.getTime() || 0;
|
||||
return bTime - aTime;
|
||||
})
|
||||
.map((authSession): AuthSessionResponse => {
|
||||
return {
|
||||
id: uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true}),
|
||||
approx_last_used_at: authSession.approximateLastUsedAt?.toISOString() || null,
|
||||
client_os: authSession.clientOs,
|
||||
client_platform: authSession.clientPlatform,
|
||||
client_location: authSession.clientLocation ?? UNKNOWN_LOCATION,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const AuthSessionResponse = z.object({
|
||||
id: z.string(),
|
||||
approx_last_used_at: z.iso.datetime().nullish(),
|
||||
client_os: z.string(),
|
||||
client_platform: z.string(),
|
||||
client_location: z.string(),
|
||||
});
|
||||
|
||||
export type AuthSessionResponse = z.infer<typeof AuthSessionResponse>;
|
||||
592
fluxer_api/src/auth/AuthService.ts
Normal file
592
fluxer_api/src/auth/AuthService.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
/*
|
||||
* 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 {AuthenticationResponseJSON, RegistrationResponseJSON} from '@simplewebauthn/server';
|
||||
import type {
|
||||
AuthSessionResponse,
|
||||
EmailRevertRequest,
|
||||
ForgotPasswordRequest,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
ResetPasswordRequest,
|
||||
VerifyEmailRequest,
|
||||
} from '~/auth/AuthModel';
|
||||
import {AuthEmailRevertService} from '~/auth/services/AuthEmailRevertService';
|
||||
import {AuthEmailService} from '~/auth/services/AuthEmailService';
|
||||
import {AuthLoginService} from '~/auth/services/AuthLoginService';
|
||||
import {AuthMfaService} from '~/auth/services/AuthMfaService';
|
||||
import {AuthPasswordService} from '~/auth/services/AuthPasswordService';
|
||||
import {AuthPhoneService} from '~/auth/services/AuthPhoneService';
|
||||
import {AuthRegistrationService} from '~/auth/services/AuthRegistrationService';
|
||||
import {AuthSessionService} from '~/auth/services/AuthSessionService';
|
||||
import {AuthUtilityService} from '~/auth/services/AuthUtilityService';
|
||||
import {createMfaTicket, type UserID} from '~/BrandedTypes';
|
||||
import {APIErrorCodes, UserAuthenticatorTypes} from '~/Constants';
|
||||
import {FluxerAPIError} from '~/Errors';
|
||||
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import type {ISMSService} from '~/infrastructure/ISMSService';
|
||||
import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteStore';
|
||||
import type {RedisAccountDeletionQueueService} from '~/infrastructure/RedisAccountDeletionQueueService';
|
||||
import type {RedisActivityTracker} from '~/infrastructure/RedisActivityTracker';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import type {InviteService} from '~/invite/InviteService';
|
||||
import type {AuthSession, User} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
|
||||
import {randomString} from '~/utils/RandomUtils';
|
||||
|
||||
interface RegisterParams {
|
||||
data: RegisterRequest;
|
||||
request: Request;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
interface LoginParams {
|
||||
data: LoginRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface LoginMfaTotpParams {
|
||||
code: string;
|
||||
ticket: string;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface ForgotPasswordParams {
|
||||
data: ForgotPasswordRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface ResetPasswordParams {
|
||||
data: ResetPasswordRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface RevertEmailChangeParams {
|
||||
data: EmailRevertRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface LogoutAuthSessionsParams {
|
||||
user: User;
|
||||
sessionIdHashes: Array<string>;
|
||||
}
|
||||
|
||||
interface CreateAuthSessionParams {
|
||||
user: User;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface DispatchAuthSessionChangeParams {
|
||||
userId: UserID;
|
||||
oldAuthSessionIdHash: string;
|
||||
newAuthSessionIdHash: string;
|
||||
newToken: string;
|
||||
}
|
||||
|
||||
interface VerifyPasswordParams {
|
||||
password: string;
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
interface VerifyMfaCodeParams {
|
||||
userId: UserID;
|
||||
mfaSecret: string;
|
||||
code: string;
|
||||
allowBackup?: boolean;
|
||||
}
|
||||
|
||||
interface UpdateUserActivityParams {
|
||||
userId: UserID;
|
||||
clientIp: string;
|
||||
}
|
||||
|
||||
interface ValidateAgeParams {
|
||||
dateOfBirth: string;
|
||||
minAge: number;
|
||||
}
|
||||
|
||||
interface CheckEmailChangeRateLimitParams {
|
||||
userId: UserID;
|
||||
}
|
||||
|
||||
interface IAuthService {
|
||||
verifyPassword(params: {password: string; passwordHash: string}): Promise<boolean>;
|
||||
getUserSession(requestCache: RequestCache, token: string): Promise<AuthSession>;
|
||||
}
|
||||
|
||||
export class AuthService implements IAuthService {
|
||||
private sessionService: AuthSessionService;
|
||||
private passwordService: AuthPasswordService;
|
||||
private registrationService: AuthRegistrationService;
|
||||
private loginService: AuthLoginService;
|
||||
private emailService: AuthEmailService;
|
||||
private emailRevertService: AuthEmailRevertService;
|
||||
private phoneService: AuthPhoneService;
|
||||
private mfaService: AuthMfaService;
|
||||
private utilityService: AuthUtilityService;
|
||||
|
||||
constructor(
|
||||
private repository: IUserRepository,
|
||||
inviteService: InviteService,
|
||||
private cacheService: ICacheService,
|
||||
gatewayService: IGatewayService,
|
||||
rateLimitService: IRateLimitService,
|
||||
emailServiceDep: IEmailService,
|
||||
smsService: ISMSService,
|
||||
snowflakeService: SnowflakeService,
|
||||
discriminatorService: IDiscriminatorService,
|
||||
redisAccountDeletionQueue: RedisAccountDeletionQueueService,
|
||||
redisActivityTracker: RedisActivityTracker,
|
||||
pendingJoinInviteStore: PendingJoinInviteStore,
|
||||
private readonly contactChangeLogService: UserContactChangeLogService,
|
||||
botMfaMirrorService?: BotMfaMirrorService,
|
||||
authMfaService?: AuthMfaService,
|
||||
) {
|
||||
this.utilityService = new AuthUtilityService(repository, rateLimitService, gatewayService);
|
||||
|
||||
this.sessionService = new AuthSessionService(
|
||||
repository,
|
||||
gatewayService,
|
||||
this.utilityService.generateAuthToken.bind(this.utilityService),
|
||||
this.utilityService.getTokenIdHash.bind(this.utilityService),
|
||||
);
|
||||
|
||||
this.passwordService = new AuthPasswordService(
|
||||
repository,
|
||||
emailServiceDep,
|
||||
rateLimitService,
|
||||
this.utilityService.generateSecureToken.bind(this.utilityService),
|
||||
this.utilityService.handleBanStatus.bind(this.utilityService),
|
||||
this.utilityService.assertNonBotUser.bind(this.utilityService),
|
||||
this.createMfaTicketResponse.bind(this),
|
||||
this.sessionService.createAuthSession.bind(this.sessionService),
|
||||
);
|
||||
|
||||
this.mfaService =
|
||||
authMfaService ?? new AuthMfaService(repository, cacheService, smsService, gatewayService, botMfaMirrorService);
|
||||
|
||||
this.registrationService = new AuthRegistrationService(
|
||||
repository,
|
||||
inviteService,
|
||||
rateLimitService,
|
||||
emailServiceDep,
|
||||
snowflakeService,
|
||||
discriminatorService,
|
||||
redisActivityTracker,
|
||||
pendingJoinInviteStore,
|
||||
cacheService,
|
||||
this.passwordService.hashPassword.bind(this.passwordService),
|
||||
this.passwordService.isPasswordPwned.bind(this.passwordService),
|
||||
this.utilityService.validateAge.bind(this.utilityService),
|
||||
this.utilityService.generateSecureToken.bind(this.utilityService),
|
||||
this.sessionService.createAuthSession.bind(this.sessionService),
|
||||
);
|
||||
|
||||
this.loginService = new AuthLoginService(
|
||||
repository,
|
||||
inviteService,
|
||||
cacheService,
|
||||
rateLimitService,
|
||||
emailServiceDep,
|
||||
redisAccountDeletionQueue,
|
||||
this.passwordService.verifyPassword.bind(this.passwordService),
|
||||
this.utilityService.handleBanStatus.bind(this.utilityService),
|
||||
this.utilityService.assertNonBotUser.bind(this.utilityService),
|
||||
this.sessionService.createAuthSession.bind(this.sessionService),
|
||||
this.utilityService.generateSecureToken.bind(this.utilityService),
|
||||
this.mfaService.verifyMfaCode.bind(this.mfaService),
|
||||
this.mfaService.verifySmsMfaCode.bind(this.mfaService),
|
||||
this.mfaService.verifyWebAuthnAuthentication.bind(this.mfaService),
|
||||
);
|
||||
|
||||
this.emailService = new AuthEmailService(
|
||||
repository,
|
||||
emailServiceDep,
|
||||
gatewayService,
|
||||
rateLimitService,
|
||||
this.utilityService.assertNonBotUser.bind(this.utilityService),
|
||||
this.utilityService.generateSecureToken.bind(this.utilityService),
|
||||
);
|
||||
|
||||
this.emailRevertService = new AuthEmailRevertService(
|
||||
repository,
|
||||
emailServiceDep,
|
||||
gatewayService,
|
||||
this.passwordService.hashPassword.bind(this.passwordService),
|
||||
this.passwordService.isPasswordPwned.bind(this.passwordService),
|
||||
this.utilityService.handleBanStatus.bind(this.utilityService),
|
||||
this.utilityService.assertNonBotUser.bind(this.utilityService),
|
||||
this.utilityService.generateSecureToken.bind(this.utilityService),
|
||||
this.sessionService.createAuthSession.bind(this.sessionService),
|
||||
this.sessionService.terminateAllUserSessions.bind(this.sessionService),
|
||||
this.contactChangeLogService!,
|
||||
);
|
||||
|
||||
this.phoneService = new AuthPhoneService(
|
||||
repository,
|
||||
smsService,
|
||||
gatewayService,
|
||||
this.utilityService.assertNonBotUser.bind(this.utilityService),
|
||||
this.utilityService.generateSecureToken.bind(this.utilityService),
|
||||
this.contactChangeLogService!,
|
||||
);
|
||||
}
|
||||
|
||||
async register({
|
||||
data,
|
||||
request,
|
||||
requestCache,
|
||||
}: RegisterParams): Promise<{user_id: string; token: string; pending_verification?: boolean}> {
|
||||
return this.registrationService.register({data, request, requestCache});
|
||||
}
|
||||
|
||||
async login({
|
||||
data,
|
||||
request,
|
||||
}: LoginParams): Promise<
|
||||
| {mfa: false; user_id: string; token: string; pending_verification?: boolean}
|
||||
| {mfa: true; ticket: string; sms: boolean; totp: boolean; webauthn: boolean}
|
||||
> {
|
||||
return this.loginService.login({data, request});
|
||||
}
|
||||
|
||||
async loginMfaTotp({code, ticket, request}: LoginMfaTotpParams): Promise<{user_id: string; token: string}> {
|
||||
return this.loginService.loginMfaTotp({code, ticket, request});
|
||||
}
|
||||
|
||||
async loginMfaSms({
|
||||
code,
|
||||
ticket,
|
||||
request,
|
||||
}: {
|
||||
code: string;
|
||||
ticket: string;
|
||||
request: Request;
|
||||
}): Promise<{user_id: string; token: string}> {
|
||||
return this.loginService.loginMfaSms({code, ticket, request});
|
||||
}
|
||||
|
||||
async loginMfaWebAuthn({
|
||||
response,
|
||||
challenge,
|
||||
ticket,
|
||||
request,
|
||||
}: {
|
||||
response: AuthenticationResponseJSON;
|
||||
challenge: string;
|
||||
ticket: string;
|
||||
request: Request;
|
||||
}): Promise<{user_id: string; token: string}> {
|
||||
return this.loginService.loginMfaWebAuthn({response, challenge, ticket, request});
|
||||
}
|
||||
|
||||
async forgotPassword({data, request}: ForgotPasswordParams): Promise<void> {
|
||||
return this.passwordService.forgotPassword({data, request});
|
||||
}
|
||||
|
||||
async resetPassword({
|
||||
data,
|
||||
request,
|
||||
}: ResetPasswordParams): Promise<
|
||||
| {mfa: false; user_id: string; token: string}
|
||||
| {mfa: true; ticket: string; sms: boolean; totp: boolean; webauthn: boolean}
|
||||
> {
|
||||
return this.passwordService.resetPassword({data, request});
|
||||
}
|
||||
|
||||
async revertEmailChange({data, request}: RevertEmailChangeParams): Promise<{user_id: string; token: string}> {
|
||||
return this.emailRevertService.revertEmailChange({
|
||||
token: data.token,
|
||||
password: data.password,
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
async issueEmailRevertToken(user: User, previousEmail: string, newEmail: string): Promise<void> {
|
||||
return this.emailRevertService.issueRevertToken({user, previousEmail, newEmail});
|
||||
}
|
||||
|
||||
async hashPassword(password: string): Promise<string> {
|
||||
return this.passwordService.hashPassword(password);
|
||||
}
|
||||
|
||||
async verifyPassword({password, passwordHash}: VerifyPasswordParams): Promise<boolean> {
|
||||
return this.passwordService.verifyPassword({password, passwordHash});
|
||||
}
|
||||
|
||||
async isPasswordPwned(password: string): Promise<boolean> {
|
||||
return this.passwordService.isPasswordPwned(password);
|
||||
}
|
||||
|
||||
async verifyEmail(data: VerifyEmailRequest): Promise<boolean> {
|
||||
return this.emailService.verifyEmail(data);
|
||||
}
|
||||
|
||||
async resendVerificationEmail(user: User): Promise<void> {
|
||||
return this.emailService.resendVerificationEmail(user);
|
||||
}
|
||||
|
||||
async getAuthSessionByToken(token: string): Promise<AuthSession | null> {
|
||||
return this.sessionService.getAuthSessionByToken(token);
|
||||
}
|
||||
|
||||
async getAuthSessions(userId: UserID): Promise<Array<AuthSessionResponse>> {
|
||||
return this.sessionService.getAuthSessions(userId);
|
||||
}
|
||||
|
||||
async createAdditionalAuthSessionFromToken({
|
||||
token,
|
||||
expectedUserId,
|
||||
request,
|
||||
}: {
|
||||
token: string;
|
||||
expectedUserId?: string;
|
||||
request: Request;
|
||||
}): Promise<{token: string; userId: string}> {
|
||||
const existingSession = await this.sessionService.getAuthSessionByToken(token);
|
||||
|
||||
if (!existingSession) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.INVALID_TOKEN,
|
||||
message: 'Invalid or expired session token',
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await this.repository.findUnique(existingSession.userId);
|
||||
if (!user) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.UNKNOWN_USER,
|
||||
message: 'User not found for session token',
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (expectedUserId && user.id.toString() !== expectedUserId) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.INVALID_REQUEST,
|
||||
message: 'Session token does not match provided user',
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const [newToken] = await this.sessionService.createAuthSession({user, request});
|
||||
|
||||
return {token: newToken, userId: user.id.toString()};
|
||||
}
|
||||
|
||||
async createAuthSession({user, request}: CreateAuthSessionParams): Promise<[token: string, AuthSession]> {
|
||||
return this.sessionService.createAuthSession({user, request});
|
||||
}
|
||||
|
||||
async updateAuthSessionLastUsed(tokenHash: Uint8Array): Promise<void> {
|
||||
return this.sessionService.updateAuthSessionLastUsed(tokenHash);
|
||||
}
|
||||
|
||||
async updateUserActivity({userId, clientIp}: UpdateUserActivityParams): Promise<void> {
|
||||
return this.sessionService.updateUserActivity({userId, clientIp});
|
||||
}
|
||||
|
||||
async revokeToken(token: string): Promise<void> {
|
||||
return this.sessionService.revokeToken(token);
|
||||
}
|
||||
|
||||
async logoutAuthSessions({user, sessionIdHashes}: LogoutAuthSessionsParams): Promise<void> {
|
||||
return this.sessionService.logoutAuthSessions({user, sessionIdHashes});
|
||||
}
|
||||
|
||||
async terminateAllUserSessions(userId: UserID): Promise<void> {
|
||||
return this.sessionService.terminateAllUserSessions(userId);
|
||||
}
|
||||
|
||||
async dispatchAuthSessionChange({
|
||||
userId,
|
||||
oldAuthSessionIdHash,
|
||||
newAuthSessionIdHash,
|
||||
newToken,
|
||||
}: DispatchAuthSessionChangeParams): Promise<void> {
|
||||
return this.sessionService.dispatchAuthSessionChange({
|
||||
userId,
|
||||
oldAuthSessionIdHash,
|
||||
newAuthSessionIdHash,
|
||||
newToken,
|
||||
});
|
||||
}
|
||||
|
||||
async getUserSession(_requestCache: RequestCache, token: string): Promise<AuthSession> {
|
||||
const session = await this.getAuthSessionByToken(token);
|
||||
if (!session) {
|
||||
throw new FluxerAPIError({code: APIErrorCodes.UNAUTHORIZED, message: 'Invalid session token', status: 401});
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
async sendPhoneVerificationCode(phone: string, userId: UserID | null): Promise<void> {
|
||||
return this.phoneService.sendPhoneVerificationCode(phone, userId);
|
||||
}
|
||||
|
||||
async verifyPhoneCode(phone: string, code: string, userId: UserID | null): Promise<string> {
|
||||
return this.phoneService.verifyPhoneCode(phone, code, userId);
|
||||
}
|
||||
|
||||
async addPhoneToAccount(userId: UserID, phoneToken: string): Promise<void> {
|
||||
return this.phoneService.addPhoneToAccount(userId, phoneToken);
|
||||
}
|
||||
|
||||
async removePhoneFromAccount(userId: UserID): Promise<void> {
|
||||
return this.phoneService.removePhoneFromAccount(userId);
|
||||
}
|
||||
|
||||
async verifyMfaCode({userId, mfaSecret, code, allowBackup = false}: VerifyMfaCodeParams): Promise<boolean> {
|
||||
return this.mfaService.verifyMfaCode({userId, mfaSecret, code, allowBackup});
|
||||
}
|
||||
|
||||
async enableSmsMfa(userId: UserID): Promise<void> {
|
||||
return this.mfaService.enableSmsMfa(userId);
|
||||
}
|
||||
|
||||
async disableSmsMfa(userId: UserID): Promise<void> {
|
||||
return this.mfaService.disableSmsMfa(userId);
|
||||
}
|
||||
|
||||
async sendSmsMfaCode(userId: UserID): Promise<void> {
|
||||
return this.mfaService.sendSmsMfaCode(userId);
|
||||
}
|
||||
|
||||
async sendSmsMfaCodeForTicket(ticket: string): Promise<void> {
|
||||
return this.mfaService.sendSmsMfaCodeForTicket(ticket);
|
||||
}
|
||||
|
||||
async verifySmsMfaCode(userId: UserID, code: string): Promise<boolean> {
|
||||
return this.mfaService.verifySmsMfaCode(userId, code);
|
||||
}
|
||||
|
||||
async generateWebAuthnRegistrationOptions(userId: UserID) {
|
||||
return this.mfaService.generateWebAuthnRegistrationOptions(userId);
|
||||
}
|
||||
|
||||
async verifyWebAuthnRegistration(
|
||||
userId: UserID,
|
||||
response: RegistrationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
return this.mfaService.verifyWebAuthnRegistration(userId, response, expectedChallenge, name);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.mfaService.deleteWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
async renameWebAuthnCredential(userId: UserID, credentialId: string, name: string): Promise<void> {
|
||||
return this.mfaService.renameWebAuthnCredential(userId, credentialId, name);
|
||||
}
|
||||
|
||||
async generateWebAuthnAuthenticationOptionsDiscoverable() {
|
||||
return this.mfaService.generateWebAuthnAuthenticationOptionsDiscoverable();
|
||||
}
|
||||
|
||||
async verifyWebAuthnAuthenticationDiscoverable(
|
||||
response: AuthenticationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
): Promise<User> {
|
||||
return this.mfaService.verifyWebAuthnAuthenticationDiscoverable(response, expectedChallenge);
|
||||
}
|
||||
|
||||
async generateWebAuthnAuthenticationOptionsForMfa(ticket: string) {
|
||||
return this.mfaService.generateWebAuthnAuthenticationOptionsForMfa(ticket);
|
||||
}
|
||||
|
||||
async verifyWebAuthnAuthentication(
|
||||
userId: UserID,
|
||||
response: AuthenticationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
): Promise<void> {
|
||||
return this.mfaService.verifyWebAuthnAuthentication(userId, response, expectedChallenge);
|
||||
}
|
||||
|
||||
async generateSecureToken(length = 64): Promise<string> {
|
||||
return this.utilityService.generateSecureToken(length);
|
||||
}
|
||||
|
||||
async generateAuthToken(): Promise<string> {
|
||||
return this.utilityService.generateAuthToken();
|
||||
}
|
||||
|
||||
generateBackupCodes(): Array<string> {
|
||||
return this.utilityService.generateBackupCodes();
|
||||
}
|
||||
|
||||
async checkEmailChangeRateLimit({
|
||||
userId,
|
||||
}: CheckEmailChangeRateLimitParams): Promise<{allowed: boolean; retryAfter?: number}> {
|
||||
return this.utilityService.checkEmailChangeRateLimit({userId});
|
||||
}
|
||||
|
||||
validateAge({dateOfBirth, minAge}: ValidateAgeParams): boolean {
|
||||
return this.utilityService.validateAge({dateOfBirth, minAge});
|
||||
}
|
||||
|
||||
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
|
||||
return this.utilityService.authorizeIpByToken(token);
|
||||
}
|
||||
|
||||
async resendIpAuthorization(ticket: string): Promise<{retryAfter?: number}> {
|
||||
return this.loginService.resendIpAuthorization(ticket);
|
||||
}
|
||||
|
||||
async completeIpAuthorization(token: string): Promise<{token: string; user_id: string; ticket: string}> {
|
||||
return this.loginService.completeIpAuthorization(token);
|
||||
}
|
||||
|
||||
async redeemBetaCode(userId: UserID, betaCode: string): Promise<void> {
|
||||
return this.utilityService.redeemBetaCode(userId, betaCode);
|
||||
}
|
||||
|
||||
private async createMfaTicketResponse(user: User): Promise<{
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
sms: boolean;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
}> {
|
||||
const ticket = createMfaTicket(randomString(64));
|
||||
await this.cacheService.set(`mfa-ticket:${ticket}`, user.id.toString(), 60 * 5);
|
||||
|
||||
const credentials = await this.repository.listWebAuthnCredentials(user.id);
|
||||
const hasSms = user.authenticatorTypes.has(UserAuthenticatorTypes.SMS);
|
||||
const hasWebauthn = credentials.length > 0;
|
||||
const hasTotp = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
|
||||
|
||||
return {
|
||||
mfa: true,
|
||||
ticket: ticket,
|
||||
sms: hasSms,
|
||||
totp: hasTotp,
|
||||
webauthn: hasWebauthn,
|
||||
};
|
||||
}
|
||||
}
|
||||
139
fluxer_api/src/auth/services/AuthEmailRevertService.ts
Normal file
139
fluxer_api/src/auth/services/AuthEmailRevertService.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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 {UserID} from '~/BrandedTypes';
|
||||
import {createEmailRevertToken} from '~/BrandedTypes';
|
||||
import {InputValidationError, UnauthorizedError} from '~/Errors';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import {getUserSearchService} from '~/Meilisearch';
|
||||
import type {AuthSession, User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
|
||||
import {mapUserToPrivateResponse} from '~/user/UserModel';
|
||||
import * as IpUtils from '~/utils/IpUtils';
|
||||
|
||||
interface IssueTokenParams {
|
||||
user: User;
|
||||
previousEmail: string;
|
||||
newEmail: string;
|
||||
}
|
||||
|
||||
interface RevertParams {
|
||||
token: string;
|
||||
password: string;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
export class AuthEmailRevertService {
|
||||
constructor(
|
||||
private readonly repository: IUserRepository,
|
||||
private readonly emailService: IEmailService,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly hashPassword: (password: string) => Promise<string>,
|
||||
private readonly isPasswordPwned: (password: string) => Promise<boolean>,
|
||||
private readonly handleBanStatus: (user: User) => Promise<User>,
|
||||
private readonly assertNonBotUser: (user: User) => void,
|
||||
private readonly generateSecureToken: () => Promise<string>,
|
||||
private readonly createAuthSession: (params: {user: User; request: Request}) => Promise<[string, AuthSession]>,
|
||||
private readonly terminateAllUserSessions: (userId: UserID) => Promise<void>,
|
||||
private readonly contactChangeLogService: UserContactChangeLogService,
|
||||
) {}
|
||||
|
||||
async issueRevertToken(params: IssueTokenParams): Promise<void> {
|
||||
const {user, previousEmail, newEmail} = params;
|
||||
const trimmed = previousEmail.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const token = createEmailRevertToken(await this.generateSecureToken());
|
||||
await this.repository.createEmailRevertToken({
|
||||
token_: token,
|
||||
user_id: user.id,
|
||||
email: trimmed,
|
||||
});
|
||||
|
||||
await this.emailService.sendEmailChangeRevert(trimmed, user.username, newEmail, token, user.locale);
|
||||
}
|
||||
|
||||
async revertEmailChange(params: RevertParams): Promise<{user_id: string; token: string}> {
|
||||
const {token, password, request} = params;
|
||||
const tokenData = await this.repository.getEmailRevertToken(token);
|
||||
if (!tokenData) {
|
||||
throw InputValidationError.create('token', 'Invalid or expired revert token');
|
||||
}
|
||||
|
||||
const user = await this.repository.findUnique(tokenData.userId);
|
||||
if (!user) {
|
||||
throw InputValidationError.create('token', 'Invalid or expired revert token');
|
||||
}
|
||||
|
||||
this.assertNonBotUser(user);
|
||||
await this.handleBanStatus(user);
|
||||
|
||||
if (await this.isPasswordPwned(password)) {
|
||||
throw InputValidationError.create('password', 'Password is too common');
|
||||
}
|
||||
|
||||
const passwordHash = await this.hashPassword(password);
|
||||
const now = new Date();
|
||||
|
||||
const updatedUser = await this.repository.patchUpsert(user.id, {
|
||||
email: tokenData.email,
|
||||
email_verified: true,
|
||||
phone: null,
|
||||
totp_secret: null,
|
||||
authenticator_types: null,
|
||||
password_hash: passwordHash,
|
||||
password_last_changed_at: now,
|
||||
});
|
||||
|
||||
if (!updatedUser) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
await this.repository.deleteEmailRevertToken(token);
|
||||
await this.repository.deleteAllMfaBackupCodes(user.id);
|
||||
await this.repository.deleteAllWebAuthnCredentials(user.id);
|
||||
await this.repository.deleteAllAuthorizedIps(user.id);
|
||||
await this.terminateAllUserSessions(user.id);
|
||||
await this.repository.createAuthorizedIp(user.id, IpUtils.requireClientIp(request));
|
||||
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && updatedUser) {
|
||||
await userSearchService.updateUser(updatedUser).catch(() => {});
|
||||
}
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: updatedUser.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser),
|
||||
});
|
||||
|
||||
const [authToken] = await this.createAuthSession({user: updatedUser, request});
|
||||
|
||||
await this.contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser,
|
||||
reason: 'user_requested',
|
||||
actorUserId: user.id,
|
||||
});
|
||||
|
||||
return {user_id: updatedUser.id.toString(), token: authToken};
|
||||
}
|
||||
}
|
||||
137
fluxer_api/src/auth/services/AuthEmailService.ts
Normal file
137
fluxer_api/src/auth/services/AuthEmailService.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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 {VerifyEmailRequest} from '~/auth/AuthModel';
|
||||
import {createEmailVerificationToken} from '~/BrandedTypes';
|
||||
import {SuspiciousActivityFlags, UserFlags} from '~/Constants';
|
||||
import {RateLimitError} from '~/Errors';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import {Logger} from '~/Logger';
|
||||
import {getUserSearchService} from '~/Meilisearch';
|
||||
import type {User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {mapUserToPrivateResponse} from '~/user/UserModel';
|
||||
|
||||
const EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS =
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
|
||||
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL |
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE |
|
||||
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE |
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE |
|
||||
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE;
|
||||
|
||||
export class AuthEmailService {
|
||||
constructor(
|
||||
private repository: IUserRepository,
|
||||
private emailService: IEmailService,
|
||||
private gatewayService: IGatewayService,
|
||||
private rateLimitService: IRateLimitService,
|
||||
private assertNonBotUser: (user: User) => void,
|
||||
private generateSecureToken: () => Promise<string>,
|
||||
) {}
|
||||
|
||||
async verifyEmail(data: VerifyEmailRequest): Promise<boolean> {
|
||||
const tokenData = await this.repository.getEmailVerificationToken(data.token);
|
||||
if (!tokenData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = await this.repository.findUnique(tokenData.userId);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.assertNonBotUser(user);
|
||||
|
||||
if (user.flags & UserFlags.DELETED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updates: {email_verified: boolean; suspicious_activity_flags?: number} = {
|
||||
email_verified: true,
|
||||
};
|
||||
|
||||
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
|
||||
const newFlags = user.suspiciousActivityFlags & ~EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS;
|
||||
if (newFlags !== user.suspiciousActivityFlags) {
|
||||
updates.suspicious_activity_flags = newFlags;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await this.repository.patchUpsert(user.id, updates);
|
||||
await this.repository.deleteEmailVerificationToken(data.token);
|
||||
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && updatedUser) {
|
||||
await userSearchService.updateUser(updatedUser).catch((error) => {
|
||||
Logger.error({userId: user.id, error}, 'Failed to update user in search');
|
||||
});
|
||||
}
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: user.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser!),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async resendVerificationEmail(user: User): Promise<void> {
|
||||
this.assertNonBotUser(user);
|
||||
|
||||
const allowReverification =
|
||||
user.suspiciousActivityFlags !== null &&
|
||||
((user.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL) !== 0 ||
|
||||
(user.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE) !== 0 ||
|
||||
(user.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE) !== 0 ||
|
||||
(user.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE) !== 0);
|
||||
|
||||
if (user.emailVerified && !allowReverification) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rateLimit = await this.rateLimitService.checkLimit({
|
||||
identifier: `email_verification:${user.email!}`,
|
||||
maxAttempts: 3,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
const resetTime = new Date(Date.now() + (rateLimit.retryAfter || 0) * 1000);
|
||||
throw new RateLimitError({
|
||||
message: 'Too many verification email requests. Please try again later.',
|
||||
retryAfter: rateLimit.retryAfter || 0,
|
||||
limit: 3,
|
||||
resetTime,
|
||||
});
|
||||
}
|
||||
|
||||
const emailVerifyToken = createEmailVerificationToken(await this.generateSecureToken());
|
||||
await this.repository.createEmailVerificationToken({
|
||||
token_: emailVerifyToken,
|
||||
user_id: user.id,
|
||||
email: user.email!,
|
||||
});
|
||||
|
||||
await this.emailService.sendEmailVerification(user.email!, user.username, emailVerifyToken, user.locale);
|
||||
}
|
||||
}
|
||||
586
fluxer_api/src/auth/services/AuthLoginService.ts
Normal file
586
fluxer_api/src/auth/services/AuthLoginService.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
/*
|
||||
* 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 {AuthenticationResponseJSON} from '@simplewebauthn/server';
|
||||
import type {LoginRequest} from '~/auth/AuthModel';
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {
|
||||
createInviteCode,
|
||||
createIpAuthorizationTicket,
|
||||
createIpAuthorizationToken,
|
||||
createMfaTicket,
|
||||
createUserID,
|
||||
} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {APIErrorCodes, UserAuthenticatorTypes, UserFlags} from '~/Constants';
|
||||
import {FluxerAPIError, InputValidationError} from '~/Errors';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import {getMetricsService} from '~/infrastructure/MetricsService';
|
||||
import type {RedisAccountDeletionQueueService} from '~/infrastructure/RedisAccountDeletionQueueService';
|
||||
import type {InviteService} from '~/invite/InviteService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {AuthSession, User} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import * as IpUtils from '~/utils/IpUtils';
|
||||
import * as RandomUtils from '~/utils/RandomUtils';
|
||||
|
||||
function createRequestCache(): RequestCache {
|
||||
return {
|
||||
userPartials: new Map(),
|
||||
clear: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
interface LoginParams {
|
||||
data: LoginRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface LoginMfaTotpParams {
|
||||
code: string;
|
||||
ticket: string;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
export interface IpAuthorizationTicketCache {
|
||||
userId: string;
|
||||
email: string;
|
||||
username: string;
|
||||
clientIp: string;
|
||||
userAgent: string;
|
||||
platform: string | null;
|
||||
authToken: string;
|
||||
clientLocation: string;
|
||||
inviteCode?: string | null;
|
||||
resendUsed?: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export class AuthLoginService {
|
||||
constructor(
|
||||
private repository: IUserRepository,
|
||||
private inviteService: InviteService,
|
||||
private cacheService: ICacheService,
|
||||
private rateLimitService: IRateLimitService,
|
||||
private emailService: IEmailService,
|
||||
private redisDeletionQueue: RedisAccountDeletionQueueService,
|
||||
private verifyPassword: (params: {password: string; passwordHash: string}) => Promise<boolean>,
|
||||
private handleBanStatus: (user: User) => Promise<User>,
|
||||
private assertNonBotUser: (user: User) => void,
|
||||
private createAuthSession: (params: {user: User; request: Request}) => Promise<[string, AuthSession]>,
|
||||
private generateSecureToken: () => Promise<string>,
|
||||
private verifyMfaCode: (params: {
|
||||
userId: UserID;
|
||||
mfaSecret: string;
|
||||
code: string;
|
||||
allowBackup?: boolean;
|
||||
}) => Promise<boolean>,
|
||||
private verifySmsMfaCode: (userId: UserID, code: string) => Promise<boolean>,
|
||||
private verifyWebAuthnAuthentication: (
|
||||
userId: UserID,
|
||||
response: AuthenticationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
context?: 'registration' | 'discoverable' | 'mfa' | 'sudo',
|
||||
ticket?: string,
|
||||
) => Promise<void>,
|
||||
) {}
|
||||
|
||||
private getTicketCacheKey(ticket: string): string {
|
||||
return `ip-auth-ticket:${ticket}`;
|
||||
}
|
||||
|
||||
private getTokenCacheKey(token: string): string {
|
||||
return `ip-auth-token:${token}`;
|
||||
}
|
||||
|
||||
async resendIpAuthorization(ticket: string): Promise<{retryAfter?: number}> {
|
||||
const cacheKey = this.getTicketCacheKey(ticket);
|
||||
const payload = await this.cacheService.get<IpAuthorizationTicketCache>(cacheKey);
|
||||
if (!payload) {
|
||||
throw InputValidationError.create('ticket', 'Invalid or expired authorization ticket');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const secondsSinceCreation = Math.floor((now - payload.createdAt) / 1000);
|
||||
if (payload.resendUsed) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.RATE_LIMITED,
|
||||
message: 'You can only resend this email once.',
|
||||
status: 429,
|
||||
});
|
||||
}
|
||||
|
||||
const minDelay = 30;
|
||||
if (secondsSinceCreation < minDelay) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.RATE_LIMITED,
|
||||
message: 'Please wait before resending the email.',
|
||||
status: 429,
|
||||
data: {resend_available_in: minDelay - secondsSinceCreation},
|
||||
});
|
||||
}
|
||||
|
||||
await this.emailService.sendIpAuthorizationEmail(
|
||||
payload.email,
|
||||
payload.username,
|
||||
payload.authToken,
|
||||
payload.clientIp,
|
||||
payload.clientLocation,
|
||||
null,
|
||||
);
|
||||
|
||||
const ttl = await this.cacheService.ttl(cacheKey);
|
||||
await this.cacheService.set(
|
||||
cacheKey,
|
||||
{
|
||||
...payload,
|
||||
resendUsed: true,
|
||||
},
|
||||
ttl > 0 ? ttl : undefined,
|
||||
);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
async completeIpAuthorization(token: string): Promise<{token: string; user_id: string; ticket: string}> {
|
||||
const tokenMapping = await this.cacheService.get<{ticket: string}>(this.getTokenCacheKey(token));
|
||||
if (!tokenMapping?.ticket) {
|
||||
throw InputValidationError.create('token', 'Invalid or expired authorization token');
|
||||
}
|
||||
|
||||
const cacheKey = this.getTicketCacheKey(tokenMapping.ticket);
|
||||
const payload = await this.cacheService.get<IpAuthorizationTicketCache>(cacheKey);
|
||||
if (!payload) {
|
||||
throw InputValidationError.create('token', 'Invalid or expired authorization token');
|
||||
}
|
||||
|
||||
const repoResult = await this.repository.authorizeIpByToken(token);
|
||||
if (!repoResult || repoResult.userId.toString() !== payload.userId) {
|
||||
throw InputValidationError.create('token', 'Invalid or expired authorization token');
|
||||
}
|
||||
|
||||
const user = await this.repository.findUnique(createUserID(BigInt(payload.userId)));
|
||||
if (!user) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.UNKNOWN_USER,
|
||||
message: 'User not found',
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
this.assertNonBotUser(user);
|
||||
|
||||
await this.repository.createAuthorizedIp(user.id, payload.clientIp);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'X-Forwarded-For': payload.clientIp,
|
||||
'user-agent': payload.userAgent,
|
||||
};
|
||||
if (payload.platform) {
|
||||
headers['x-fluxer-platform'] = payload.platform;
|
||||
}
|
||||
|
||||
const syntheticRequest = new Request('https://api.fluxer.app/auth/ip-authorization', {
|
||||
headers,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const [sessionToken] = await this.createAuthSession({user, request: syntheticRequest});
|
||||
|
||||
await this.cacheService.delete(cacheKey);
|
||||
await this.cacheService.delete(this.getTokenCacheKey(token));
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'user.login',
|
||||
dimensions: {mfa_type: 'ip_authorization'},
|
||||
});
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.success',
|
||||
});
|
||||
|
||||
return {token: sessionToken, user_id: user.id.toString(), ticket: tokenMapping.ticket};
|
||||
}
|
||||
|
||||
async login({
|
||||
data,
|
||||
request,
|
||||
}: LoginParams): Promise<
|
||||
| {mfa: false; user_id: string; token: string; pending_verification?: boolean}
|
||||
| {mfa: true; ticket: string; sms: boolean; totp: boolean; webauthn: boolean}
|
||||
> {
|
||||
const inTests = Config.dev.testModeEnabled || process.env.CI === 'true';
|
||||
const skipRateLimits = inTests || Config.dev.disableRateLimits;
|
||||
|
||||
const emailRateLimit = await this.rateLimitService.checkLimit({
|
||||
identifier: `login:email:${data.email}`,
|
||||
maxAttempts: 5,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!emailRateLimit.allowed && !skipRateLimits) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.RATE_LIMITED,
|
||||
message: 'Too many login attempts. Please try again later.',
|
||||
status: 429,
|
||||
});
|
||||
}
|
||||
|
||||
const clientIp = IpUtils.requireClientIp(request);
|
||||
const ipRateLimit = await this.rateLimitService.checkLimit({
|
||||
identifier: `login:ip:${clientIp}`,
|
||||
maxAttempts: 10,
|
||||
windowMs: 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!ipRateLimit.allowed && !skipRateLimits) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.RATE_LIMITED,
|
||||
message: 'Too many login attempts from this IP. Please try again later.',
|
||||
status: 429,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await this.repository.findByEmail(data.email);
|
||||
if (!user) {
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.failure',
|
||||
dimensions: {reason: 'invalid_credentials'},
|
||||
});
|
||||
throw InputValidationError.createMultiple([
|
||||
{field: 'email', message: 'Invalid email or password'},
|
||||
{field: 'password', message: 'Invalid email or password'},
|
||||
]);
|
||||
}
|
||||
|
||||
this.assertNonBotUser(user);
|
||||
|
||||
const isMatch = await this.verifyPassword({
|
||||
password: data.password,
|
||||
passwordHash: user.passwordHash!,
|
||||
});
|
||||
|
||||
if (!isMatch) {
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.failure',
|
||||
dimensions: {reason: 'invalid_credentials'},
|
||||
});
|
||||
throw InputValidationError.createMultiple([
|
||||
{field: 'email', message: 'Invalid email or password'},
|
||||
{field: 'password', message: 'Invalid email or password'},
|
||||
]);
|
||||
}
|
||||
|
||||
let currentUser = await this.handleBanStatus(user);
|
||||
|
||||
if ((currentUser.flags & UserFlags.DISABLED) !== 0n && !currentUser.tempBannedUntil) {
|
||||
const updatedFlags = currentUser.flags & ~UserFlags.DISABLED;
|
||||
const updatedUser = await this.repository.patchUpsert(currentUser.id, {
|
||||
flags: updatedFlags,
|
||||
});
|
||||
if (updatedUser) {
|
||||
currentUser = updatedUser;
|
||||
Logger.info({userId: currentUser.id}, 'Auto-undisabled user on login');
|
||||
}
|
||||
}
|
||||
|
||||
if ((currentUser.flags & UserFlags.SELF_DELETED) !== 0n) {
|
||||
if (currentUser.pendingDeletionAt) {
|
||||
await this.repository.removePendingDeletion(currentUser.id, currentUser.pendingDeletionAt);
|
||||
}
|
||||
|
||||
await this.redisDeletionQueue.removeFromQueue(currentUser.id);
|
||||
|
||||
const updatedFlags = currentUser.flags & ~UserFlags.SELF_DELETED;
|
||||
const updatedUser = await this.repository.patchUpsert(currentUser.id, {
|
||||
flags: updatedFlags,
|
||||
pending_deletion_at: null,
|
||||
});
|
||||
if (updatedUser) {
|
||||
currentUser = updatedUser;
|
||||
Logger.info({userId: currentUser.id}, 'Auto-cancelled deletion on login');
|
||||
} else {
|
||||
Logger.error({userId: currentUser.id}, 'Failed to cancel deletion during login');
|
||||
throw new Error('Failed to cancel account deletion during login');
|
||||
}
|
||||
}
|
||||
|
||||
const hasMfa = (currentUser.authenticatorTypes?.size ?? 0) > 0;
|
||||
const isAppStoreReviewer = (currentUser.flags & UserFlags.APP_STORE_REVIEWER) !== 0n;
|
||||
|
||||
if (!hasMfa && !isAppStoreReviewer) {
|
||||
const isIpAuthorized = await this.repository.checkIpAuthorized(currentUser.id, clientIp);
|
||||
if (!isIpAuthorized) {
|
||||
const ticket = createIpAuthorizationTicket(await this.generateSecureToken());
|
||||
const authToken = createIpAuthorizationToken(await this.generateSecureToken());
|
||||
const geoipResult = await IpUtils.getCountryCodeDetailed(clientIp);
|
||||
const clientLocation = IpUtils.formatGeoipLocation(geoipResult);
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
const platform = request.headers.get('x-fluxer-platform');
|
||||
|
||||
const cachePayload: IpAuthorizationTicketCache = {
|
||||
userId: currentUser.id.toString(),
|
||||
email: currentUser.email!,
|
||||
username: currentUser.username,
|
||||
clientIp,
|
||||
userAgent,
|
||||
platform: platform ?? null,
|
||||
authToken,
|
||||
clientLocation,
|
||||
inviteCode: data.invite_code ?? null,
|
||||
resendUsed: false,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const ttlSeconds = 15 * 60;
|
||||
await this.cacheService.set<IpAuthorizationTicketCache>(`ip-auth-ticket:${ticket}`, cachePayload, ttlSeconds);
|
||||
await this.cacheService.set<{ticket: string}>(`ip-auth-token:${authToken}`, {ticket}, ttlSeconds);
|
||||
|
||||
await this.repository.createIpAuthorizationToken(currentUser.id, authToken);
|
||||
|
||||
await this.emailService.sendIpAuthorizationEmail(
|
||||
currentUser.email!,
|
||||
currentUser.username,
|
||||
authToken,
|
||||
clientIp,
|
||||
clientLocation,
|
||||
currentUser.locale,
|
||||
);
|
||||
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.IP_AUTHORIZATION_REQUIRED,
|
||||
message: 'New login location detected. Check your inbox for a link to authorize this device.',
|
||||
status: 403,
|
||||
data: {
|
||||
ip_authorization_required: true,
|
||||
ticket,
|
||||
email: currentUser.email,
|
||||
resend_available_in: 30,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMfa) {
|
||||
return await this.createMfaTicketResponse(currentUser);
|
||||
}
|
||||
|
||||
if (data.invite_code && this.inviteService) {
|
||||
try {
|
||||
await this.inviteService.acceptInvite({
|
||||
userId: currentUser.id,
|
||||
inviteCode: createInviteCode(data.invite_code),
|
||||
requestCache: createRequestCache(),
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.warn({inviteCode: data.invite_code, error}, 'Failed to auto-join invite on login');
|
||||
}
|
||||
}
|
||||
|
||||
const [token] = await this.createAuthSession({user: currentUser, request});
|
||||
const isPendingVerification = (currentUser.flags & UserFlags.PENDING_MANUAL_VERIFICATION) !== 0n;
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'user.login',
|
||||
dimensions: {mfa_type: 'none'},
|
||||
});
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.success',
|
||||
});
|
||||
|
||||
return {
|
||||
mfa: false,
|
||||
user_id: currentUser.id.toString(),
|
||||
token,
|
||||
pending_verification: isPendingVerification ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async loginMfaTotp({code, ticket, request}: LoginMfaTotpParams): Promise<{user_id: string; token: string}> {
|
||||
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.failure',
|
||||
dimensions: {reason: 'mfa_ticket_expired'},
|
||||
});
|
||||
throw InputValidationError.create('code', 'Session timeout. Please refresh the page and log in again.');
|
||||
}
|
||||
|
||||
const user = await this.repository.findUnique(createUserID(BigInt(userId)));
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
this.assertNonBotUser(user);
|
||||
|
||||
if (!user.totpSecret) {
|
||||
const [token] = await this.createAuthSession({user, request});
|
||||
getMetricsService().counter({
|
||||
name: 'user.login',
|
||||
dimensions: {mfa_type: 'totp'},
|
||||
});
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
const isValid = await this.verifyMfaCode({
|
||||
userId: user.id,
|
||||
mfaSecret: user.totpSecret,
|
||||
code,
|
||||
allowBackup: true,
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.failure',
|
||||
dimensions: {reason: 'mfa_invalid'},
|
||||
});
|
||||
throw InputValidationError.create('code', 'Invalid code');
|
||||
}
|
||||
|
||||
await this.cacheService.delete(`mfa-ticket:${ticket}`);
|
||||
const [token] = await this.createAuthSession({user, request});
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'user.login',
|
||||
dimensions: {mfa_type: 'totp'},
|
||||
});
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.success',
|
||||
});
|
||||
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
async loginMfaSms({
|
||||
code,
|
||||
ticket,
|
||||
request,
|
||||
}: {
|
||||
code: string;
|
||||
ticket: string;
|
||||
request: Request;
|
||||
}): Promise<{user_id: string; token: string}> {
|
||||
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.failure',
|
||||
dimensions: {reason: 'mfa_ticket_expired'},
|
||||
});
|
||||
throw InputValidationError.create('code', 'Session timeout. Please refresh the page and log in again.');
|
||||
}
|
||||
|
||||
const user = await this.repository.findUnique(createUserID(BigInt(userId)));
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
this.assertNonBotUser(user);
|
||||
|
||||
const isValid = await this.verifySmsMfaCode(user.id, code);
|
||||
if (!isValid) {
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.failure',
|
||||
dimensions: {reason: 'mfa_invalid'},
|
||||
});
|
||||
throw InputValidationError.create('code', 'Invalid code');
|
||||
}
|
||||
|
||||
await this.cacheService.delete(`mfa-ticket:${ticket}`);
|
||||
const [token] = await this.createAuthSession({user, request});
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'user.login',
|
||||
dimensions: {mfa_type: 'sms'},
|
||||
});
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.success',
|
||||
});
|
||||
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
async loginMfaWebAuthn({
|
||||
response,
|
||||
challenge,
|
||||
ticket,
|
||||
request,
|
||||
}: {
|
||||
response: AuthenticationResponseJSON;
|
||||
challenge: string;
|
||||
ticket: string;
|
||||
request: Request;
|
||||
}): Promise<{user_id: string; token: string}> {
|
||||
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.failure',
|
||||
dimensions: {reason: 'mfa_ticket_expired'},
|
||||
});
|
||||
throw InputValidationError.create('ticket', 'Session timeout. Please refresh the page and log in again.');
|
||||
}
|
||||
|
||||
const user = await this.repository.findUnique(createUserID(BigInt(userId)));
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
this.assertNonBotUser(user);
|
||||
|
||||
await this.verifyWebAuthnAuthentication(user.id, response, challenge, 'mfa', ticket);
|
||||
|
||||
await this.cacheService.delete(`mfa-ticket:${ticket}`);
|
||||
const [token] = await this.createAuthSession({user, request});
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'user.login',
|
||||
dimensions: {mfa_type: 'webauthn'},
|
||||
});
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.success',
|
||||
});
|
||||
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
private async createMfaTicketResponse(user: User): Promise<{
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
sms: boolean;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
}> {
|
||||
const ticket = createMfaTicket(RandomUtils.randomString(64));
|
||||
await this.cacheService.set(`mfa-ticket:${ticket}`, user.id.toString(), 60 * 5);
|
||||
|
||||
const credentials = await this.repository.listWebAuthnCredentials(user.id);
|
||||
const hasSms = user.authenticatorTypes.has(UserAuthenticatorTypes.SMS);
|
||||
const hasWebauthn = credentials.length > 0;
|
||||
const hasTotp = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
|
||||
|
||||
return {
|
||||
mfa: true,
|
||||
ticket: ticket,
|
||||
sms: hasSms,
|
||||
totp: hasTotp,
|
||||
webauthn: hasWebauthn,
|
||||
};
|
||||
}
|
||||
}
|
||||
721
fluxer_api/src/auth/services/AuthMfaService.ts
Normal file
721
fluxer_api/src/auth/services/AuthMfaService.ts
Normal file
@@ -0,0 +1,721 @@
|
||||
/*
|
||||
* 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 {AuthenticationResponseJSON, RegistrationResponseJSON} from '@simplewebauthn/server';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
type VerifiedAuthenticationResponse,
|
||||
type VerifiedRegistrationResponse,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {APIErrorCodes, UserAuthenticatorTypes} from '~/Constants';
|
||||
import {
|
||||
FluxerAPIError,
|
||||
InputValidationError,
|
||||
PhoneRequiredForSmsMfaError,
|
||||
SmsMfaNotEnabledError,
|
||||
SmsMfaRequiresTotpError,
|
||||
} from '~/Errors';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {ISMSService} from '~/infrastructure/ISMSService';
|
||||
import {getMetricsService} from '~/infrastructure/MetricsService';
|
||||
import {Logger} from '~/Logger';
|
||||
import {getUserSearchService} from '~/Meilisearch';
|
||||
import type {User} from '~/Models';
|
||||
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {mapUserToPrivateResponse} from '~/user/UserModel';
|
||||
import {TotpGenerator} from '~/utils/TotpGenerator';
|
||||
|
||||
const WEBAUTHN_CHALLENGE_TTL_SECONDS = 60 * 5;
|
||||
type WebAuthnChallengeContext = 'registration' | 'discoverable' | 'mfa' | 'sudo';
|
||||
|
||||
interface SudoMfaVerificationParams {
|
||||
userId: UserID;
|
||||
method: 'totp' | 'sms' | 'webauthn';
|
||||
code?: string;
|
||||
webauthnResponse?: AuthenticationResponseJSON;
|
||||
webauthnChallenge?: string;
|
||||
}
|
||||
|
||||
interface SudoMfaVerificationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface VerifyMfaCodeParams {
|
||||
userId: UserID;
|
||||
mfaSecret: string;
|
||||
code: string;
|
||||
allowBackup?: boolean;
|
||||
}
|
||||
|
||||
export class AuthMfaService {
|
||||
constructor(
|
||||
private repository: IUserRepository,
|
||||
private cacheService: ICacheService,
|
||||
private smsService: ISMSService,
|
||||
private gatewayService: IGatewayService,
|
||||
private botMfaMirrorService?: BotMfaMirrorService,
|
||||
) {}
|
||||
|
||||
async verifyMfaCode({userId, mfaSecret, code, allowBackup = false}: VerifyMfaCodeParams): Promise<boolean> {
|
||||
try {
|
||||
const totp = new TotpGenerator(mfaSecret);
|
||||
const isValidTotp = await totp.validateTotp(code);
|
||||
|
||||
if (isValidTotp) {
|
||||
if (Config.dev.testModeEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const reuseKey = `mfa-totp:${userId}:${code}`;
|
||||
const isCodeUsed = await this.cacheService.get<number>(reuseKey);
|
||||
if (!isCodeUsed) {
|
||||
await this.cacheService.set(reuseKey, 1, 30);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (allowBackup) {
|
||||
const backupCodes = await this.repository.listMfaBackupCodes(userId);
|
||||
const backupCode = backupCodes.find((bc) => bc.code === code && !bc.consumed);
|
||||
|
||||
if (backupCode) {
|
||||
await this.repository.consumeMfaBackupCode(userId, code);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async enableSmsMfa(userId: UserID): Promise<void> {
|
||||
const user = await this.repository.findUniqueAssert(userId);
|
||||
|
||||
if (!user.totpSecret) {
|
||||
throw new SmsMfaRequiresTotpError();
|
||||
}
|
||||
|
||||
if (!user.phone) {
|
||||
throw new PhoneRequiredForSmsMfaError();
|
||||
}
|
||||
|
||||
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
|
||||
authenticatorTypes.add(UserAuthenticatorTypes.SMS);
|
||||
const updatedUser = await this.repository.patchUpsert(userId, {authenticator_types: authenticatorTypes});
|
||||
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && updatedUser) {
|
||||
await userSearchService.updateUser(updatedUser).catch((error) => {
|
||||
Logger.error({userId, error}, 'Failed to update user in search');
|
||||
});
|
||||
}
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser!),
|
||||
});
|
||||
if (updatedUser) {
|
||||
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
}
|
||||
|
||||
async disableSmsMfa(userId: UserID): Promise<void> {
|
||||
const user = await this.repository.findUniqueAssert(userId);
|
||||
|
||||
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
|
||||
authenticatorTypes.delete(UserAuthenticatorTypes.SMS);
|
||||
const updatedUser = await this.repository.patchUpsert(userId, {authenticator_types: authenticatorTypes});
|
||||
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && updatedUser) {
|
||||
await userSearchService.updateUser(updatedUser).catch((error) => {
|
||||
Logger.error({userId, error}, 'Failed to update user in search');
|
||||
});
|
||||
}
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser!),
|
||||
});
|
||||
if (updatedUser) {
|
||||
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
}
|
||||
|
||||
async sendSmsMfaCode(userId: UserID): Promise<void> {
|
||||
const user = await this.repository.findUniqueAssert(userId);
|
||||
|
||||
if (!user.authenticatorTypes?.has(UserAuthenticatorTypes.SMS)) {
|
||||
throw new SmsMfaNotEnabledError();
|
||||
}
|
||||
|
||||
if (!user.phone) {
|
||||
throw new PhoneRequiredForSmsMfaError();
|
||||
}
|
||||
|
||||
await this.smsService.startVerification(user.phone);
|
||||
}
|
||||
|
||||
async sendSmsMfaCodeForTicket(ticket: string): Promise<void> {
|
||||
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
throw InputValidationError.create('ticket', 'Session timeout. Please refresh the page and log in again.');
|
||||
}
|
||||
await this.sendSmsMfaCode(createUserID(BigInt(userId)));
|
||||
}
|
||||
|
||||
async verifySmsMfaCode(userId: UserID, code: string): Promise<boolean> {
|
||||
const user = await this.repository.findUnique(userId);
|
||||
if (!user || !user.phone) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.smsService.checkVerification(user.phone, code);
|
||||
}
|
||||
|
||||
async generateWebAuthnRegistrationOptions(userId: UserID) {
|
||||
const user = await this.repository.findUniqueAssert(userId);
|
||||
const existingCredentials = await this.repository.listWebAuthnCredentials(userId);
|
||||
|
||||
if (existingCredentials.length >= 10) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.WEBAUTHN_CREDENTIAL_LIMIT_REACHED,
|
||||
message: 'You have reached the maximum number of passkeys (10)',
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const rpName = Config.auth.passkeys.rpName;
|
||||
const rpID = Config.auth.passkeys.rpId;
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userID: new TextEncoder().encode(user.id.toString()),
|
||||
userName: user.username!,
|
||||
userDisplayName: user.username!,
|
||||
attestationType: 'none',
|
||||
excludeCredentials: existingCredentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
requireResidentKey: true,
|
||||
userVerification: 'required',
|
||||
},
|
||||
});
|
||||
|
||||
await this.saveWebAuthnChallenge(options.challenge, {context: 'registration', userId});
|
||||
return options;
|
||||
}
|
||||
|
||||
async verifyWebAuthnRegistration(
|
||||
userId: UserID,
|
||||
response: RegistrationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const user = await this.repository.findUniqueAssert(userId);
|
||||
const existingCredentials = await this.repository.listWebAuthnCredentials(userId);
|
||||
|
||||
await this.consumeWebAuthnChallenge(expectedChallenge, 'registration', {userId});
|
||||
|
||||
if (existingCredentials.length >= 10) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.WEBAUTHN_CREDENTIAL_LIMIT_REACHED,
|
||||
message: 'You have reached the maximum number of passkeys (10)',
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const rpID = Config.auth.passkeys.rpId;
|
||||
const expectedOrigin = Config.auth.passkeys.allowedOrigins;
|
||||
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
});
|
||||
} catch (_error) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL,
|
||||
message: 'Failed to verify WebAuthn credential',
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL,
|
||||
message: 'Failed to verify WebAuthn credential',
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const {credential} = verification.registrationInfo;
|
||||
|
||||
let publicKeyBuffer: Buffer;
|
||||
let counterBigInt: bigint;
|
||||
|
||||
try {
|
||||
publicKeyBuffer = Buffer.from(credential.publicKey);
|
||||
} catch (_error) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL,
|
||||
message: 'Invalid credential public key format during registration',
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (credential.counter === undefined || credential.counter === null) {
|
||||
throw new Error('Counter value is undefined or null');
|
||||
}
|
||||
counterBigInt = BigInt(credential.counter);
|
||||
} catch (_error) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL,
|
||||
message: 'Invalid credential counter value during registration',
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const responseObj = response as {response?: {transports?: Array<string>}};
|
||||
await this.repository.createWebAuthnCredential(
|
||||
userId,
|
||||
credential.id,
|
||||
publicKeyBuffer,
|
||||
counterBigInt,
|
||||
responseObj.response?.transports ? new Set(responseObj.response.transports) : null,
|
||||
name,
|
||||
);
|
||||
|
||||
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
|
||||
if (!authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
authenticatorTypes.add(UserAuthenticatorTypes.WEBAUTHN);
|
||||
const updatedUser = await this.repository.patchUpsert(userId, {authenticator_types: authenticatorTypes});
|
||||
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && updatedUser) {
|
||||
await userSearchService.updateUser(updatedUser).catch((error) => {
|
||||
Logger.error({userId, error}, 'Failed to update user in search');
|
||||
});
|
||||
}
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser!),
|
||||
});
|
||||
if (updatedUser) {
|
||||
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
const credential = await this.repository.getWebAuthnCredential(userId, credentialId);
|
||||
if (!credential) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.UNKNOWN_WEBAUTHN_CREDENTIAL,
|
||||
message: 'Credential not found',
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
await this.repository.deleteWebAuthnCredential(userId, credentialId);
|
||||
|
||||
const remainingCredentials = await this.repository.listWebAuthnCredentials(userId);
|
||||
if (remainingCredentials.length === 0) {
|
||||
const user = await this.repository.findUniqueAssert(userId);
|
||||
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
|
||||
authenticatorTypes.delete(UserAuthenticatorTypes.WEBAUTHN);
|
||||
const updatedUser = await this.repository.patchUpsert(userId, {authenticator_types: authenticatorTypes});
|
||||
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && updatedUser) {
|
||||
await userSearchService.updateUser(updatedUser).catch((error) => {
|
||||
Logger.error({userId, error}, 'Failed to update user in search');
|
||||
});
|
||||
}
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser!),
|
||||
});
|
||||
if (updatedUser) {
|
||||
await this.botMfaMirrorService?.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async renameWebAuthnCredential(userId: UserID, credentialId: string, name: string): Promise<void> {
|
||||
const credential = await this.repository.getWebAuthnCredential(userId, credentialId);
|
||||
if (!credential) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.UNKNOWN_WEBAUTHN_CREDENTIAL,
|
||||
message: 'Credential not found',
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
await this.repository.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
}
|
||||
|
||||
async generateWebAuthnAuthenticationOptionsDiscoverable() {
|
||||
const rpID = Config.auth.passkeys.rpId;
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
userVerification: 'required',
|
||||
});
|
||||
|
||||
await this.saveWebAuthnChallenge(options.challenge, {context: 'discoverable'});
|
||||
return options;
|
||||
}
|
||||
|
||||
async verifyWebAuthnAuthenticationDiscoverable(
|
||||
response: AuthenticationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
): Promise<User> {
|
||||
const responseObj = response as {id: string};
|
||||
const credentialId = responseObj.id;
|
||||
|
||||
const userId = await this.repository.getUserIdByCredentialId(credentialId);
|
||||
if (!userId) {
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.failure',
|
||||
dimensions: {reason: 'mfa_invalid'},
|
||||
});
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED,
|
||||
message: 'Passkey authentication failed',
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
await this.verifyWebAuthnAuthentication(userId, response, expectedChallenge, 'discoverable');
|
||||
|
||||
const user = await this.repository.findUniqueAssert(userId);
|
||||
return user;
|
||||
}
|
||||
|
||||
async generateWebAuthnAuthenticationOptionsForMfa(ticket: string) {
|
||||
const userId = await this.cacheService.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
throw InputValidationError.create('ticket', 'Session timeout. Please refresh the page and log in again.');
|
||||
}
|
||||
|
||||
const credentials = await this.repository.listWebAuthnCredentials(createUserID(BigInt(userId)));
|
||||
|
||||
if (credentials.length === 0) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL,
|
||||
message: 'No passkeys registered',
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const rpID = Config.auth.passkeys.rpId;
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
allowCredentials: credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
userVerification: 'required',
|
||||
});
|
||||
|
||||
await this.saveWebAuthnChallenge(options.challenge, {context: 'mfa', userId: createUserID(BigInt(userId)), ticket});
|
||||
return options;
|
||||
}
|
||||
|
||||
async verifyWebAuthnAuthentication(
|
||||
userId: UserID,
|
||||
response: AuthenticationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
context: WebAuthnChallengeContext = 'mfa',
|
||||
ticket?: string,
|
||||
): Promise<void> {
|
||||
await this.consumeWebAuthnChallenge(expectedChallenge, context, {userId, ticket});
|
||||
|
||||
const responseObj = response as {id: string};
|
||||
const credentialId = responseObj.id;
|
||||
const credential = await this.repository.getWebAuthnCredential(userId, credentialId);
|
||||
|
||||
if (!credential) {
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.failure',
|
||||
dimensions: {reason: 'mfa_invalid'},
|
||||
});
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED,
|
||||
message: 'Passkey authentication failed',
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const rpID = Config.auth.passkeys.rpId;
|
||||
const expectedOrigin = Config.auth.passkeys.allowedOrigins;
|
||||
|
||||
let verification: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
let publicKeyUint8Array: Uint8Array<ArrayBuffer>;
|
||||
try {
|
||||
const buffer = Buffer.from(credential.publicKey);
|
||||
const arrayBuffer: ArrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
||||
publicKeyUint8Array = new Uint8Array(arrayBuffer);
|
||||
} catch (_error) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL,
|
||||
message: 'Invalid credential public key format',
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
credential: {
|
||||
id: credential.credentialId,
|
||||
publicKey: publicKeyUint8Array,
|
||||
counter: Number(credential.counter),
|
||||
transports: credential.transports
|
||||
? (Array.from(credential.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (_error) {
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.failure',
|
||||
dimensions: {reason: 'mfa_invalid'},
|
||||
});
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED,
|
||||
message: 'Passkey authentication failed',
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (!verification.verified) {
|
||||
getMetricsService().counter({
|
||||
name: 'auth.login.failure',
|
||||
dimensions: {reason: 'mfa_invalid'},
|
||||
});
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED,
|
||||
message: 'Passkey authentication failed',
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
let newCounter: bigint;
|
||||
try {
|
||||
if (
|
||||
verification.authenticationInfo.newCounter === undefined ||
|
||||
verification.authenticationInfo.newCounter === null
|
||||
) {
|
||||
throw new Error('Counter value is undefined or null');
|
||||
}
|
||||
newCounter = BigInt(verification.authenticationInfo.newCounter);
|
||||
} catch (_error) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.GENERAL_ERROR,
|
||||
message: 'Invalid authentication counter value',
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
await this.repository.updateWebAuthnCredentialCounter(userId, credentialId, newCounter);
|
||||
await this.repository.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
}
|
||||
|
||||
async generateWebAuthnOptionsForSudo(userId: UserID) {
|
||||
const credentials = await this.repository.listWebAuthnCredentials(userId);
|
||||
|
||||
if (credentials.length === 0) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL,
|
||||
message: 'No passkeys registered',
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const rpID = Config.auth.passkeys.rpId;
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
allowCredentials: credentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
? (Array.from(cred.transports) as Array<'usb' | 'nfc' | 'ble' | 'internal' | 'cable' | 'hybrid'>)
|
||||
: undefined,
|
||||
})),
|
||||
userVerification: 'required',
|
||||
});
|
||||
|
||||
await this.saveWebAuthnChallenge(options.challenge, {context: 'sudo', userId});
|
||||
return options;
|
||||
}
|
||||
|
||||
async verifySudoMfa(params: SudoMfaVerificationParams): Promise<SudoMfaVerificationResult> {
|
||||
const {userId, method, code, webauthnResponse, webauthnChallenge} = params;
|
||||
|
||||
const user = await this.repository.findUnique(userId);
|
||||
const hasMfa = (user?.authenticatorTypes?.size ?? 0) > 0;
|
||||
if (!user || !hasMfa) {
|
||||
return {success: false, error: 'MFA not enabled'};
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case 'totp': {
|
||||
if (!code) {
|
||||
return {success: false, error: 'TOTP code is required'};
|
||||
}
|
||||
if (!user.totpSecret) {
|
||||
return {success: false, error: 'TOTP is not enabled'};
|
||||
}
|
||||
const isValid = await this.verifyMfaCode({
|
||||
userId,
|
||||
mfaSecret: user.totpSecret,
|
||||
code,
|
||||
allowBackup: true,
|
||||
});
|
||||
return {success: isValid, error: isValid ? undefined : 'Invalid TOTP code'};
|
||||
}
|
||||
|
||||
case 'sms': {
|
||||
if (!code) {
|
||||
return {success: false, error: 'SMS code is required'};
|
||||
}
|
||||
if (!user.authenticatorTypes?.has(UserAuthenticatorTypes.SMS)) {
|
||||
return {success: false, error: 'SMS MFA is not enabled'};
|
||||
}
|
||||
const isValid = await this.verifySmsMfaCode(userId, code);
|
||||
return {success: isValid, error: isValid ? undefined : 'Invalid SMS code'};
|
||||
}
|
||||
|
||||
case 'webauthn': {
|
||||
if (!webauthnResponse || !webauthnChallenge) {
|
||||
return {success: false, error: 'WebAuthn response and challenge are required'};
|
||||
}
|
||||
if (!user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
return {success: false, error: 'WebAuthn is not enabled'};
|
||||
}
|
||||
try {
|
||||
await this.verifyWebAuthnAuthentication(userId, webauthnResponse, webauthnChallenge, 'sudo');
|
||||
return {success: true};
|
||||
} catch {
|
||||
return {success: false, error: 'WebAuthn verification failed'};
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {success: false, error: 'Invalid MFA method'};
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableMfaMethods(
|
||||
userId: UserID,
|
||||
): Promise<{totp: boolean; sms: boolean; webauthn: boolean; has_mfa: boolean}> {
|
||||
const user = await this.repository.findUnique(userId);
|
||||
if (!user) {
|
||||
return {totp: false, sms: false, webauthn: false, has_mfa: false};
|
||||
}
|
||||
|
||||
const hasMfa = (user.authenticatorTypes?.size ?? 0) > 0;
|
||||
|
||||
return {
|
||||
totp: user.totpSecret !== null,
|
||||
sms: user.authenticatorTypes?.has(UserAuthenticatorTypes.SMS) ?? false,
|
||||
webauthn: user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false,
|
||||
has_mfa: hasMfa,
|
||||
};
|
||||
}
|
||||
|
||||
private getWebAuthnChallengeCacheKey(challenge: string): string {
|
||||
return `webauthn:challenge:${challenge}`;
|
||||
}
|
||||
|
||||
private async saveWebAuthnChallenge(
|
||||
challenge: string,
|
||||
entry: {context: WebAuthnChallengeContext; userId?: UserID; ticket?: string},
|
||||
): Promise<void> {
|
||||
const key = this.getWebAuthnChallengeCacheKey(challenge);
|
||||
await this.cacheService.set(
|
||||
key,
|
||||
{context: entry.context, userId: entry.userId?.toString(), ticket: entry.ticket},
|
||||
WEBAUTHN_CHALLENGE_TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
private async consumeWebAuthnChallenge(
|
||||
challenge: string,
|
||||
expectedContext: WebAuthnChallengeContext,
|
||||
{userId, ticket}: {userId?: UserID; ticket?: string} = {},
|
||||
): Promise<void> {
|
||||
const key = this.getWebAuthnChallengeCacheKey(challenge);
|
||||
const cached = await this.cacheService.get<{context: WebAuthnChallengeContext; userId?: string; ticket?: string}>(
|
||||
key,
|
||||
);
|
||||
|
||||
const challengeMatches =
|
||||
cached &&
|
||||
cached.context === expectedContext &&
|
||||
(userId === undefined || cached.userId === undefined || cached.userId === userId.toString()) &&
|
||||
(ticket === undefined || cached.ticket === undefined || cached.ticket === ticket);
|
||||
|
||||
if (!challengeMatches) {
|
||||
throw this.createChallengeError(expectedContext);
|
||||
}
|
||||
|
||||
await this.cacheService.delete(key);
|
||||
}
|
||||
|
||||
private createChallengeError(context: WebAuthnChallengeContext): FluxerAPIError {
|
||||
const isRegistration = context === 'registration';
|
||||
return new FluxerAPIError({
|
||||
code: isRegistration ? APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL : APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED,
|
||||
message: isRegistration ? 'Failed to verify WebAuthn credential' : 'Passkey authentication failed',
|
||||
status: isRegistration ? 400 : 401,
|
||||
});
|
||||
}
|
||||
}
|
||||
203
fluxer_api/src/auth/services/AuthPasswordService.ts
Normal file
203
fluxer_api/src/auth/services/AuthPasswordService.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* 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 {ForgotPasswordRequest, ResetPasswordRequest} from '~/auth/AuthModel';
|
||||
import {createPasswordResetToken} from '~/BrandedTypes';
|
||||
import {FLUXER_USER_AGENT, UserFlags} from '~/Constants';
|
||||
import {InputValidationError, RateLimitError, UnauthorizedError} from '~/Errors';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {AuthSession, User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import * as IpUtils from '~/utils/IpUtils';
|
||||
import {hashPassword as hashPasswordUtil, verifyPassword as verifyPasswordUtil} from '~/utils/PasswordUtils';
|
||||
|
||||
interface ForgotPasswordParams {
|
||||
data: ForgotPasswordRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface ResetPasswordParams {
|
||||
data: ResetPasswordRequest;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface VerifyPasswordParams {
|
||||
password: string;
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
export class AuthPasswordService {
|
||||
constructor(
|
||||
private repository: IUserRepository,
|
||||
private emailService: IEmailService,
|
||||
private rateLimitService: IRateLimitService,
|
||||
private generateSecureToken: () => Promise<string>,
|
||||
private handleBanStatus: (user: User) => Promise<User>,
|
||||
private assertNonBotUser: (user: User) => void,
|
||||
private createMfaTicketResponse: (
|
||||
user: User,
|
||||
) => Promise<{mfa: true; ticket: string; sms: boolean; totp: boolean; webauthn: boolean}>,
|
||||
private createAuthSession: (params: {user: User; request: Request}) => Promise<[string, AuthSession]>,
|
||||
) {}
|
||||
|
||||
async hashPassword(password: string): Promise<string> {
|
||||
return hashPasswordUtil(password);
|
||||
}
|
||||
|
||||
async verifyPassword({password, passwordHash}: VerifyPasswordParams): Promise<boolean> {
|
||||
return verifyPasswordUtil({password, passwordHash});
|
||||
}
|
||||
|
||||
async isPasswordPwned(password: string): Promise<boolean> {
|
||||
try {
|
||||
const hashed = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
|
||||
const hashPrefix = hashed.slice(0, 5);
|
||||
const hashSuffix = hashed.slice(5);
|
||||
|
||||
const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`, {
|
||||
headers: {
|
||||
'User-Agent': FLUXER_USER_AGENT,
|
||||
'Add-Padding': 'true',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = await response.text();
|
||||
const lines = body.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const [hashSuffixLine, count] = line.split(':', 2);
|
||||
if (hashSuffixLine === hashSuffix && Number.parseInt(count, 10) > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Failed to check password against Pwned Passwords API');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async forgotPassword({data, request}: ForgotPasswordParams): Promise<void> {
|
||||
const clientIp = IpUtils.requireClientIp(request);
|
||||
|
||||
const ipLimitConfig = {maxAttempts: 20, windowMs: 30 * 60 * 1000};
|
||||
const emailLimitConfig = {maxAttempts: 5, windowMs: 30 * 60 * 1000};
|
||||
|
||||
const ipRateLimit = await this.rateLimitService.checkLimit({
|
||||
identifier: `password_reset:ip:${clientIp}`,
|
||||
...ipLimitConfig,
|
||||
});
|
||||
const emailRateLimit = await this.rateLimitService.checkLimit({
|
||||
identifier: `password_reset:email:${data.email.toLowerCase()}`,
|
||||
...emailLimitConfig,
|
||||
});
|
||||
|
||||
const exceeded = !ipRateLimit.allowed
|
||||
? {result: ipRateLimit, config: ipLimitConfig}
|
||||
: !emailRateLimit.allowed
|
||||
? {result: emailRateLimit, config: emailLimitConfig}
|
||||
: null;
|
||||
|
||||
if (exceeded) {
|
||||
const retryAfter =
|
||||
exceeded.result.retryAfter ?? Math.max(0, Math.ceil((exceeded.result.resetTime.getTime() - Date.now()) / 1000));
|
||||
throw new RateLimitError({
|
||||
message: 'Too many password reset attempts. Please try again later.',
|
||||
retryAfter,
|
||||
limit: exceeded.config.maxAttempts,
|
||||
resetTime: exceeded.result.resetTime,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await this.repository.findByEmail(data.email);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.assertNonBotUser(user);
|
||||
|
||||
const token = createPasswordResetToken(await this.generateSecureToken());
|
||||
await this.repository.createPasswordResetToken({
|
||||
token_: token,
|
||||
user_id: user.id,
|
||||
email: user.email!,
|
||||
});
|
||||
|
||||
await this.emailService.sendPasswordResetEmail(user.email!, user.username, token, user.locale);
|
||||
}
|
||||
|
||||
async resetPassword({
|
||||
data,
|
||||
request,
|
||||
}: ResetPasswordParams): Promise<
|
||||
| {mfa: false; user_id: string; token: string}
|
||||
| {mfa: true; ticket: string; sms: boolean; totp: boolean; webauthn: boolean}
|
||||
> {
|
||||
const tokenData = await this.repository.getPasswordResetToken(data.token);
|
||||
if (!tokenData) {
|
||||
throw InputValidationError.create('token', 'Invalid or expired reset token');
|
||||
}
|
||||
|
||||
const user = await this.repository.findUnique(tokenData.userId);
|
||||
if (!user) {
|
||||
throw InputValidationError.create('token', 'Invalid or expired reset token');
|
||||
}
|
||||
|
||||
this.assertNonBotUser(user);
|
||||
|
||||
if (user.flags & UserFlags.DELETED) {
|
||||
throw InputValidationError.create('token', 'Invalid or expired reset token');
|
||||
}
|
||||
|
||||
await this.handleBanStatus(user);
|
||||
|
||||
if (await this.isPasswordPwned(data.password)) {
|
||||
throw InputValidationError.create('password', 'Password is too common');
|
||||
}
|
||||
|
||||
const newPasswordHash = await this.hashPassword(data.password);
|
||||
const updatedUser = await this.repository.patchUpsert(user.id, {
|
||||
password_hash: newPasswordHash,
|
||||
password_last_changed_at: new Date(),
|
||||
});
|
||||
|
||||
if (!updatedUser) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
await this.repository.deleteAllAuthSessions(user.id);
|
||||
await this.repository.deletePasswordResetToken(data.token);
|
||||
|
||||
const hasMfa = (updatedUser.authenticatorTypes?.size ?? 0) > 0;
|
||||
if (hasMfa) {
|
||||
return await this.createMfaTicketResponse(updatedUser);
|
||||
}
|
||||
|
||||
const [token] = await this.createAuthSession({user: updatedUser, request});
|
||||
return {mfa: false, user_id: updatedUser.id.toString(), token};
|
||||
}
|
||||
}
|
||||
212
fluxer_api/src/auth/services/AuthPhoneService.ts
Normal file
212
fluxer_api/src/auth/services/AuthPhoneService.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* 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 {createPhoneVerificationToken, type UserID} from '~/BrandedTypes';
|
||||
import {SuspiciousActivityFlags, UserAuthenticatorTypes, UserFlags} from '~/Constants';
|
||||
import {
|
||||
InvalidPhoneNumberError,
|
||||
InvalidPhoneVerificationCodeError,
|
||||
PhoneAlreadyUsedError,
|
||||
PhoneVerificationRequiredError,
|
||||
SmsMfaNotEnabledError,
|
||||
} from '~/Errors';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {ISMSService} from '~/infrastructure/ISMSService';
|
||||
import {Logger} from '~/Logger';
|
||||
import {getUserSearchService} from '~/Meilisearch';
|
||||
import type {User} from '~/Models';
|
||||
import {PHONE_E164_REGEX} from '~/Schema';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
|
||||
import {mapUserToPrivateResponse} from '~/user/UserModel';
|
||||
|
||||
const PHONE_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS =
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE |
|
||||
SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE |
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE |
|
||||
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE |
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE |
|
||||
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE;
|
||||
|
||||
export class AuthPhoneService {
|
||||
constructor(
|
||||
private repository: IUserRepository,
|
||||
private smsService: ISMSService,
|
||||
private gatewayService: IGatewayService,
|
||||
private assertNonBotUser: (user: User) => void,
|
||||
private generateSecureToken: () => Promise<string>,
|
||||
private contactChangeLogService: UserContactChangeLogService,
|
||||
) {}
|
||||
|
||||
async sendPhoneVerificationCode(phone: string, userId: UserID | null): Promise<void> {
|
||||
if (!PHONE_E164_REGEX.test(phone)) {
|
||||
throw new InvalidPhoneNumberError();
|
||||
}
|
||||
|
||||
const existingUser = await this.repository.findByPhone(phone);
|
||||
|
||||
if (userId) {
|
||||
const requestingUser = await this.repository.findUnique(userId);
|
||||
if (requestingUser) {
|
||||
this.assertNonBotUser(requestingUser);
|
||||
}
|
||||
}
|
||||
|
||||
const allowReverification =
|
||||
existingUser &&
|
||||
userId &&
|
||||
existingUser.id === userId &&
|
||||
existingUser.suspiciousActivityFlags !== null &&
|
||||
((existingUser.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE) !== 0 ||
|
||||
(existingUser.suspiciousActivityFlags &
|
||||
SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE) !==
|
||||
0 ||
|
||||
(existingUser.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE) !==
|
||||
0 ||
|
||||
(existingUser.suspiciousActivityFlags & SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE) !==
|
||||
0);
|
||||
|
||||
if (existingUser) {
|
||||
this.assertNonBotUser(existingUser);
|
||||
}
|
||||
|
||||
if (existingUser && (!userId || existingUser.id !== userId) && !allowReverification) {
|
||||
throw new PhoneAlreadyUsedError();
|
||||
}
|
||||
|
||||
await this.smsService.startVerification(phone);
|
||||
}
|
||||
|
||||
async verifyPhoneCode(phone: string, code: string, userId: UserID | null): Promise<string> {
|
||||
if (!PHONE_E164_REGEX.test(phone)) {
|
||||
throw new InvalidPhoneNumberError();
|
||||
}
|
||||
|
||||
const isValid = await this.smsService.checkVerification(phone, code);
|
||||
if (!isValid) {
|
||||
throw new InvalidPhoneVerificationCodeError();
|
||||
}
|
||||
|
||||
const phoneToken = await this.generateSecureToken();
|
||||
const phoneVerificationToken = createPhoneVerificationToken(phoneToken);
|
||||
await this.repository.createPhoneToken(phoneVerificationToken, phone, userId);
|
||||
|
||||
return phoneToken;
|
||||
}
|
||||
|
||||
async addPhoneToAccount(userId: UserID, phoneToken: string): Promise<void> {
|
||||
const phoneVerificationToken = createPhoneVerificationToken(phoneToken);
|
||||
const tokenData = await this.repository.getPhoneToken(phoneVerificationToken);
|
||||
|
||||
if (!tokenData) {
|
||||
throw new PhoneVerificationRequiredError();
|
||||
}
|
||||
|
||||
if (tokenData.user_id && tokenData.user_id !== userId) {
|
||||
throw new PhoneVerificationRequiredError();
|
||||
}
|
||||
|
||||
const existingUser = await this.repository.findByPhone(tokenData.phone);
|
||||
if (existingUser && existingUser.id !== userId) {
|
||||
throw new PhoneAlreadyUsedError();
|
||||
}
|
||||
|
||||
const user = await this.repository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new PhoneVerificationRequiredError();
|
||||
}
|
||||
|
||||
this.assertNonBotUser(user);
|
||||
|
||||
if (user.flags & UserFlags.DELETED) {
|
||||
throw new PhoneVerificationRequiredError();
|
||||
}
|
||||
|
||||
const updates: {phone: string; suspicious_activity_flags?: number} = {
|
||||
phone: tokenData.phone,
|
||||
};
|
||||
|
||||
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
|
||||
const newFlags = user.suspiciousActivityFlags & ~PHONE_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS;
|
||||
if (newFlags !== user.suspiciousActivityFlags) {
|
||||
updates.suspicious_activity_flags = newFlags;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await this.repository.patchUpsert(userId, updates);
|
||||
|
||||
await this.repository.deletePhoneToken(phoneVerificationToken);
|
||||
|
||||
if (updatedUser) {
|
||||
await this.contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser,
|
||||
reason: 'user_requested',
|
||||
actorUserId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && updatedUser) {
|
||||
await userSearchService.updateUser(updatedUser).catch((error) => {
|
||||
Logger.error({userId, error}, 'Failed to update user in search');
|
||||
});
|
||||
}
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser!),
|
||||
});
|
||||
}
|
||||
|
||||
async removePhoneFromAccount(userId: UserID): Promise<void> {
|
||||
const user = await this.repository.findUniqueAssert(userId);
|
||||
|
||||
this.assertNonBotUser(user);
|
||||
|
||||
if (user.authenticatorTypes?.has(UserAuthenticatorTypes.SMS)) {
|
||||
throw new SmsMfaNotEnabledError();
|
||||
}
|
||||
|
||||
const updatedUser = await this.repository.patchUpsert(userId, {phone: null});
|
||||
|
||||
if (updatedUser) {
|
||||
await this.contactChangeLogService.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser,
|
||||
reason: 'user_requested',
|
||||
actorUserId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && updatedUser) {
|
||||
await userSearchService.updateUser(updatedUser).catch((error) => {
|
||||
Logger.error({userId, error}, 'Failed to update user in search');
|
||||
});
|
||||
}
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser!),
|
||||
});
|
||||
}
|
||||
}
|
||||
577
fluxer_api/src/auth/services/AuthRegistrationService.ts
Normal file
577
fluxer_api/src/auth/services/AuthRegistrationService.ts
Normal file
@@ -0,0 +1,577 @@
|
||||
/*
|
||||
* 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 {types} from 'cassandra-driver';
|
||||
import {UAParser} from 'ua-parser-js';
|
||||
import type {RegisterRequest} from '~/auth/AuthModel';
|
||||
import {createEmailVerificationToken, createInviteCode, createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
import {APIErrorCodes, UserFlags} from '~/Constants';
|
||||
import {FluxerAPIError, InputValidationError} from '~/Errors';
|
||||
import type {IDiscriminatorService} from '~/infrastructure/DiscriminatorService';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import {getMetricsService} from '~/infrastructure/MetricsService';
|
||||
import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteStore';
|
||||
import type {RedisActivityTracker} from '~/infrastructure/RedisActivityTracker';
|
||||
import type {SnowflakeService} from '~/infrastructure/SnowflakeService';
|
||||
import {InstanceConfigRepository} from '~/instance/InstanceConfigRepository';
|
||||
import type {InviteService} from '~/invite/InviteService';
|
||||
import {Logger} from '~/Logger';
|
||||
import {getUserSearchService} from '~/Meilisearch';
|
||||
import type {AuthSession, User} from '~/Models';
|
||||
import {UserSettings} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import * as AgeUtils from '~/utils/AgeUtils';
|
||||
import * as IpUtils from '~/utils/IpUtils';
|
||||
import {parseAcceptLanguage} from '~/utils/LocaleUtils';
|
||||
import {generateRandomUsername} from '~/utils/UsernameGenerator';
|
||||
|
||||
const MINIMUM_AGE_BY_COUNTRY: Record<string, number> = {
|
||||
KR: 14,
|
||||
VN: 15,
|
||||
AW: 16,
|
||||
BQ: 16,
|
||||
CW: 16,
|
||||
SX: 16,
|
||||
AT: 14,
|
||||
BG: 14,
|
||||
HR: 16,
|
||||
CY: 14,
|
||||
CZ: 15,
|
||||
FR: 15,
|
||||
DE: 16,
|
||||
GR: 15,
|
||||
HU: 16,
|
||||
IE: 16,
|
||||
IT: 14,
|
||||
LT: 14,
|
||||
LU: 16,
|
||||
NL: 16,
|
||||
PL: 16,
|
||||
RO: 16,
|
||||
SM: 16,
|
||||
RS: 15,
|
||||
SK: 16,
|
||||
SI: 16,
|
||||
ES: 14,
|
||||
CL: 14,
|
||||
CO: 14,
|
||||
PE: 14,
|
||||
VE: 14,
|
||||
};
|
||||
|
||||
const DEFAULT_MINIMUM_AGE = 13;
|
||||
|
||||
const USER_AGENT_TRUNCATE_LENGTH = 512;
|
||||
|
||||
interface RegistrationMetadataContext {
|
||||
metadata: Map<string, string>;
|
||||
clientIp: string;
|
||||
countryCode: string;
|
||||
location: string;
|
||||
city: string | null;
|
||||
region: string | null;
|
||||
osInfo: string;
|
||||
browserInfo: string;
|
||||
deviceInfo: string;
|
||||
truncatedUserAgent: string;
|
||||
fluxerTag: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
ipAddressReverse: string | null;
|
||||
}
|
||||
|
||||
const AGE_BUCKETS: Array<{label: string; min: number; max: number}> = [
|
||||
{label: '0-12', min: 0, max: 12},
|
||||
{label: '13-17', min: 13, max: 17},
|
||||
{label: '18-24', min: 18, max: 24},
|
||||
{label: '25-34', min: 25, max: 34},
|
||||
{label: '35-44', min: 35, max: 44},
|
||||
{label: '45-54', min: 45, max: 54},
|
||||
{label: '55-64', min: 55, max: 64},
|
||||
];
|
||||
|
||||
function determineAgeGroup(age: number | null): string {
|
||||
if (age === null || age < 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
for (const bucket of AGE_BUCKETS) {
|
||||
if (age >= bucket.min && age <= bucket.max) {
|
||||
return bucket.label;
|
||||
}
|
||||
}
|
||||
|
||||
return '65+';
|
||||
}
|
||||
|
||||
interface RegisterParams {
|
||||
data: RegisterRequest;
|
||||
request: Request;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
export class AuthRegistrationService {
|
||||
constructor(
|
||||
private repository: IUserRepository,
|
||||
private inviteService: InviteService,
|
||||
private rateLimitService: IRateLimitService,
|
||||
private emailService: IEmailService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private discriminatorService: IDiscriminatorService,
|
||||
private redisActivityTracker: RedisActivityTracker,
|
||||
private pendingJoinInviteStore: PendingJoinInviteStore,
|
||||
private cacheService: ICacheService,
|
||||
private hashPassword: (password: string) => Promise<string>,
|
||||
private isPasswordPwned: (password: string) => Promise<boolean>,
|
||||
private validateAge: (params: {dateOfBirth: string; minAge: number}) => boolean,
|
||||
private generateSecureToken: () => Promise<string>,
|
||||
private createAuthSession: (params: {user: User; request: Request}) => Promise<[string, AuthSession]>,
|
||||
) {}
|
||||
|
||||
async register({
|
||||
data,
|
||||
request,
|
||||
requestCache,
|
||||
}: RegisterParams): Promise<{user_id: string; token: string; pending_verification?: boolean}> {
|
||||
if (!data.consent) {
|
||||
throw InputValidationError.create('consent', 'You must agree to the Terms of Service and Privacy Policy');
|
||||
}
|
||||
|
||||
const countryCode = await IpUtils.getCountryCodeFromReq(request);
|
||||
const clientIp = IpUtils.requireClientIp(request);
|
||||
const countryResultDetailed = await IpUtils.getCountryCodeDetailed(clientIp);
|
||||
const minAge = (countryCode && MINIMUM_AGE_BY_COUNTRY[countryCode]) || DEFAULT_MINIMUM_AGE;
|
||||
if (!this.validateAge({dateOfBirth: data.date_of_birth, minAge})) {
|
||||
throw InputValidationError.create(
|
||||
'date_of_birth',
|
||||
`You must be at least ${minAge} years old to create an account`,
|
||||
);
|
||||
}
|
||||
|
||||
if (data.password && (await this.isPasswordPwned(data.password))) {
|
||||
throw InputValidationError.create('password', 'Password is too common');
|
||||
}
|
||||
|
||||
const enforceRateLimits = !Config.dev.relaxRegistrationRateLimits;
|
||||
|
||||
if (enforceRateLimits && data.email) {
|
||||
const emailRateLimit = await this.rateLimitService.checkLimit({
|
||||
identifier: `registration:email:${data.email}`,
|
||||
maxAttempts: 3,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!emailRateLimit.allowed) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.RATE_LIMITED,
|
||||
message: 'Too many registration attempts. Please try again later.',
|
||||
status: 429,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (enforceRateLimits) {
|
||||
const ipRateLimit = await this.rateLimitService.checkLimit({
|
||||
identifier: `registration:ip:${clientIp}`,
|
||||
maxAttempts: 5,
|
||||
windowMs: 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (!ipRateLimit.allowed) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.RATE_LIMITED,
|
||||
message: 'Too many registration attempts from this IP. Please try again later.',
|
||||
status: 429,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let betaCode = null;
|
||||
let hasValidBetaCode = false;
|
||||
if (data.beta_code) {
|
||||
if (Config.nodeEnv === 'development' && data.beta_code === 'NOVERIFY') {
|
||||
hasValidBetaCode = false;
|
||||
} else {
|
||||
betaCode = await this.repository.getBetaCode(data.beta_code);
|
||||
if (betaCode && !betaCode.redeemerId) {
|
||||
hasValidBetaCode = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rawEmail = data.email?.trim() || null;
|
||||
const normalizedEmail = rawEmail?.toLowerCase() || null;
|
||||
|
||||
if (normalizedEmail) {
|
||||
const emailTaken = await this.repository.findByEmail(normalizedEmail);
|
||||
if (emailTaken) {
|
||||
throw InputValidationError.create('email', 'Email already in use');
|
||||
}
|
||||
}
|
||||
|
||||
const username = data.username || generateRandomUsername();
|
||||
|
||||
const discriminatorResult = await this.discriminatorService.generateDiscriminator({
|
||||
username,
|
||||
isPremium: false,
|
||||
});
|
||||
|
||||
if (!discriminatorResult.available || discriminatorResult.discriminator === -1) {
|
||||
throw InputValidationError.create('username', 'Too many users with this username');
|
||||
}
|
||||
|
||||
const discriminator = discriminatorResult.discriminator;
|
||||
|
||||
let userId: UserID;
|
||||
if (normalizedEmail && process.env.EARLY_TESTER_EMAIL_HASH_TO_SNOWFLAKE) {
|
||||
const mapping = JSON.parse(process.env.EARLY_TESTER_EMAIL_HASH_TO_SNOWFLAKE) as Record<string, string>;
|
||||
const emailHash = crypto.createHash('sha256').update(normalizedEmail).digest('hex');
|
||||
const mappedUserId = mapping[emailHash];
|
||||
userId = mappedUserId ? createUserID(BigInt(mappedUserId)) : createUserID(this.snowflakeService.generate());
|
||||
} else {
|
||||
userId = createUserID(this.snowflakeService.generate());
|
||||
}
|
||||
|
||||
const acceptLanguage = request.headers.get('accept-language');
|
||||
const userLocale = parseAcceptLanguage(acceptLanguage);
|
||||
const passwordHash = data.password ? await this.hashPassword(data.password) : null;
|
||||
|
||||
const instanceConfigRepository = new InstanceConfigRepository();
|
||||
const instanceConfig = await instanceConfigRepository.getInstanceConfig();
|
||||
const isManualReviewActive = instanceConfigRepository.isManualReviewActiveNow(instanceConfig);
|
||||
|
||||
const shouldRequireVerification =
|
||||
(isManualReviewActive && Config.nodeEnv === 'production') ||
|
||||
(Config.nodeEnv === 'development' && data.beta_code === 'NOVERIFY');
|
||||
const isPendingVerification = shouldRequireVerification && !hasValidBetaCode;
|
||||
|
||||
let baseFlags = Config.nodeEnv === 'development' ? UserFlags.STAFF : 0n;
|
||||
if (isPendingVerification) {
|
||||
baseFlags |= UserFlags.PENDING_MANUAL_VERIFICATION;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const user = await this.repository.create({
|
||||
user_id: userId,
|
||||
username,
|
||||
discriminator: discriminator,
|
||||
global_name: data.global_name || null,
|
||||
bot: false,
|
||||
system: false,
|
||||
email: rawEmail,
|
||||
email_verified: false,
|
||||
email_bounced: false,
|
||||
phone: null,
|
||||
password_hash: passwordHash,
|
||||
password_last_changed_at: passwordHash ? new Date() : null,
|
||||
totp_secret: null,
|
||||
authenticator_types: new Set(),
|
||||
avatar_hash: null,
|
||||
avatar_color: null,
|
||||
banner_hash: null,
|
||||
banner_color: null,
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
accent_color: null,
|
||||
date_of_birth: types.LocalDate.fromString(data.date_of_birth),
|
||||
locale: userLocale,
|
||||
flags: baseFlags,
|
||||
premium_type: null,
|
||||
premium_since: null,
|
||||
premium_until: null,
|
||||
premium_will_cancel: null,
|
||||
premium_billing_cycle: null,
|
||||
premium_lifetime_sequence: null,
|
||||
stripe_subscription_id: null,
|
||||
stripe_customer_id: null,
|
||||
has_ever_purchased: null,
|
||||
suspicious_activity_flags: null,
|
||||
terms_agreed_at: new Date(),
|
||||
privacy_agreed_at: new Date(),
|
||||
last_active_at: now,
|
||||
last_active_ip: clientIp,
|
||||
temp_banned_until: null,
|
||||
pending_deletion_at: null,
|
||||
pending_bulk_message_deletion_at: null,
|
||||
pending_bulk_message_deletion_channel_count: null,
|
||||
pending_bulk_message_deletion_message_count: null,
|
||||
deletion_reason_code: null,
|
||||
deletion_public_reason: null,
|
||||
deletion_audit_log_reason: null,
|
||||
acls: null,
|
||||
first_refund_at: null,
|
||||
beta_code_allowance: 0,
|
||||
beta_code_last_reset_at: null,
|
||||
gift_inventory_server_seq: null,
|
||||
gift_inventory_client_seq: null,
|
||||
premium_onboarding_dismissed_at: null,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
await this.redisActivityTracker.updateActivity(user.id, now);
|
||||
|
||||
getMetricsService().counter({
|
||||
name: 'user.registration',
|
||||
dimensions: {
|
||||
country: countryCode ?? 'unknown',
|
||||
state: countryResultDetailed.region ?? 'unknown',
|
||||
ip_version: clientIp.includes(':') ? 'v6' : 'v4',
|
||||
},
|
||||
});
|
||||
|
||||
const age = data.date_of_birth ? AgeUtils.calculateAge(data.date_of_birth) : null;
|
||||
getMetricsService().counter({
|
||||
name: 'user.age',
|
||||
dimensions: {
|
||||
country: countryCode ?? 'unknown',
|
||||
state: countryResultDetailed.region ?? 'unknown',
|
||||
age: age !== null ? age.toString() : 'unknown',
|
||||
age_group: determineAgeGroup(age),
|
||||
},
|
||||
});
|
||||
|
||||
await this.repository.upsertSettings(
|
||||
UserSettings.getDefaultUserSettings({
|
||||
userId,
|
||||
locale: userLocale,
|
||||
isAdult: AgeUtils.isUserAdult(data.date_of_birth),
|
||||
}),
|
||||
);
|
||||
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService) {
|
||||
await userSearchService.indexUser(user).catch((error) => {
|
||||
Logger.error({userId: user.id, error}, 'Failed to index user in search');
|
||||
});
|
||||
}
|
||||
|
||||
if (rawEmail) {
|
||||
const emailVerifyToken = createEmailVerificationToken(await this.generateSecureToken());
|
||||
await this.repository.createEmailVerificationToken({
|
||||
token_: emailVerifyToken,
|
||||
user_id: userId,
|
||||
email: rawEmail,
|
||||
});
|
||||
|
||||
await this.emailService.sendEmailVerification(rawEmail, user.username, emailVerifyToken, user.locale);
|
||||
}
|
||||
|
||||
if (betaCode) {
|
||||
await this.repository.updateBetaCodeRedeemed(betaCode.code, userId, new Date());
|
||||
}
|
||||
|
||||
const registrationMetadata = await this.buildRegistrationMetadataContext(user, clientIp, request);
|
||||
|
||||
if (isPendingVerification) {
|
||||
await this.repository.createPendingVerification(userId, new Date(), registrationMetadata.metadata);
|
||||
}
|
||||
|
||||
await this.repository.createAuthorizedIp(userId, clientIp);
|
||||
|
||||
const inviteCodeToJoin = data.invite_code || Config.instance.autoJoinInviteCode;
|
||||
if (inviteCodeToJoin != null) {
|
||||
if (isPendingVerification) {
|
||||
await this.pendingJoinInviteStore.setPendingInvite(userId, inviteCodeToJoin);
|
||||
} else if (this.inviteService) {
|
||||
try {
|
||||
await this.inviteService.acceptInvite({
|
||||
userId,
|
||||
inviteCode: createInviteCode(inviteCodeToJoin),
|
||||
requestCache,
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.warn({inviteCode: inviteCodeToJoin, error}, 'Failed to auto-join invite on registration');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [token] = await this.createAuthSession({user, request});
|
||||
|
||||
this.sendRegistrationWebhook(user, registrationMetadata, instanceConfig.registrationAlertsWebhookUrl).catch(
|
||||
(error) => {
|
||||
Logger.warn({error, userId: user.id.toString()}, 'Failed to send registration webhook');
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
user_id: user.id.toString(),
|
||||
token,
|
||||
pending_verification: isPendingVerification ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildRegistrationMetadataContext(
|
||||
user: User,
|
||||
clientIp: string,
|
||||
request: Request,
|
||||
): Promise<RegistrationMetadataContext> {
|
||||
const countryResult = await IpUtils.getCountryCodeDetailed(clientIp);
|
||||
const userAgentHeader = request.headers.get('user-agent') ?? '';
|
||||
const trimmedUserAgent = userAgentHeader.trim();
|
||||
const parsedUserAgent = new UAParser(trimmedUserAgent).getResult();
|
||||
|
||||
const fluxerTag = `${user.username}#${user.discriminator.toString().padStart(4, '0')}`;
|
||||
const displayName = user.globalName || user.username;
|
||||
const emailDisplay = user.email || 'Not provided';
|
||||
const normalizedUserAgent = trimmedUserAgent.length > 0 ? trimmedUserAgent : 'Not provided';
|
||||
const truncatedUserAgent = this.truncateUserAgent(normalizedUserAgent);
|
||||
const normalizedIp = countryResult.normalizedIp ?? clientIp;
|
||||
const geoipReason = countryResult.reason ?? 'none';
|
||||
|
||||
const osInfo = parsedUserAgent.os.name
|
||||
? `${parsedUserAgent.os.name}${parsedUserAgent.os.version ? ` ${parsedUserAgent.os.version}` : ''}`
|
||||
: 'Unknown';
|
||||
const browserInfo = parsedUserAgent.browser.name
|
||||
? `${parsedUserAgent.browser.name}${parsedUserAgent.browser.version ? ` ${parsedUserAgent.browser.version}` : ''}`
|
||||
: 'Unknown';
|
||||
const deviceInfo = parsedUserAgent.device.vendor
|
||||
? `${parsedUserAgent.device.vendor} ${parsedUserAgent.device.model || ''}`.trim()
|
||||
: 'Desktop/Unknown';
|
||||
|
||||
const ipAddressReverse = await IpUtils.getIpAddressReverse(normalizedIp, this.cacheService);
|
||||
const locationLabel = IpUtils.formatGeoipLocation(countryResult);
|
||||
|
||||
const metadataEntries: Array<[string, string]> = [
|
||||
['fluxer_tag', fluxerTag],
|
||||
['display_name', displayName],
|
||||
['email', emailDisplay],
|
||||
['ip_address', clientIp],
|
||||
['normalized_ip', normalizedIp],
|
||||
['country_code', countryResult.countryCode],
|
||||
['location', locationLabel],
|
||||
['geoip_reason', geoipReason],
|
||||
['os', osInfo],
|
||||
['browser', browserInfo],
|
||||
['device', deviceInfo],
|
||||
['user_agent', truncatedUserAgent],
|
||||
];
|
||||
|
||||
if (countryResult.city) {
|
||||
metadataEntries.push(['city', countryResult.city]);
|
||||
}
|
||||
if (countryResult.region) {
|
||||
metadataEntries.push(['region', countryResult.region]);
|
||||
}
|
||||
if (countryResult.countryName) {
|
||||
metadataEntries.push(['country_name', countryResult.countryName]);
|
||||
}
|
||||
if (ipAddressReverse) {
|
||||
metadataEntries.push(['ip_address_reverse', ipAddressReverse]);
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: new Map(metadataEntries),
|
||||
clientIp,
|
||||
countryCode: countryResult.countryCode,
|
||||
location: locationLabel,
|
||||
city: countryResult.city,
|
||||
region: countryResult.region,
|
||||
osInfo,
|
||||
browserInfo,
|
||||
deviceInfo,
|
||||
truncatedUserAgent,
|
||||
fluxerTag,
|
||||
displayName,
|
||||
email: emailDisplay,
|
||||
ipAddressReverse,
|
||||
};
|
||||
}
|
||||
|
||||
private truncateUserAgent(userAgent: string): string {
|
||||
if (userAgent.length <= USER_AGENT_TRUNCATE_LENGTH) {
|
||||
return userAgent;
|
||||
}
|
||||
|
||||
return `${userAgent.slice(0, USER_AGENT_TRUNCATE_LENGTH)}...`;
|
||||
}
|
||||
|
||||
private async sendRegistrationWebhook(
|
||||
user: User,
|
||||
context: RegistrationMetadataContext,
|
||||
webhookUrl: string | null,
|
||||
): Promise<void> {
|
||||
if (!webhookUrl) return;
|
||||
|
||||
const {
|
||||
clientIp,
|
||||
countryCode,
|
||||
location,
|
||||
city,
|
||||
osInfo,
|
||||
browserInfo,
|
||||
deviceInfo,
|
||||
truncatedUserAgent,
|
||||
fluxerTag,
|
||||
displayName,
|
||||
email,
|
||||
ipAddressReverse,
|
||||
} = context;
|
||||
|
||||
const locationDisplay = city ? location : countryCode;
|
||||
|
||||
const embedFields = [
|
||||
{name: 'User ID', value: user.id.toString(), inline: true},
|
||||
{name: 'FluxerTag', value: fluxerTag, inline: true},
|
||||
{name: 'Display Name', value: displayName, inline: true},
|
||||
{name: 'Email', value: email, inline: true},
|
||||
{name: 'IP Address', value: clientIp, inline: true},
|
||||
{name: 'Location', value: locationDisplay, inline: true},
|
||||
{name: 'OS', value: osInfo, inline: true},
|
||||
{name: 'Browser', value: browserInfo, inline: true},
|
||||
{name: 'Device', value: deviceInfo, inline: true},
|
||||
{name: 'User Agent', value: truncatedUserAgent, inline: false},
|
||||
];
|
||||
|
||||
if (ipAddressReverse) {
|
||||
embedFields.splice(6, 0, {name: 'Reverse DNS', value: ipAddressReverse, inline: true});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
username: 'Registration Monitor',
|
||||
embeds: [
|
||||
{
|
||||
title: 'New Account Registered',
|
||||
color: 0x10b981,
|
||||
fields: embedFields,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
Logger.warn({status: response.status, body}, 'Failed to send registration webhook');
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to send registration webhook');
|
||||
}
|
||||
}
|
||||
}
|
||||
158
fluxer_api/src/auth/services/AuthSessionService.ts
Normal file
158
fluxer_api/src/auth/services/AuthSessionService.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* 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 {UAParser} from 'ua-parser-js';
|
||||
import {type AuthSessionResponse, mapAuthSessionsToResponse} from '~/auth/AuthModel';
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {AccessDeniedError} from '~/Errors';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {AuthSession, User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import * as IpUtils from '~/utils/IpUtils';
|
||||
|
||||
interface CreateAuthSessionParams {
|
||||
user: User;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface LogoutAuthSessionsParams {
|
||||
user: User;
|
||||
sessionIdHashes: Array<string>;
|
||||
}
|
||||
|
||||
interface UpdateUserActivityParams {
|
||||
userId: UserID;
|
||||
clientIp: string;
|
||||
}
|
||||
|
||||
export class AuthSessionService {
|
||||
constructor(
|
||||
private repository: IUserRepository,
|
||||
private gatewayService: IGatewayService,
|
||||
private generateAuthToken: () => Promise<string>,
|
||||
private getTokenIdHash: (token: string) => Uint8Array,
|
||||
) {}
|
||||
|
||||
async createAuthSession({user, request}: CreateAuthSessionParams): Promise<[token: string, AuthSession]> {
|
||||
if (user.isBot) {
|
||||
throw new AccessDeniedError('Bot users cannot create auth sessions');
|
||||
}
|
||||
|
||||
const token = await this.generateAuthToken();
|
||||
const ip = IpUtils.requireClientIp(request);
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
const platformHeader = request.headers.get('x-fluxer-platform')?.toLowerCase() ?? null;
|
||||
const parsedUserAgent = new UAParser(userAgent).getResult();
|
||||
const geoipResult = await IpUtils.getCountryCodeDetailed(ip);
|
||||
const clientLocationLabel = IpUtils.formatGeoipLocation(geoipResult);
|
||||
const detectedPlatform = parsedUserAgent.browser.name ?? 'Unknown';
|
||||
const clientPlatform = platformHeader === 'desktop' ? 'Fluxer Desktop' : detectedPlatform;
|
||||
|
||||
const authSession = await this.repository.createAuthSession({
|
||||
user_id: user.id,
|
||||
session_id_hash: Buffer.from(this.getTokenIdHash(token)),
|
||||
created_at: new Date(),
|
||||
approx_last_used_at: new Date(),
|
||||
client_ip: ip,
|
||||
client_os: parsedUserAgent.os.name ?? 'Unknown',
|
||||
client_platform: clientPlatform,
|
||||
client_country:
|
||||
(geoipResult.countryName ?? geoipResult.countryCode) === IpUtils.UNKNOWN_LOCATION
|
||||
? null
|
||||
: (geoipResult.countryName ?? geoipResult.countryCode),
|
||||
client_location: clientLocationLabel === IpUtils.UNKNOWN_LOCATION ? null : clientLocationLabel,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
return [token, authSession];
|
||||
}
|
||||
|
||||
async getAuthSessionByToken(token: string): Promise<AuthSession | null> {
|
||||
return this.repository.getAuthSessionByToken(Buffer.from(this.getTokenIdHash(token)));
|
||||
}
|
||||
|
||||
async getAuthSessions(userId: UserID): Promise<Array<AuthSessionResponse>> {
|
||||
const authSessions = await this.repository.listAuthSessions(userId);
|
||||
return mapAuthSessionsToResponse({authSessions});
|
||||
}
|
||||
|
||||
async updateAuthSessionLastUsed(tokenHash: Uint8Array): Promise<void> {
|
||||
await this.repository.updateAuthSessionLastUsed(Buffer.from(tokenHash));
|
||||
}
|
||||
|
||||
async updateUserActivity({userId, clientIp}: UpdateUserActivityParams): Promise<void> {
|
||||
await this.repository.updateUserActivity(userId, clientIp);
|
||||
}
|
||||
|
||||
async revokeToken(token: string): Promise<void> {
|
||||
const tokenHash = this.getTokenIdHash(token);
|
||||
const authSession = await this.repository.getAuthSessionByToken(Buffer.from(tokenHash));
|
||||
|
||||
if (authSession) {
|
||||
await this.repository.revokeAuthSession(Buffer.from(tokenHash));
|
||||
await this.gatewayService.terminateSession({
|
||||
userId: authSession.userId,
|
||||
sessionIdHashes: [Buffer.from(authSession.sessionIdHash).toString('base64url')],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async logoutAuthSessions({user, sessionIdHashes}: LogoutAuthSessionsParams): Promise<void> {
|
||||
const hashes = sessionIdHashes.map((hash) => Buffer.from(hash, 'base64url'));
|
||||
await this.repository.deleteAuthSessions(user.id, hashes);
|
||||
await this.gatewayService.terminateSession({
|
||||
userId: user.id,
|
||||
sessionIdHashes: sessionIdHashes,
|
||||
});
|
||||
}
|
||||
|
||||
async terminateAllUserSessions(userId: UserID): Promise<void> {
|
||||
const authSessions = await this.repository.listAuthSessions(userId);
|
||||
await this.repository.deleteAuthSessions(
|
||||
userId,
|
||||
authSessions.map((session) => session.sessionIdHash),
|
||||
);
|
||||
await this.gatewayService.terminateSession({
|
||||
userId,
|
||||
sessionIdHashes: authSessions.map((session) => Buffer.from(session.sessionIdHash).toString('base64url')),
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchAuthSessionChange({
|
||||
userId,
|
||||
oldAuthSessionIdHash,
|
||||
newAuthSessionIdHash,
|
||||
newToken,
|
||||
}: {
|
||||
userId: UserID;
|
||||
oldAuthSessionIdHash: string;
|
||||
newAuthSessionIdHash: string;
|
||||
newToken: string;
|
||||
}): Promise<void> {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'AUTH_SESSION_CHANGE',
|
||||
data: {
|
||||
old_auth_session_id_hash: oldAuthSessionIdHash,
|
||||
new_auth_session_id_hash: newAuthSessionIdHash,
|
||||
new_token: newToken,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
214
fluxer_api/src/auth/services/AuthUtilityService.ts
Normal file
214
fluxer_api/src/auth/services/AuthUtilityService.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* 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 {promisify} from 'node:util';
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {APIErrorCodes, UserFlags} from '~/Constants';
|
||||
import {AccessDeniedError, FluxerAPIError, InputValidationError, UnauthorizedError} from '~/Errors';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import type {IRateLimitService} from '~/infrastructure/IRateLimitService';
|
||||
import {Logger} from '~/Logger';
|
||||
import {getUserSearchService} from '~/Meilisearch';
|
||||
import type {User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import {mapUserToPrivateResponse} from '~/user/UserModel';
|
||||
import * as AgeUtils from '~/utils/AgeUtils';
|
||||
import * as RandomUtils from '~/utils/RandomUtils';
|
||||
|
||||
const randomBytesAsync = promisify(crypto.randomBytes);
|
||||
const ALPHANUMERIC_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
const base62Encode = (buffer: Uint8Array): string => {
|
||||
let num = BigInt(`0x${Buffer.from(buffer).toString('hex')}`);
|
||||
const base = BigInt(ALPHANUMERIC_CHARS.length);
|
||||
let encoded = '';
|
||||
while (num > 0) {
|
||||
const remainder = num % base;
|
||||
encoded = ALPHANUMERIC_CHARS[Number(remainder)] + encoded;
|
||||
num = num / base;
|
||||
}
|
||||
return encoded;
|
||||
};
|
||||
|
||||
interface ValidateAgeParams {
|
||||
dateOfBirth: string;
|
||||
minAge: number;
|
||||
}
|
||||
|
||||
interface CheckEmailChangeRateLimitParams {
|
||||
userId: UserID;
|
||||
}
|
||||
|
||||
export class AuthUtilityService {
|
||||
constructor(
|
||||
private repository: IUserRepository,
|
||||
private rateLimitService: IRateLimitService,
|
||||
private gatewayService: IGatewayService,
|
||||
) {}
|
||||
|
||||
async generateSecureToken(length = 64): Promise<string> {
|
||||
return RandomUtils.randomString(length);
|
||||
}
|
||||
|
||||
async generateAuthToken(): Promise<string> {
|
||||
const bytes = await randomBytesAsync(27);
|
||||
let token = base62Encode(new Uint8Array(bytes));
|
||||
|
||||
while (token.length < 36) {
|
||||
const extraBytes = await randomBytesAsync(1);
|
||||
token += ALPHANUMERIC_CHARS[extraBytes[0] % ALPHANUMERIC_CHARS.length];
|
||||
}
|
||||
|
||||
if (token.length > 36) {
|
||||
token = token.slice(0, 36);
|
||||
}
|
||||
|
||||
return `flx_${token}`;
|
||||
}
|
||||
|
||||
generateBackupCodes(): Array<string> {
|
||||
return Array.from({length: 10}, () => {
|
||||
return `${RandomUtils.randomString(4).toLowerCase()}-${RandomUtils.randomString(4).toLowerCase()}`;
|
||||
});
|
||||
}
|
||||
|
||||
getTokenIdHash(token: string): Uint8Array {
|
||||
return new Uint8Array(crypto.createHash('sha256').update(token).digest());
|
||||
}
|
||||
|
||||
async checkEmailChangeRateLimit({
|
||||
userId,
|
||||
}: CheckEmailChangeRateLimitParams): Promise<{allowed: boolean; retryAfter?: number}> {
|
||||
const rateLimit = await this.rateLimitService.checkLimit({
|
||||
identifier: `email_change:${userId}`,
|
||||
maxAttempts: 3,
|
||||
windowMs: 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
return {
|
||||
allowed: rateLimit.allowed,
|
||||
retryAfter: rateLimit.retryAfter,
|
||||
};
|
||||
}
|
||||
|
||||
validateAge({dateOfBirth, minAge}: ValidateAgeParams): boolean {
|
||||
const birthDate = new Date(dateOfBirth);
|
||||
const age = AgeUtils.calculateAge({
|
||||
year: birthDate.getFullYear(),
|
||||
month: birthDate.getMonth() + 1,
|
||||
day: birthDate.getDate(),
|
||||
});
|
||||
return age >= minAge;
|
||||
}
|
||||
|
||||
assertNonBotUser(user: User): void {
|
||||
if (user.isBot) {
|
||||
throw new AccessDeniedError('Bot users cannot use auth endpoints');
|
||||
}
|
||||
}
|
||||
|
||||
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
|
||||
return this.repository.authorizeIpByToken(token);
|
||||
}
|
||||
|
||||
checkAccountBanStatus(user: User): {
|
||||
isPermanentlyBanned: boolean;
|
||||
isTempBanned: boolean;
|
||||
tempBanExpired: boolean;
|
||||
} {
|
||||
const isPermanentlyBanned = !!(user.flags & UserFlags.DELETED);
|
||||
const hasTempBan = !!(user.flags & UserFlags.DISABLED && user.tempBannedUntil);
|
||||
const tempBanExpired = hasTempBan && user.tempBannedUntil! <= new Date();
|
||||
|
||||
return {
|
||||
isPermanentlyBanned,
|
||||
isTempBanned: hasTempBan && !tempBanExpired,
|
||||
tempBanExpired,
|
||||
};
|
||||
}
|
||||
|
||||
async handleBanStatus(user: User): Promise<User> {
|
||||
const banStatus = this.checkAccountBanStatus(user);
|
||||
|
||||
if (banStatus.isPermanentlyBanned) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.ACCOUNT_DISABLED,
|
||||
message: 'Your account has been permanently suspended',
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (banStatus.isTempBanned) {
|
||||
throw new FluxerAPIError({
|
||||
code: APIErrorCodes.ACCOUNT_DISABLED,
|
||||
message: 'Your account has been temporarily suspended',
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (banStatus.tempBanExpired) {
|
||||
const updatedUser = await this.repository.patchUpsert(user.id, {
|
||||
flags: user.flags & ~UserFlags.DISABLED,
|
||||
temp_banned_until: null,
|
||||
});
|
||||
|
||||
if (!updatedUser) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async redeemBetaCode(userId: UserID, betaCode: string): Promise<void> {
|
||||
const user = await this.repository.findUniqueAssert(userId);
|
||||
|
||||
if ((user.flags & UserFlags.PENDING_MANUAL_VERIFICATION) === 0n) {
|
||||
throw InputValidationError.create('beta_code', 'Your account is already verified');
|
||||
}
|
||||
|
||||
const code = await this.repository.getBetaCode(betaCode);
|
||||
if (!code || code.redeemerId) {
|
||||
throw InputValidationError.create('beta_code', 'Invalid or already used beta code');
|
||||
}
|
||||
|
||||
await this.repository.updateBetaCodeRedeemed(betaCode, userId, new Date());
|
||||
await this.repository.deletePendingVerification(userId);
|
||||
|
||||
const updatedUser = await this.repository.patchUpsert(userId, {
|
||||
flags: user.flags & ~UserFlags.PENDING_MANUAL_VERIFICATION,
|
||||
});
|
||||
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService && updatedUser) {
|
||||
await userSearchService.updateUser(updatedUser).catch((error) => {
|
||||
Logger.error({userId, error}, 'Failed to update user in search');
|
||||
});
|
||||
}
|
||||
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser!),
|
||||
});
|
||||
}
|
||||
}
|
||||
150
fluxer_api/src/auth/services/DesktopHandoffService.ts
Normal file
150
fluxer_api/src/auth/services/DesktopHandoffService.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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 {randomBytes} from 'node:crypto';
|
||||
import {APIErrorCodes} from '~/constants/API';
|
||||
import {BadRequestError} from '~/Errors';
|
||||
import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
|
||||
const HANDOFF_CODE_EXPIRY_SECONDS = 5 * 60;
|
||||
const HANDOFF_CODE_PREFIX = 'desktop-handoff:';
|
||||
const HANDOFF_TOKEN_PREFIX = 'desktop-handoff-token:';
|
||||
|
||||
const CODE_CHARACTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
const CODE_LENGTH = 8;
|
||||
const NORMALIZED_CODE_REGEX = /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{8}$/;
|
||||
|
||||
interface HandoffData {
|
||||
createdAt: number;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
interface HandoffTokenData {
|
||||
token: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
function generateHandoffCode(): string {
|
||||
const bytes = randomBytes(CODE_LENGTH);
|
||||
let code = '';
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
code += CODE_CHARACTERS[bytes[i] % CODE_CHARACTERS.length];
|
||||
}
|
||||
return `${code.slice(0, 4)}-${code.slice(4, 8)}`;
|
||||
}
|
||||
|
||||
function normalizeHandoffCode(code: string): string {
|
||||
return code.replace(/[-\s]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function assertValidHandoffCode(code: string): void {
|
||||
if (!NORMALIZED_CODE_REGEX.test(code)) {
|
||||
throw new BadRequestError({
|
||||
code: APIErrorCodes.INVALID_HANDOFF_CODE,
|
||||
message: 'Invalid handoff code format',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class DesktopHandoffService {
|
||||
constructor(private readonly cacheService: ICacheService) {}
|
||||
|
||||
async initiateHandoff(userAgent?: string): Promise<{code: string; expiresAt: Date}> {
|
||||
const code = generateHandoffCode();
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
|
||||
const handoffData: HandoffData = {
|
||||
createdAt: Date.now(),
|
||||
userAgent,
|
||||
};
|
||||
|
||||
await this.cacheService.set(`${HANDOFF_CODE_PREFIX}${normalizedCode}`, handoffData, HANDOFF_CODE_EXPIRY_SECONDS);
|
||||
|
||||
const expiresAt = new Date(Date.now() + HANDOFF_CODE_EXPIRY_SECONDS * 1000);
|
||||
|
||||
return {code, expiresAt};
|
||||
}
|
||||
|
||||
async completeHandoff(code: string, token: string, userId: string): Promise<void> {
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
assertValidHandoffCode(normalizedCode);
|
||||
const handoffData = await this.cacheService.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
|
||||
|
||||
if (!handoffData) {
|
||||
throw new BadRequestError({
|
||||
code: APIErrorCodes.INVALID_HANDOFF_CODE,
|
||||
message: 'Invalid or expired handoff code',
|
||||
});
|
||||
}
|
||||
|
||||
const tokenData: HandoffTokenData = {
|
||||
token,
|
||||
userId,
|
||||
};
|
||||
|
||||
const remainingSeconds = Math.max(
|
||||
0,
|
||||
HANDOFF_CODE_EXPIRY_SECONDS - Math.floor((Date.now() - handoffData.createdAt) / 1000),
|
||||
);
|
||||
|
||||
if (remainingSeconds <= 0) {
|
||||
throw new BadRequestError({
|
||||
code: APIErrorCodes.HANDOFF_CODE_EXPIRED,
|
||||
message: 'Handoff code has expired',
|
||||
});
|
||||
}
|
||||
|
||||
await this.cacheService.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds);
|
||||
|
||||
await this.cacheService.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
|
||||
}
|
||||
|
||||
async getHandoffStatus(
|
||||
code: string,
|
||||
): Promise<{status: 'pending' | 'completed' | 'expired'; token?: string; userId?: string}> {
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
assertValidHandoffCode(normalizedCode);
|
||||
const tokenData = await this.cacheService.getAndDelete<HandoffTokenData>(
|
||||
`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`,
|
||||
);
|
||||
|
||||
if (tokenData) {
|
||||
return {
|
||||
status: 'completed',
|
||||
token: tokenData.token,
|
||||
userId: tokenData.userId,
|
||||
};
|
||||
}
|
||||
|
||||
const handoffData = await this.cacheService.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
|
||||
|
||||
if (handoffData) {
|
||||
return {status: 'pending'};
|
||||
}
|
||||
|
||||
return {status: 'expired'};
|
||||
}
|
||||
|
||||
async cancelHandoff(code: string): Promise<void> {
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
assertValidHandoffCode(normalizedCode);
|
||||
await this.cacheService.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
|
||||
await this.cacheService.delete(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`);
|
||||
}
|
||||
}
|
||||
76
fluxer_api/src/auth/services/SudoModeService.ts
Normal file
76
fluxer_api/src/auth/services/SudoModeService.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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 {jwtVerify, SignJWT} from 'jose';
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {Config} from '~/Config';
|
||||
|
||||
const SUDO_TOKEN_EXPIRY_SECONDS = 5 * 60;
|
||||
|
||||
export class SudoModeService {
|
||||
private readonly secret: Uint8Array;
|
||||
|
||||
constructor() {
|
||||
this.secret = new TextEncoder().encode(Config.auth.sudoModeSecret);
|
||||
}
|
||||
|
||||
async generateSudoToken(userId: UserID): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const jwt = await new SignJWT({
|
||||
type: 'sudo',
|
||||
})
|
||||
.setProtectedHeader({alg: 'HS256'})
|
||||
.setSubject(userId.toString())
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + SUDO_TOKEN_EXPIRY_SECONDS)
|
||||
.sign(this.secret);
|
||||
|
||||
return jwt;
|
||||
}
|
||||
|
||||
async verifySudoToken(token: string, userId: UserID): Promise<boolean> {
|
||||
try {
|
||||
const {payload} = await jwtVerify(token, this.secret, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
|
||||
if (payload.type !== 'sudo') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.sub !== userId.toString()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sudoModeServiceInstance: SudoModeService | null = null;
|
||||
|
||||
export function getSudoModeService(): SudoModeService {
|
||||
if (!sudoModeServiceInstance) {
|
||||
sudoModeServiceInstance = new SudoModeService();
|
||||
}
|
||||
return sudoModeServiceInstance;
|
||||
}
|
||||
157
fluxer_api/src/auth/services/SudoVerificationService.ts
Normal file
157
fluxer_api/src/auth/services/SudoVerificationService.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* 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 {AuthenticationResponseJSON} from '@simplewebauthn/server';
|
||||
import type {Context} from 'hono';
|
||||
import type {HonoEnv} from '~/App';
|
||||
import type {AuthService} from '~/auth/AuthService';
|
||||
import type {AuthMfaService} from '~/auth/services/AuthMfaService';
|
||||
import {getSudoModeService} from '~/auth/services/SudoModeService';
|
||||
import {InputValidationError} from '~/Errors';
|
||||
import {SudoModeRequiredError} from '~/errors/SudoModeRequiredError';
|
||||
import type {User} from '~/Models';
|
||||
import {SUDO_MODE_HEADER} from '~/middleware/SudoModeMiddleware';
|
||||
import {setSudoCookie} from '~/utils/SudoCookieUtils';
|
||||
|
||||
interface SudoVerificationBody {
|
||||
password?: string;
|
||||
mfa_method?: 'totp' | 'sms' | 'webauthn';
|
||||
mfa_code?: string;
|
||||
webauthn_response?: AuthenticationResponseJSON;
|
||||
webauthn_challenge?: string;
|
||||
}
|
||||
|
||||
type SudoVerificationMethod = 'password' | 'mfa' | 'sudo_token';
|
||||
|
||||
export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boolean {
|
||||
return (user.authenticatorTypes?.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
export interface SudoVerificationResult {
|
||||
verified: boolean;
|
||||
method: SudoVerificationMethod;
|
||||
sudoToken?: string;
|
||||
}
|
||||
|
||||
export interface SudoVerificationOptions {
|
||||
issueSudoToken?: boolean;
|
||||
}
|
||||
|
||||
async function verifySudoMode(
|
||||
ctx: Context<HonoEnv>,
|
||||
user: User,
|
||||
body: SudoVerificationBody,
|
||||
authService: AuthService,
|
||||
mfaService: AuthMfaService,
|
||||
options: SudoVerificationOptions = {},
|
||||
): Promise<SudoVerificationResult> {
|
||||
if (user.isBot) {
|
||||
return {verified: true, method: 'sudo_token'};
|
||||
}
|
||||
|
||||
const hasMfa = userHasMfa(user);
|
||||
const issueSudoToken = options.issueSudoToken ?? hasMfa;
|
||||
|
||||
if (hasMfa && ctx.get('sudoModeValid')) {
|
||||
const sudoToken = ctx.get('sudoModeToken') ?? ctx.req.header(SUDO_MODE_HEADER) ?? undefined;
|
||||
return {verified: true, method: 'sudo_token', sudoToken: issueSudoToken ? sudoToken : undefined};
|
||||
}
|
||||
|
||||
const incomingToken = ctx.req.header(SUDO_MODE_HEADER);
|
||||
if (!hasMfa && incomingToken && ctx.get('sudoModeValid')) {
|
||||
return {verified: true, method: 'sudo_token', sudoToken: issueSudoToken ? incomingToken : undefined};
|
||||
}
|
||||
|
||||
if (hasMfa && body.mfa_method) {
|
||||
const result = await mfaService.verifySudoMfa({
|
||||
userId: user.id,
|
||||
method: body.mfa_method,
|
||||
code: body.mfa_code,
|
||||
webauthnResponse: body.webauthn_response,
|
||||
webauthnChallenge: body.webauthn_challenge,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw InputValidationError.create('mfa_code', result.error ?? 'Invalid MFA code');
|
||||
}
|
||||
|
||||
const sudoModeService = getSudoModeService();
|
||||
const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined;
|
||||
|
||||
return {verified: true, sudoToken, method: 'mfa'};
|
||||
}
|
||||
|
||||
const isUnclaimedAccount = !user.passwordHash;
|
||||
if (isUnclaimedAccount && !hasMfa) {
|
||||
return {verified: true, method: 'password'};
|
||||
}
|
||||
|
||||
if (body.password && !hasMfa) {
|
||||
if (!user.passwordHash) {
|
||||
throw InputValidationError.create('password', 'Password not set');
|
||||
}
|
||||
|
||||
const passwordValid = await authService.verifyPassword({
|
||||
password: body.password,
|
||||
passwordHash: user.passwordHash,
|
||||
});
|
||||
|
||||
if (!passwordValid) {
|
||||
throw InputValidationError.create('password', 'Invalid password');
|
||||
}
|
||||
|
||||
return {verified: true, method: 'password'};
|
||||
}
|
||||
|
||||
throw new SudoModeRequiredError(hasMfa);
|
||||
}
|
||||
|
||||
function setSudoTokenHeader(
|
||||
ctx: Context<HonoEnv>,
|
||||
result: SudoVerificationResult,
|
||||
options: SudoVerificationOptions = {},
|
||||
): void {
|
||||
const issueSudoToken = options.issueSudoToken ?? true;
|
||||
|
||||
if (!issueSudoToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenToSet = result.sudoToken ?? ctx.req.header(SUDO_MODE_HEADER);
|
||||
if (tokenToSet) {
|
||||
ctx.header(SUDO_MODE_HEADER, tokenToSet);
|
||||
const user = ctx.get('user');
|
||||
if (user) {
|
||||
setSudoCookie(ctx, tokenToSet, user.id.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireSudoMode(
|
||||
ctx: Context<HonoEnv>,
|
||||
user: User,
|
||||
body: SudoVerificationBody,
|
||||
authService: AuthService,
|
||||
mfaService: AuthMfaService,
|
||||
options: SudoVerificationOptions = {},
|
||||
): Promise<SudoVerificationResult> {
|
||||
const sudoResult = await verifySudoMode(ctx, user, body, authService, mfaService, options);
|
||||
setSudoTokenHeader(ctx, sudoResult, options);
|
||||
return sudoResult;
|
||||
}
|
||||
75
fluxer_api/src/channel/AttachmentDTOs.ts
Normal file
75
fluxer_api/src/channel/AttachmentDTOs.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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 {coerceNumberFromString, createStringType, FilenameType, Int32Type, z} from '~/Schema';
|
||||
|
||||
export const ClientAttachmentRequest = z.object({
|
||||
id: coerceNumberFromString(Int32Type),
|
||||
filename: FilenameType,
|
||||
title: createStringType(1, 1024).nullish(),
|
||||
description: createStringType(1, 1024).nullish(),
|
||||
flags: coerceNumberFromString(Int32Type).default(0),
|
||||
});
|
||||
export type ClientAttachmentRequest = z.infer<typeof ClientAttachmentRequest>;
|
||||
|
||||
export interface UploadedAttachment {
|
||||
id: number;
|
||||
filename: string;
|
||||
upload_filename: string;
|
||||
file_size: number;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
export interface AttachmentToProcess {
|
||||
id: number;
|
||||
filename: string;
|
||||
upload_filename: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
flags: number;
|
||||
file_size: number;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
export const ClientAttachmentReferenceRequest = z.object({
|
||||
id: coerceNumberFromString(Int32Type),
|
||||
filename: FilenameType.optional(),
|
||||
title: createStringType(1, 1024).nullish(),
|
||||
description: createStringType(1, 1024).nullish(),
|
||||
flags: coerceNumberFromString(Int32Type).default(0),
|
||||
});
|
||||
export type ClientAttachmentReferenceRequest = z.infer<typeof ClientAttachmentReferenceRequest>;
|
||||
|
||||
export function mergeUploadWithClientData(
|
||||
uploaded: UploadedAttachment,
|
||||
clientData?: ClientAttachmentRequest | ClientAttachmentReferenceRequest,
|
||||
): AttachmentToProcess {
|
||||
return {
|
||||
id: uploaded.id,
|
||||
filename: uploaded.filename,
|
||||
upload_filename: uploaded.upload_filename,
|
||||
file_size: uploaded.file_size,
|
||||
content_type: uploaded.content_type,
|
||||
title: clientData?.title ?? null,
|
||||
description: clientData?.description ?? null,
|
||||
flags: 'flags' in (clientData ?? {}) ? (clientData as ClientAttachmentRequest).flags : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export type AttachmentRequestData = AttachmentToProcess | ClientAttachmentRequest | ClientAttachmentReferenceRequest;
|
||||
25
fluxer_api/src/channel/ChannelController.ts
Normal file
25
fluxer_api/src/channel/ChannelController.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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 {registerChannelControllers} from './controllers';
|
||||
|
||||
export const ChannelController = (app: HonoApp) => {
|
||||
registerChannelControllers(app);
|
||||
};
|
||||
226
fluxer_api/src/channel/ChannelMappers.ts
Normal file
226
fluxer_api/src/channel/ChannelMappers.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* 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 {UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes} from '~/Constants';
|
||||
import type {UserCacheService} from '~/infrastructure/UserCacheService';
|
||||
import type {Channel} from '~/Models';
|
||||
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
|
||||
import {getCachedUserPartialResponses} from '~/user/UserCacheHelpers';
|
||||
import type {ChannelOverwriteResponse, ChannelPartialResponse, ChannelResponse} from './ChannelTypes';
|
||||
|
||||
interface MapChannelToResponseParams {
|
||||
channel: Channel;
|
||||
currentUserId: UserID | null;
|
||||
userCacheService: UserCacheService;
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
function serializeBaseChannelFields(channel: Channel) {
|
||||
return {
|
||||
id: channel.id.toString(),
|
||||
type: channel.type,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeMessageableFields(channel: Channel) {
|
||||
return {
|
||||
last_message_id: channel.lastMessageId ? channel.lastMessageId.toString() : null,
|
||||
last_pin_timestamp: channel.lastPinTimestamp ? channel.lastPinTimestamp.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeGuildChannelFields(channel: Channel) {
|
||||
return {
|
||||
guild_id: channel.guildId?.toString(),
|
||||
name: channel.name ?? undefined,
|
||||
position: channel.position ?? undefined,
|
||||
permission_overwrites: serializePermissionOverwrites(channel),
|
||||
};
|
||||
}
|
||||
|
||||
function serializePositionableGuildChannelFields(channel: Channel) {
|
||||
return {
|
||||
...serializeGuildChannelFields(channel),
|
||||
parent_id: channel.parentId ? channel.parentId.toString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function serializePermissionOverwrites(channel: Channel): Array<ChannelOverwriteResponse> {
|
||||
if (!channel.permissionOverwrites) return [];
|
||||
return Array.from(channel.permissionOverwrites).map(([targetId, overwrite]) => ({
|
||||
id: targetId.toString(),
|
||||
type: overwrite.type,
|
||||
allow: (overwrite.allow ?? 0n).toString(),
|
||||
deny: (overwrite.deny ?? 0n).toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
function serializeGuildTextChannel(channel: Channel): ChannelResponse {
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializeMessageableFields(channel),
|
||||
...serializePositionableGuildChannelFields(channel),
|
||||
topic: channel.topic,
|
||||
nsfw: channel.isNsfw,
|
||||
rate_limit_per_user: channel.rateLimitPerUser,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeGuildVoiceChannel(channel: Channel): ChannelResponse {
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializePositionableGuildChannelFields(channel),
|
||||
bitrate: channel.bitrate,
|
||||
user_limit: channel.userLimit,
|
||||
rtc_region: channel.rtcRegion,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeGuildCategoryChannel(channel: Channel): ChannelResponse {
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializeGuildChannelFields(channel),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeGuildLinkChannel(channel: Channel): ChannelResponse {
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializePositionableGuildChannelFields(channel),
|
||||
url: channel.url,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeDMChannel(channel: Channel): ChannelResponse {
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializeMessageableFields(channel),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeGroupDMChannel(channel: Channel): ChannelResponse {
|
||||
const nicknameMap = channel.nicknames ?? new Map<string, string>();
|
||||
const nicks: Record<string, string> = {};
|
||||
if (nicknameMap.size > 0) {
|
||||
for (const [userId, nickname] of nicknameMap) {
|
||||
const key = String(userId);
|
||||
nicks[key] = nickname;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializeMessageableFields(channel),
|
||||
name: channel.name ?? undefined,
|
||||
icon: channel.iconHash ?? null,
|
||||
owner_id: channel.ownerId ? channel.ownerId.toString() : null,
|
||||
nicks: nicknameMap.size > 0 ? nicks : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeDMPersonalNotesChannel(channel: Channel): ChannelResponse {
|
||||
return {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializeMessageableFields(channel),
|
||||
};
|
||||
}
|
||||
|
||||
async function addDMRecipients(
|
||||
response: ChannelResponse,
|
||||
channel: Channel,
|
||||
currentUserId: UserID | null,
|
||||
userCacheService: UserCacheService,
|
||||
requestCache: RequestCache,
|
||||
): Promise<void> {
|
||||
if (
|
||||
channel.guildId == null &&
|
||||
channel.type !== ChannelTypes.DM_PERSONAL_NOTES &&
|
||||
currentUserId != null &&
|
||||
channel.recipientIds &&
|
||||
channel.recipientIds.size > 0
|
||||
) {
|
||||
const recipientIds = Array.from(channel.recipientIds).filter((id) => id !== currentUserId);
|
||||
if (recipientIds.length > 0) {
|
||||
const userPartials = await getCachedUserPartialResponses({
|
||||
userIds: recipientIds,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
response.recipients = recipientIds.map((userId) => userPartials.get(userId)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId,
|
||||
userCacheService,
|
||||
requestCache,
|
||||
}: MapChannelToResponseParams): Promise<ChannelResponse> {
|
||||
let response: ChannelResponse;
|
||||
|
||||
switch (channel.type) {
|
||||
case ChannelTypes.GUILD_TEXT:
|
||||
response = serializeGuildTextChannel(channel);
|
||||
break;
|
||||
case ChannelTypes.GUILD_VOICE:
|
||||
response = serializeGuildVoiceChannel(channel);
|
||||
break;
|
||||
case ChannelTypes.GUILD_CATEGORY:
|
||||
response = serializeGuildCategoryChannel(channel);
|
||||
break;
|
||||
case ChannelTypes.GUILD_LINK:
|
||||
response = serializeGuildLinkChannel(channel);
|
||||
break;
|
||||
case ChannelTypes.DM:
|
||||
response = serializeDMChannel(channel);
|
||||
await addDMRecipients(response, channel, currentUserId, userCacheService, requestCache);
|
||||
break;
|
||||
case ChannelTypes.GROUP_DM:
|
||||
response = serializeGroupDMChannel(channel);
|
||||
await addDMRecipients(response, channel, currentUserId, userCacheService, requestCache);
|
||||
break;
|
||||
case ChannelTypes.DM_PERSONAL_NOTES:
|
||||
response = serializeDMPersonalNotesChannel(channel);
|
||||
break;
|
||||
default:
|
||||
response = {
|
||||
...serializeBaseChannelFields(channel),
|
||||
...serializeMessageableFields(channel),
|
||||
guild_id: channel.guildId?.toString(),
|
||||
name: channel.name ?? undefined,
|
||||
topic: channel.topic,
|
||||
url: channel.url ?? undefined,
|
||||
icon: channel.iconHash ?? null,
|
||||
owner_id: channel.ownerId ? channel.ownerId.toString() : null,
|
||||
position: channel.position ?? undefined,
|
||||
parent_id: channel.parentId ? channel.parentId.toString() : null,
|
||||
permission_overwrites: channel.guildId ? serializePermissionOverwrites(channel) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const mapChannelToPartialResponse = (channel: Channel): ChannelPartialResponse => ({
|
||||
id: channel.id.toString(),
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
});
|
||||
24
fluxer_api/src/channel/ChannelModel.ts
Normal file
24
fluxer_api/src/channel/ChannelModel.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export * from './ChannelMappers';
|
||||
export * from './ChannelTypes';
|
||||
export * from './EmbedTypes';
|
||||
export * from './MessageMappers';
|
||||
export * from './MessageTypes';
|
||||
232
fluxer_api/src/channel/ChannelRepository.ts
Normal file
232
fluxer_api/src/channel/ChannelRepository.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AttachmentID, ChannelID, EmojiID, GuildID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {AttachmentLookupRow, ChannelRow, MessageRow} from '~/database/CassandraTypes';
|
||||
import type {Channel, Message, MessageReaction} from '~/Models';
|
||||
import {IChannelRepository} from './IChannelRepository';
|
||||
import {ChannelRepository as NewChannelRepository} from './repositories/ChannelRepository';
|
||||
|
||||
export class ChannelRepository extends IChannelRepository {
|
||||
private repository: NewChannelRepository;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.repository = new NewChannelRepository();
|
||||
}
|
||||
|
||||
get channelData() {
|
||||
return this.repository.channelData;
|
||||
}
|
||||
|
||||
get messages() {
|
||||
return this.repository.messages;
|
||||
}
|
||||
|
||||
get messageInteractions() {
|
||||
return this.repository.messageInteractions;
|
||||
}
|
||||
|
||||
async findUnique(channelId: ChannelID): Promise<Channel | null> {
|
||||
return this.repository.channelData.findUnique(channelId);
|
||||
}
|
||||
|
||||
async upsert(data: ChannelRow): Promise<Channel> {
|
||||
return this.repository.channelData.upsert(data);
|
||||
}
|
||||
|
||||
async updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise<void> {
|
||||
return this.repository.channelData.updateLastMessageId(channelId, messageId);
|
||||
}
|
||||
|
||||
async delete(channelId: ChannelID, guildId?: GuildID): Promise<void> {
|
||||
return this.repository.channelData.delete(channelId, guildId);
|
||||
}
|
||||
|
||||
async listMessages(
|
||||
channelId: ChannelID,
|
||||
beforeMessageId?: MessageID,
|
||||
limit?: number,
|
||||
afterMessageId?: MessageID,
|
||||
): Promise<Array<Message>> {
|
||||
return this.repository.messages.listMessages(channelId, beforeMessageId, limit, afterMessageId);
|
||||
}
|
||||
|
||||
async getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null> {
|
||||
return this.repository.messages.getMessage(channelId, messageId);
|
||||
}
|
||||
|
||||
async upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message> {
|
||||
return this.repository.messages.upsertMessage(data, oldData);
|
||||
}
|
||||
|
||||
async deleteMessage(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
authorId: UserID,
|
||||
pinnedTimestamp?: Date,
|
||||
): Promise<void> {
|
||||
return this.repository.messages.deleteMessage(channelId, messageId, authorId, pinnedTimestamp);
|
||||
}
|
||||
|
||||
async bulkDeleteMessages(channelId: ChannelID, messageIds: Array<MessageID>): Promise<void> {
|
||||
return this.repository.messages.bulkDeleteMessages(channelId, messageIds);
|
||||
}
|
||||
|
||||
async listChannelPins(channelId: ChannelID, beforePinnedTimestamp: Date, limit?: number): Promise<Array<Message>> {
|
||||
return this.repository.messageInteractions.listChannelPins(channelId, beforePinnedTimestamp, limit);
|
||||
}
|
||||
|
||||
async listMessageReactions(channelId: ChannelID, messageId: MessageID): Promise<Array<MessageReaction>> {
|
||||
return this.repository.messageInteractions.listMessageReactions(channelId, messageId);
|
||||
}
|
||||
|
||||
async listReactionUsers(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
limit?: number,
|
||||
after?: UserID,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<Array<MessageReaction>> {
|
||||
return this.repository.messageInteractions.listReactionUsers(
|
||||
channelId,
|
||||
messageId,
|
||||
emojiName,
|
||||
limit,
|
||||
after,
|
||||
emojiId,
|
||||
);
|
||||
}
|
||||
|
||||
async addReaction(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
emojiAnimated?: boolean,
|
||||
): Promise<MessageReaction> {
|
||||
return this.repository.messageInteractions.addReaction(
|
||||
channelId,
|
||||
messageId,
|
||||
userId,
|
||||
emojiName,
|
||||
emojiId,
|
||||
emojiAnimated,
|
||||
);
|
||||
}
|
||||
|
||||
async removeReaction(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<void> {
|
||||
return this.repository.messageInteractions.removeReaction(channelId, messageId, userId, emojiName, emojiId);
|
||||
}
|
||||
|
||||
async removeAllReactions(channelId: ChannelID, messageId: MessageID): Promise<void> {
|
||||
return this.repository.messageInteractions.removeAllReactions(channelId, messageId);
|
||||
}
|
||||
|
||||
async removeAllReactionsForEmoji(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<void> {
|
||||
return this.repository.messageInteractions.removeAllReactionsForEmoji(channelId, messageId, emojiName, emojiId);
|
||||
}
|
||||
|
||||
async countReactionUsers(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<number> {
|
||||
return this.repository.messageInteractions.countReactionUsers(channelId, messageId, emojiName, emojiId);
|
||||
}
|
||||
|
||||
async countUniqueReactions(channelId: ChannelID, messageId: MessageID): Promise<number> {
|
||||
return this.repository.messageInteractions.countUniqueReactions(channelId, messageId);
|
||||
}
|
||||
|
||||
async checkUserReactionExists(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
userId: UserID,
|
||||
emojiName: string,
|
||||
emojiId?: EmojiID,
|
||||
): Promise<boolean> {
|
||||
return this.repository.messageInteractions.checkUserReactionExists(
|
||||
channelId,
|
||||
messageId,
|
||||
userId,
|
||||
emojiName,
|
||||
emojiId,
|
||||
);
|
||||
}
|
||||
|
||||
async listGuildChannels(guildId: GuildID): Promise<Array<Channel>> {
|
||||
return this.repository.channelData.listGuildChannels(guildId);
|
||||
}
|
||||
|
||||
async countGuildChannels(guildId: GuildID): Promise<number> {
|
||||
return this.repository.channelData.countGuildChannels(guildId);
|
||||
}
|
||||
|
||||
async lookupAttachmentByChannelAndFilename(
|
||||
channelId: ChannelID,
|
||||
attachmentId: AttachmentID,
|
||||
filename: string,
|
||||
): Promise<MessageID | null> {
|
||||
return this.repository.messages.lookupAttachmentByChannelAndFilename(channelId, attachmentId, filename);
|
||||
}
|
||||
|
||||
async listChannelAttachments(channelId: ChannelID): Promise<Array<AttachmentLookupRow>> {
|
||||
return this.repository.messages.listChannelAttachments(channelId);
|
||||
}
|
||||
|
||||
async listMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
limit?: number,
|
||||
lastChannelId?: ChannelID,
|
||||
lastMessageId?: MessageID,
|
||||
): Promise<Array<{channelId: ChannelID; messageId: MessageID}>> {
|
||||
return this.repository.messages.listMessagesByAuthor(authorId, limit, lastChannelId, lastMessageId);
|
||||
}
|
||||
|
||||
async deleteMessagesByAuthor(
|
||||
authorId: UserID,
|
||||
channelIds?: Array<ChannelID>,
|
||||
messageIds?: Array<MessageID>,
|
||||
): Promise<void> {
|
||||
return this.repository.messages.deleteMessagesByAuthor(authorId, channelIds, messageIds);
|
||||
}
|
||||
|
||||
async anonymizeMessage(channelId: ChannelID, messageId: MessageID, newAuthorId: UserID): Promise<void> {
|
||||
return this.repository.messages.anonymizeMessage(channelId, messageId, newAuthorId);
|
||||
}
|
||||
|
||||
async deleteAllChannelMessages(channelId: ChannelID): Promise<void> {
|
||||
return this.repository.messages.deleteAllChannelMessages(channelId);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user