diff --git a/apps/api/src/cache/leaderboard.ts b/apps/api/src/cache/leaderboard.ts index 72ba2ef64..bf8cf7c5d 100644 --- a/apps/api/src/cache/leaderboard.ts +++ b/apps/api/src/cache/leaderboard.ts @@ -3,7 +3,7 @@ import type { DatabaseClient } from '@rctf/db' import { challenges, scoreEvents, users } from '@rctf/db' import { and, asc, eq, gte, inArray, sql } from 'drizzle-orm' import { inJsonbArray } from '../lib/db-bulk' -import { challengeIsPublicSql } from '../services/challenges' +import { challengeIsPublicSql } from '../services/challenge-queries' import { getCompetitionTiming } from '../services/settings' import type { TypedRedis } from './scripts' diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 47aa278ab..8224cb6f4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -9,11 +9,9 @@ import type { AppEnv } from './lib/app-env' import { runMigrationsOnStartup } from './lib/migrations' import { appEnvMiddleware } from './middlewares/app-env' import { dynamicChallengeAuthMiddleware } from './middlewares/dynamic-challenge-auth' -import { - adminBotProvider, - analyticsProvider, - uploadProvider, -} from './providers' +import { adminBotProvider } from './providers/instances/admin-bot' +import { analyticsProvider } from './providers/instances/analytics' +import { uploadProvider } from './providers/instances/uploads' import { routeModules } from './routes' import { analyticsScriptHandler } from './routes/v2/integrations/routes/get-analytics-script' import { startLeaderboardWorker, stopWorkers } from './workers' diff --git a/apps/api/src/providers/index.ts b/apps/api/src/providers/index.ts deleted file mode 100644 index e282558d3..000000000 --- a/apps/api/src/providers/index.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { config } from '@rctf/config' -import type { ProviderConfig } from '@rctf/config' -import { scoreProviders } from '@rctf/scoring' -import type { ScoreProvider } from '@rctf/scoring' -import { adminBotProviders } from './admin-bot' -import { analyticsProviders } from './analytics' -import { captchaProviders } from './captcha' -import { emailProviders } from './emails' -import { instancerProviders } from './instancer' -import type { InstancerProvider } from './instancer/base' -import { resolveInstancerConfigs } from './instancer/resolve' -import { messagesProviders } from './messages' -import { moderationProviders } from './moderation' -import { uploadProviders } from './uploads' - -const loadProvider = ( - providers: Record Base>, - providerConfig: ProviderConfig | undefined -): Base | undefined => { - if (!providerConfig) { - return undefined - } - - const provider = providers[providerConfig.name] - if (!provider) { - throw new Error( - `Unsupported provider: ${ - providerConfig.name - }. Available: ${Object.keys(providers).join(', ')}` - ) - } - - return provider(providerConfig.options) -} - -export const emailProvider = loadProvider( - emailProviders, - config.email?.provider -) - -export const uploadProvider = loadProvider( - uploadProviders, - config.uploadProvider -)! - -export const scoreProvider = loadProvider( - scoreProviders, - config.scoreProvider -)! - -const resolvedInstancers = resolveInstancerConfigs(config) -export const instancers: Record = Object.fromEntries( - Object.entries(resolvedInstancers.configs).map(([name, providerConfig]) => [ - name, - loadProvider(instancerProviders, providerConfig)!, - ]) -) -export const defaultInstancerName = resolvedInstancers.defaultName -export const instancerEnabled = Object.keys(instancers).length > 0 - -export const captchaProvider = (() => { - if (config.captcha?.provider) { - return loadProvider(captchaProviders, config.captcha.provider) - } - - // NOTE(es3n1n): backporting v1 google captcha config - if (config.recaptcha) { - const provider = { - name: 'captcha/recaptcha' as const, - options: { - secretKey: config.recaptcha.secretKey, - siteKey: config.recaptcha.siteKey, - }, - } - config.captcha = { - provider, - protectedEndpoints: config.recaptcha.protectedActions ?? [], - } - - return loadProvider(captchaProviders, provider) - } - - return undefined -})() - -export const bloodBotProviders = config.bloodBot?.destinations.map( - ({ provider }) => loadProvider(messagesProviders, provider)! -) - -export const avatarModerationProvider = loadProvider( - moderationProviders, - config.avatarsModeration?.provider -) - -export const adminBotProvider = loadProvider( - adminBotProviders, - config.adminBot?.provider -) -export const adminBotEnabled = adminBotProvider !== undefined - -export const analyticsProvider = (() => { - if (config.analytics?.provider) { - return loadProvider(analyticsProviders, config.analytics.provider) - } - - // NOTE(es3n1n): backporting v1 google analytics config - if (config.globalSiteTag) { - return loadProvider(analyticsProviders, { - name: 'analytics/google', - options: { siteTag: config.globalSiteTag }, - }) - } - - return undefined -})() diff --git a/apps/api/src/providers/instances/admin-bot.ts b/apps/api/src/providers/instances/admin-bot.ts new file mode 100644 index 000000000..fd0d1831d --- /dev/null +++ b/apps/api/src/providers/instances/admin-bot.ts @@ -0,0 +1,9 @@ +import { config } from '@rctf/config' +import { adminBotProviders } from '../admin-bot' +import { loadProvider } from './load' + +export const adminBotProvider = loadProvider( + adminBotProviders, + config.adminBot?.provider +) +export const adminBotEnabled = adminBotProvider !== undefined diff --git a/apps/api/src/providers/instances/all.ts b/apps/api/src/providers/instances/all.ts new file mode 100644 index 000000000..b57e58f73 --- /dev/null +++ b/apps/api/src/providers/instances/all.ts @@ -0,0 +1,9 @@ +import './admin-bot' +import './analytics' +import './captcha' +import './emails' +import './instancer' +import './messages' +import './moderation' +import './score' +import './uploads' diff --git a/apps/api/src/providers/instances/analytics.ts b/apps/api/src/providers/instances/analytics.ts new file mode 100644 index 000000000..df638d697 --- /dev/null +++ b/apps/api/src/providers/instances/analytics.ts @@ -0,0 +1,8 @@ +import { config } from '@rctf/config' +import { analyticsProviders } from '../analytics' +import { loadProvider } from './load' + +export const analyticsProvider = loadProvider( + analyticsProviders, + config.analytics?.provider +) diff --git a/apps/api/src/providers/instances/captcha.ts b/apps/api/src/providers/instances/captcha.ts new file mode 100644 index 000000000..87842b7f4 --- /dev/null +++ b/apps/api/src/providers/instances/captcha.ts @@ -0,0 +1,8 @@ +import { config } from '@rctf/config' +import { captchaProviders } from '../captcha' +import { loadProvider } from './load' + +export const captchaProvider = loadProvider( + captchaProviders, + config.captcha?.provider +) diff --git a/apps/api/src/providers/instances/emails.ts b/apps/api/src/providers/instances/emails.ts new file mode 100644 index 000000000..004a8fd9b --- /dev/null +++ b/apps/api/src/providers/instances/emails.ts @@ -0,0 +1,8 @@ +import { config } from '@rctf/config' +import { emailProviders } from '../emails' +import { loadProvider } from './load' + +export const emailProvider = loadProvider( + emailProviders, + config.email?.provider +) diff --git a/apps/api/src/providers/instances/instancer.ts b/apps/api/src/providers/instances/instancer.ts new file mode 100644 index 000000000..882f45ac5 --- /dev/null +++ b/apps/api/src/providers/instances/instancer.ts @@ -0,0 +1,15 @@ +import { config } from '@rctf/config' +import { instancerProviders } from '../instancer' +import type { InstancerProvider } from '../instancer/base' +import { resolveInstancerConfigs } from '../instancer/resolve' +import { loadProvider } from './load' + +const resolvedInstancers = resolveInstancerConfigs(config) +export const instancers: Record = Object.fromEntries( + Object.entries(resolvedInstancers.configs).map(([name, providerConfig]) => [ + name, + loadProvider(instancerProviders, providerConfig)!, + ]) +) +export const defaultInstancerName = resolvedInstancers.defaultName +export const instancerEnabled = Object.keys(instancers).length > 0 diff --git a/apps/api/src/providers/instances/load.ts b/apps/api/src/providers/instances/load.ts new file mode 100644 index 000000000..e42e929bd --- /dev/null +++ b/apps/api/src/providers/instances/load.ts @@ -0,0 +1,21 @@ +import type { ProviderConfig } from '@rctf/config' + +export const loadProvider = ( + providers: Record Base>, + providerConfig: ProviderConfig | undefined +): Base | undefined => { + if (!providerConfig) { + return undefined + } + + const provider = providers[providerConfig.name] + if (!provider) { + throw new Error( + `Unsupported provider: ${providerConfig.name}. Available: ${Object.keys( + providers + ).join(', ')}` + ) + } + + return provider(providerConfig.options) +} diff --git a/apps/api/src/providers/instances/messages.ts b/apps/api/src/providers/instances/messages.ts new file mode 100644 index 000000000..59309a7d4 --- /dev/null +++ b/apps/api/src/providers/instances/messages.ts @@ -0,0 +1,7 @@ +import { config } from '@rctf/config' +import { messagesProviders } from '../messages' +import { loadProvider } from './load' + +export const bloodBotProviders = config.bloodBot?.destinations.map( + ({ provider }) => loadProvider(messagesProviders, provider)! +) diff --git a/apps/api/src/providers/instances/moderation.ts b/apps/api/src/providers/instances/moderation.ts new file mode 100644 index 000000000..1cbd3f40b --- /dev/null +++ b/apps/api/src/providers/instances/moderation.ts @@ -0,0 +1,8 @@ +import { config } from '@rctf/config' +import { moderationProviders } from '../moderation' +import { loadProvider } from './load' + +export const avatarModerationProvider = loadProvider( + moderationProviders, + config.avatarsModeration?.provider +) diff --git a/apps/api/src/providers/instances/score.ts b/apps/api/src/providers/instances/score.ts new file mode 100644 index 000000000..72518fd1c --- /dev/null +++ b/apps/api/src/providers/instances/score.ts @@ -0,0 +1,8 @@ +import { config } from '@rctf/config' +import { scoreProviders, type ScoreProvider } from '@rctf/scoring' +import { loadProvider } from './load' + +export const scoreProvider = loadProvider( + scoreProviders, + config.scoreProvider +)! diff --git a/apps/api/src/providers/instances/uploads.ts b/apps/api/src/providers/instances/uploads.ts new file mode 100644 index 000000000..a59658f62 --- /dev/null +++ b/apps/api/src/providers/instances/uploads.ts @@ -0,0 +1,8 @@ +import { config } from '@rctf/config' +import { uploadProviders } from '../uploads' +import { loadProvider } from './load' + +export const uploadProvider = loadProvider( + uploadProviders, + config.uploadProvider +)! diff --git a/apps/api/src/routes/v1/admin/routes/query-uploads.ts b/apps/api/src/routes/v1/admin/routes/query-uploads.ts index 0d6c09f57..b37858735 100644 --- a/apps/api/src/routes/v1/admin/routes/query-uploads.ts +++ b/apps/api/src/routes/v1/admin/routes/query-uploads.ts @@ -1,5 +1,5 @@ import { QueryUploadsRoute } from '@rctf/types' -import { uploadProvider } from '../../../../providers' +import { uploadProvider } from '../../../../providers/instances/uploads' import adminGroup from '../group' adminGroup.route(QueryUploadsRoute, async ({ body, res }) => { diff --git a/apps/api/src/routes/v1/admin/routes/upload-files.ts b/apps/api/src/routes/v1/admin/routes/upload-files.ts index 0b6431c93..1d2a95127 100644 --- a/apps/api/src/routes/v1/admin/routes/upload-files.ts +++ b/apps/api/src/routes/v1/admin/routes/upload-files.ts @@ -1,6 +1,6 @@ import { UploadFilesRoute } from '@rctf/types' import { dataUriToBuffer } from 'data-uri-to-buffer' -import { uploadProvider } from '../../../../providers' +import { uploadProvider } from '../../../../providers/instances/uploads' import adminGroup from '../group' adminGroup.route(UploadFilesRoute, async ({ res, body }) => { diff --git a/apps/api/src/routes/v1/leaderboard/routes/get-leaderboard.ts b/apps/api/src/routes/v1/leaderboard/routes/get-leaderboard.ts index 6777943d5..0341da3f1 100644 --- a/apps/api/src/routes/v1/leaderboard/routes/get-leaderboard.ts +++ b/apps/api/src/routes/v1/leaderboard/routes/get-leaderboard.ts @@ -1,6 +1,6 @@ import { config } from '@rctf/config' import { GetLeaderboardRoute } from '@rctf/types' -import { getLeaderboardWithTotal } from '../../../../services/leaderboard' +import { getLeaderboardWithTotal } from '../../../../services/leaderboard-queries' import leaderboardGroup from '../group' leaderboardGroup.route( diff --git a/apps/api/src/routes/v2/admin/routes/get-admin-bot-status.ts b/apps/api/src/routes/v2/admin/routes/get-admin-bot-status.ts index f0de4ec01..3f9e5ee4b 100644 --- a/apps/api/src/routes/v2/admin/routes/get-admin-bot-status.ts +++ b/apps/api/src/routes/v2/admin/routes/get-admin-bot-status.ts @@ -1,5 +1,5 @@ import { GetAdminBotStatusRouteV2 } from '@rctf/types' -import { adminBotProvider } from '../../../../providers' +import { adminBotProvider } from '../../../../providers/instances/admin-bot' import adminGroup from '../group' adminGroup.route(GetAdminBotStatusRouteV2, async ({ res }) => { diff --git a/apps/api/src/routes/v2/admin/routes/get-instancer-schema.ts b/apps/api/src/routes/v2/admin/routes/get-instancer-schema.ts index d9e511f26..25df4cf92 100644 --- a/apps/api/src/routes/v2/admin/routes/get-instancer-schema.ts +++ b/apps/api/src/routes/v2/admin/routes/get-instancer-schema.ts @@ -4,7 +4,7 @@ import { defaultInstancerName, instancerEnabled, instancers, -} from '../../../../providers' +} from '../../../../providers/instances/instancer' import adminGroup from '../group' adminGroup.route(GetInstancerSchemaRouteV2, async ({ res }) => { diff --git a/apps/api/src/routes/v2/admin/routes/query-uploads.ts b/apps/api/src/routes/v2/admin/routes/query-uploads.ts index cc1f4a406..2eeb9a4b6 100644 --- a/apps/api/src/routes/v2/admin/routes/query-uploads.ts +++ b/apps/api/src/routes/v2/admin/routes/query-uploads.ts @@ -1,5 +1,5 @@ import { QueryUploadsRouteV2 } from '@rctf/types' -import { uploadProvider } from '../../../../providers' +import { uploadProvider } from '../../../../providers/instances/uploads' import adminGroup from '../group' adminGroup.route(QueryUploadsRouteV2, async ({ body, res }) => { diff --git a/apps/api/src/routes/v2/admin/routes/update-challenge.ts b/apps/api/src/routes/v2/admin/routes/update-challenge.ts index e47d81abd..1aa3859b8 100644 --- a/apps/api/src/routes/v2/admin/routes/update-challenge.ts +++ b/apps/api/src/routes/v2/admin/routes/update-challenge.ts @@ -1,6 +1,7 @@ import type { AdminBotConfig } from '@rctf/db' import { UpdateChallengeRouteV2 } from '@rctf/types' -import { adminBotProvider, instancerEnabled } from '../../../../providers' +import { adminBotProvider } from '../../../../providers/instances/admin-bot' +import { instancerEnabled } from '../../../../providers/instances/instancer' import { createDefaultFlag, getFlagProvider } from '../../../../providers/flags' import { ChallengeKindChangeBlockedError, diff --git a/apps/api/src/routes/v2/admin/routes/upload-files.ts b/apps/api/src/routes/v2/admin/routes/upload-files.ts index 1c2f0505c..71d6fbcff 100644 --- a/apps/api/src/routes/v2/admin/routes/upload-files.ts +++ b/apps/api/src/routes/v2/admin/routes/upload-files.ts @@ -1,5 +1,5 @@ import { UploadFilesRouteV2 } from '@rctf/types' -import { uploadProvider } from '../../../../providers' +import { uploadProvider } from '../../../../providers/instances/uploads' import adminGroup from '../group' adminGroup.route(UploadFilesRouteV2, async ({ res, body }) => { diff --git a/apps/api/src/routes/v2/challs/routes/get-challenges.ts b/apps/api/src/routes/v2/challs/routes/get-challenges.ts index 148bfdfd2..2fd3f5e46 100644 --- a/apps/api/src/routes/v2/challs/routes/get-challenges.ts +++ b/apps/api/src/routes/v2/challs/routes/get-challenges.ts @@ -1,5 +1,6 @@ import { ChallengeScoringKind, GetChallengesRouteV2 } from '@rctf/types' -import { adminBotEnabled, instancerEnabled } from '../../../../providers' +import { adminBotEnabled } from '../../../../providers/instances/admin-bot' +import { instancerEnabled } from '../../../../providers/instances/instancer' import { getChallenges } from '../../../../services/challenges' import { resolveInstancerActions, diff --git a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-config.ts b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-config.ts index 27b4a041d..d6a570f3f 100644 --- a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-config.ts +++ b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-config.ts @@ -1,5 +1,5 @@ import { GetAdminBotConfigRouteV2 } from '@rctf/types' -import { adminBotProvider } from '../../../../providers' +import { adminBotProvider } from '../../../../providers/instances/admin-bot' import { getChallenge } from '../../../../services/challenges' import integrationsGroup from '../group' diff --git a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-history.ts b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-history.ts index a6451ffe3..7503769a1 100644 --- a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-history.ts +++ b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-history.ts @@ -1,5 +1,5 @@ import { AdminBotJobStatus, GetAdminBotJobHistoryRouteV2 } from '@rctf/types' -import { adminBotProvider } from '../../../../providers' +import { adminBotProvider } from '../../../../providers/instances/admin-bot' import { getJobHistory } from '../../../../services/admin-bot-jobs' import { getChallenge } from '../../../../services/challenges' import integrationsGroup from '../group' diff --git a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-logs.ts b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-logs.ts index f5663594d..73c3444b3 100644 --- a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-logs.ts +++ b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-logs.ts @@ -1,5 +1,5 @@ import { GetAdminBotJobLogsRouteV2 } from '@rctf/types' -import { adminBotProvider } from '../../../../providers' +import { adminBotProvider } from '../../../../providers/instances/admin-bot' import { getJobLogs } from '../../../../services/admin-bot-jobs' import { getChallenge } from '../../../../services/challenges' import integrationsGroup from '../group' diff --git a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-status.ts b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-status.ts index 7f25a2632..6a63ace3d 100644 --- a/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-status.ts +++ b/apps/api/src/routes/v2/integrations/routes/get-admin-bot-job-status.ts @@ -1,5 +1,5 @@ import { AdminBotJobStatus, GetAdminBotJobStatusRouteV2 } from '@rctf/types' -import { adminBotProvider } from '../../../../providers' +import { adminBotProvider } from '../../../../providers/instances/admin-bot' import { getLatestJob } from '../../../../services/admin-bot-jobs' import { getChallenge } from '../../../../services/challenges' import integrationsGroup from '../group' diff --git a/apps/api/src/routes/v2/integrations/routes/get-analytics-script.ts b/apps/api/src/routes/v2/integrations/routes/get-analytics-script.ts index fdd341cf7..a7de75694 100644 --- a/apps/api/src/routes/v2/integrations/routes/get-analytics-script.ts +++ b/apps/api/src/routes/v2/integrations/routes/get-analytics-script.ts @@ -1,6 +1,6 @@ import type { Context } from 'hono' import type { AppEnv } from '../../../../lib/app-env' -import { analyticsProvider } from '../../../../providers' +import { analyticsProvider } from '../../../../providers/instances/analytics' let cachedScript: { content: string; fetchedAt: number } | null = null const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour diff --git a/apps/api/src/routes/v2/integrations/routes/get-client-config.ts b/apps/api/src/routes/v2/integrations/routes/get-client-config.ts index c9e334b5a..2b5a02601 100644 --- a/apps/api/src/routes/v2/integrations/routes/get-client-config.ts +++ b/apps/api/src/routes/v2/integrations/routes/get-client-config.ts @@ -1,6 +1,7 @@ import { config } from '@rctf/config' import { GetClientConfigRouteV2, ProtectedAction } from '@rctf/types' -import { captchaProvider, instancerEnabled } from '../../../../providers' +import { captchaProvider } from '../../../../providers/instances/captcha' +import { instancerEnabled } from '../../../../providers/instances/instancer' import { getResolvedSettings } from '../../../../services/settings' import integrationsGroup from '../group' @@ -15,13 +16,6 @@ const getAnalyticsConfig = () => { } } - if (config.globalSiteTag) { - return { - provider: 'analytics/google', - publicOptions: { siteTag: config.globalSiteTag }, - } - } - return null } diff --git a/apps/api/src/routes/v2/integrations/routes/submit-admin-bot-job.ts b/apps/api/src/routes/v2/integrations/routes/submit-admin-bot-job.ts index 9c46c849c..d9d3ffd0f 100644 --- a/apps/api/src/routes/v2/integrations/routes/submit-admin-bot-job.ts +++ b/apps/api/src/routes/v2/integrations/routes/submit-admin-bot-job.ts @@ -5,7 +5,7 @@ import { SubmissionResult, SubmitAdminBotJobRouteV2, } from '@rctf/types' -import { adminBotProvider } from '../../../../providers' +import { adminBotProvider } from '../../../../providers/instances/admin-bot' import { getFlagsForTeam } from '../../../../providers/flags' import { createJob, hasActiveJob } from '../../../../services/admin-bot-jobs' import { getChallenge } from '../../../../services/challenges' diff --git a/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-challenges.ts b/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-challenges.ts index e4790f6d9..c512b923d 100644 --- a/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-challenges.ts +++ b/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-challenges.ts @@ -5,7 +5,7 @@ import { preparedPerDb } from '../../../../lib/prepared' import { challengeIsPublicSql, scoringKindOf, -} from '../../../../services/challenges' +} from '../../../../services/challenge-queries' import leaderboardGroup from '../group' const preparedLeaderboardChallenges = preparedPerDb(db => diff --git a/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-with-graph.ts b/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-with-graph.ts index c86ecdc3f..1e80237fd 100644 --- a/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-with-graph.ts +++ b/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard-with-graph.ts @@ -1,7 +1,7 @@ import { config } from '@rctf/config' import { GetLeaderboardWithGraphRoute } from '@rctf/types' import { getGraphForEntries } from '../../../../cache/leaderboard' -import { getLeaderboardWithFilters } from '../../../../services/leaderboard' +import { getLeaderboardWithFilters } from '../../../../services/leaderboard-queries' import { rateLimitSearch } from '../../../../services/rate-limit' import leaderboardGroup from '../group' diff --git a/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard.ts b/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard.ts index f5b602c1f..b0d52ac28 100644 --- a/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard.ts +++ b/apps/api/src/routes/v2/leaderboard/routes/get-leaderboard.ts @@ -3,7 +3,7 @@ import { GetLeaderboardRouteV2 } from '@rctf/types' import { getLeaderboardWithTotal, searchLeaderboard, -} from '../../../../services/leaderboard' +} from '../../../../services/leaderboard-queries' import { rateLimitSearch } from '../../../../services/rate-limit' import leaderboardGroup from '../group' diff --git a/apps/api/src/services/avatar.ts b/apps/api/src/services/avatar.ts index c5651c526..2a4bc59f3 100644 --- a/apps/api/src/services/avatar.ts +++ b/apps/api/src/services/avatar.ts @@ -10,7 +10,8 @@ import type { import type { PinoLogger } from 'hono-pino' import sharp from 'sharp' import type { TypedRedis } from '../cache/scripts' -import { avatarModerationProvider, uploadProvider } from '../providers' +import { avatarModerationProvider } from '../providers/instances/moderation' +import { uploadProvider } from '../providers/instances/uploads' import { updateUserAvatar } from './users' export type SetUserAvatarError = diff --git a/apps/api/src/services/bloodbot.ts b/apps/api/src/services/bloodbot.ts index 2de57d574..082782a63 100644 --- a/apps/api/src/services/bloodbot.ts +++ b/apps/api/src/services/bloodbot.ts @@ -1,7 +1,7 @@ import { config } from '@rctf/config' import type { ChallengeData, User } from '@rctf/db' import mustache from 'mustache' -import { bloodBotProviders } from '../providers' +import { bloodBotProviders } from '../providers/instances/messages' import { buildBloodMessageView } from './bloodbot-view' export const shouldNotifyBloodbot = (bloodNumber: number) => { diff --git a/apps/api/src/services/challenge-queries.ts b/apps/api/src/services/challenge-queries.ts new file mode 100644 index 000000000..dfd1a0131 --- /dev/null +++ b/apps/api/src/services/challenge-queries.ts @@ -0,0 +1,19 @@ +import { challenges, users } from '@rctf/db' +import { ChallengeScoringKind } from '@rctf/types' +import { and, eq, sql } from 'drizzle-orm' +import type { PgColumn } from 'drizzle-orm/pg-core' + +export const userIsNotBanned = eq(users.banned, false) +export const nonBannedUserJoin = (ownerId: PgColumn) => + and(eq(users.id, ownerId), userIsNotBanned) + +export const challengeIsPublicSql = and( + sql`COALESCE((${challenges.data} ->> 'hidden')::boolean, false) = false`, + sql`COALESCE((${challenges.data} ->> 'releaseTime')::bigint, 0) <= ${sql.raw('extract(epoch from now())::bigint * 1000')}` +)! + +export const scoringKindOf = (data: { + scoring?: { kind: ChallengeScoringKind } | null +}): ChallengeScoringKind => data.scoring?.kind ?? ChallengeScoringKind.DECAY + +export const isDecayKind = sql`COALESCE(${challenges.data} -> 'scoring' ->> 'kind', ${ChallengeScoringKind.DECAY}) = ${ChallengeScoringKind.DECAY}` diff --git a/apps/api/src/services/challenges.ts b/apps/api/src/services/challenges.ts index 5eba9a94c..6febe5bd6 100644 --- a/apps/api/src/services/challenges.ts +++ b/apps/api/src/services/challenges.ts @@ -32,7 +32,6 @@ import { SubmissionResult, } from '@rctf/types' import { and, asc, count, desc, eq, inArray, max, sql, sum } from 'drizzle-orm' -import type { PgColumn } from 'drizzle-orm/pg-core' import type { PinoLogger } from 'hono-pino' import type { TypedRedis } from '../cache/scripts' import { inJsonbArrayPlaceholder } from '../lib/db-bulk' @@ -40,6 +39,13 @@ import { preparedPerDb } from '../lib/prepared' import { type MatchedFlagEntry, verifyFlagEntries } from '../providers/flags' import { forceLeaderboardUpdate, requestChallengeRecompute } from '../workers' import { sendBloodMessage, shouldNotifyBloodbot } from './bloodbot' +import { + challengeIsPublicSql, + isDecayKind, + nonBannedUserJoin, + scoringKindOf, + userIsNotBanned, +} from './challenge-queries' import { rateLimitFlag } from './rate-limit' import { getCompetitionTiming } from './settings' import { createSubmission } from './submissions' @@ -111,10 +117,6 @@ type ChallengeSolvesWithPosition = { solvePosition: number | null } -export const userIsNotBanned = eq(users.banned, false) -export const nonBannedUserJoin = (ownerId: PgColumn) => - and(eq(users.id, ownerId), userIsNotBanned) - const createRankedSolvesForChallenges = ( db: DatabaseClient, challengeIds: string[] @@ -180,11 +182,6 @@ export const isChallengePublic = (challenge: Challenge): boolean => { return +new Date() >= (challenge.data.releaseTime ?? 0) } -export const challengeIsPublicSql = and( - sql`COALESCE((${challenges.data} ->> 'hidden')::boolean, false) = false`, - sql`COALESCE((${challenges.data} ->> 'releaseTime')::bigint, 0) <= ${sql.raw('extract(epoch from now())::bigint * 1000')}` -)! - const challengeDefaultOrder = [ sql`((${challenges.data} ->> 'sortWeight')::int) NULLS LAST`, desc(challenges.id), @@ -268,12 +265,7 @@ export const lockChallenge = (tx: DatabaseTx, challengeId: string) => sql`SELECT pg_advisory_xact_lock(hashtextextended(${challengeId}, 0))` ) -export const scoringKindOf = (data: { - scoring?: { kind: ChallengeScoringKind } | null -}): ChallengeScoringKind => data.scoring?.kind ?? ChallengeScoringKind.DECAY - export type DecayChallenge = Pick -export const isDecayKind = sql`COALESCE(${challenges.data} -> 'scoring' ->> 'kind', ${ChallengeScoringKind.DECAY}) = ${ChallengeScoringKind.DECAY}` export const getDecayChallenge = ( tx: DatabaseTx, diff --git a/apps/api/src/services/emails.ts b/apps/api/src/services/emails.ts index 28bd79193..4d528603c 100644 --- a/apps/api/src/services/emails.ts +++ b/apps/api/src/services/emails.ts @@ -3,7 +3,7 @@ import { config } from '@rctf/config' import type { DatabaseClient } from '@rctf/db' import mustache from 'mustache' import type { TypedRedis } from '../cache/scripts' -import { emailProvider } from '../providers' +import { emailProvider } from '../providers/instances/emails' import { getResolvedSettings } from './settings' export type EmailKind = 'register' | 'recover' | 'update' diff --git a/apps/api/src/services/instancer.ts b/apps/api/src/services/instancer.ts index e462870bd..b12071994 100644 --- a/apps/api/src/services/instancer.ts +++ b/apps/api/src/services/instancer.ts @@ -12,7 +12,7 @@ import { defaultInstancerName, instancerEnabled, instancers, -} from '../providers' +} from '../providers/instances/instancer' import { getFlagsForTeam } from '../providers/flags' import { type CreateInstanceOptions, diff --git a/apps/api/src/services/leaderboard.ts b/apps/api/src/services/leaderboard-calculation.ts similarity index 77% rename from apps/api/src/services/leaderboard.ts rename to apps/api/src/services/leaderboard-calculation.ts index b0f1f299a..3744ae819 100644 --- a/apps/api/src/services/leaderboard.ts +++ b/apps/api/src/services/leaderboard-calculation.ts @@ -4,28 +4,22 @@ import { challenges, solves, users } from '@rctf/db' import { takeUnique } from '@rctf/db/util' import type { ScoreContext } from '@rctf/scoring/base' import { ChallengeScoringKind } from '@rctf/types' -import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm' -import { - leaderboardOrderSql, - userIsPublicRankedSql, - type CalculatedLeaderboard, - type InternalChallengeInfo, - type InternalUserInfo, - type Sample, +import { and, asc, inArray, sql } from 'drizzle-orm' +import type { + CalculatedLeaderboard, + InternalChallengeInfo, + InternalUserInfo, + Sample, } from '../cache/leaderboard' import type { TypedRedis } from '../cache/scripts' import { cursorAfter, type RowCursor } from '../lib/db-filters' -import { preparedPerDb } from '../lib/prepared' -import { scoreProvider } from '../providers' +import { scoreProvider } from '../providers/instances/score' import { challengeIsPublicSql, - getChallenge, - getLeaderboardChallengeData, nonBannedUserJoin, scoringKindOf, -} from './challenges' +} from './challenge-queries' import { getCompetitionTiming, type CompetitionTiming } from './settings' -import { userNameSearchFilter } from './users' const numberOfBloods = 3 @@ -788,234 +782,3 @@ const refreshDynamicContribs = async ( } return { changed, needsRebuild: false } } - -type LeaderboardEntryRow = Pick< - typeof users.$inferSelect, - 'id' | 'name' | 'division' | 'score' | 'globalRank' | 'divisionRank' -> - -const leaderboardEntrySelection = { - id: users.id, - name: users.name, - score: users.score, - division: users.division, - divisionRank: users.divisionRank, - globalRank: users.globalRank, -} - -const hydrateLeaderboardEntries = async ( - db: DatabaseClient, - entries: LeaderboardEntryRow[] -) => { - if (entries.length === 0) { - return [] - } - - const { - solves: userSolves, - dynamicScores, - userInfo, - } = await getLeaderboardChallengeData( - db, - entries.map(entry => entry.id) - ) - - return entries.map(entry => { - const info = userInfo.get(entry.id) - return { - id: entry.id, - name: entry.name, - division: entry.division, - score: entry.score, - divisionPlace: entry.divisionRank ?? 0, - globalPlace: entry.globalRank ?? null, - avatarUrl: info?.avatarUrl ?? null, - countryCode: info?.countryCode ?? null, - statusText: info?.statusText ?? null, - solves: Array.from(userSolves.get(entry.id) ?? []).map(solve => ({ - id: solve.challengeId, - solveTime: solve.solveTime, - })), - dynamicScores: dynamicScores.get(entry.id) ?? [], - } - }) -} - -export const searchLeaderboard = async ( - db: DatabaseClient, - search: string, - limit: number, - offset: number, - division?: string -) => { - const searchFilter = userNameSearchFilter(search) - - const whereClause = and( - searchFilter, - userIsPublicRankedSql, - division ? eq(users.division, division) : undefined - ) - - const [totalRow, matchingUsers] = await Promise.all([ - db - .select({ count: sql`count(*)::int` }) - .from(users) - .where(whereClause) - .then(takeUnique), - db - .select(leaderboardEntrySelection) - .from(users) - .where(whereClause) - .orderBy( - sql`similarity(${users.name}, ${search}) DESC`, - asc(users.createdAt) - ) - .limit(limit) - .offset(offset), - ]) - - return { - total: totalRow?.count ?? 0, - leaderboard: await hydrateLeaderboardEntries(db, matchingUsers), - } -} - -const divisionWhereClause = and( - userIsPublicRankedSql, - eq(users.division, sql.placeholder('division')) -) - -const preparedLeaderboardQueries = preparedPerDb(db => ({ - count: db - .select({ count: sql`count(*)::int` }) - .from(users) - .where(userIsPublicRankedSql) - .prepare('rctf_leaderboard_count'), - divisionCount: db - .select({ count: sql`count(*)::int` }) - .from(users) - .where(divisionWhereClause) - .prepare('rctf_leaderboard_count_division'), - page: db - .select(leaderboardEntrySelection) - .from(users) - .where(userIsPublicRankedSql) - .orderBy(leaderboardOrderSql) - .limit(sql.placeholder('limit')) - .offset(sql.placeholder('offset')) - .prepare('rctf_leaderboard_page'), - divisionPage: db - .select(leaderboardEntrySelection) - .from(users) - .where(divisionWhereClause) - .orderBy(leaderboardOrderSql) - .limit(sql.placeholder('limit')) - .offset(sql.placeholder('offset')) - .prepare('rctf_leaderboard_page_division'), -})) - -export const getLeaderboardWithTotal = async ( - db: DatabaseClient, - limit: number, - offset: number, - division?: string -) => { - const prepared = preparedLeaderboardQueries(db) - - const [totalRow, leaderboard] = await Promise.all([ - (division - ? prepared.divisionCount.execute({ division }) - : prepared.count.execute() - ).then(takeUnique), - limit > 0 - ? division - ? prepared.divisionPage.execute({ division, limit, offset }) - : prepared.page.execute({ limit, offset }) - : Promise.resolve([]), - ]) - - return { - total: totalRow?.count ?? 0, - leaderboard: await hydrateLeaderboardEntries(db, leaderboard), - } -} - -export const getChallengeLeaderboardWithTotal = async ( - db: DatabaseClient, - challengeId: string, - limit: number, - offset: number, - division?: string, - search?: string -) => { - const challenge = await getChallenge(db, challengeId) - if (!challenge) { - return { total: 0, leaderboard: [] } - } - - const whereClause = and( - userIsPublicRankedSql, - division ? eq(users.division, division) : undefined, - search ? userNameSearchFilter(search) : undefined - ) - const solveJoin = and( - eq(solves.userid, users.id), - eq(solves.challengeid, challengeId) - ) - const orderBy = - scoringKindOf(challenge.data) === ChallengeScoringKind.DYNAMIC - ? [desc(solves.points), asc(solves.pointsUpdatedAt), asc(solves.userid)] - : [asc(solves.createdat), asc(solves.userid)] - - const [totalRow, leaderboard] = await Promise.all([ - db - .select({ count: sql`count(*)::int` }) - .from(users) - .innerJoin(solves, solveJoin) - .where(whereClause) - .then(takeUnique), - db - .select(leaderboardEntrySelection) - .from(users) - .innerJoin(solves, solveJoin) - .where(whereClause) - .orderBy(...orderBy) - .limit(limit) - .offset(offset), - ]) - - return { - total: totalRow?.count ?? 0, - leaderboard: await hydrateLeaderboardEntries(db, leaderboard), - } -} - -type LeaderboardFilters = { - limit: number - offset: number - division?: string - search?: string - challenge?: string -} - -export const getLeaderboardWithFilters = ( - db: DatabaseClient, - { limit, offset, division, search, challenge }: LeaderboardFilters -) => { - if (challenge) { - return getChallengeLeaderboardWithTotal( - db, - challenge, - limit, - offset, - division, - search - ) - } - - if (search) { - return searchLeaderboard(db, search, limit, offset, division) - } - - return getLeaderboardWithTotal(db, limit, offset, division) -} diff --git a/apps/api/src/services/leaderboard-queries.ts b/apps/api/src/services/leaderboard-queries.ts new file mode 100644 index 000000000..33bec40d6 --- /dev/null +++ b/apps/api/src/services/leaderboard-queries.ts @@ -0,0 +1,243 @@ +import { solves, users, type DatabaseClient } from '@rctf/db' +import { takeUnique } from '@rctf/db/util' +import { ChallengeScoringKind } from '@rctf/types' +import { and, asc, desc, eq, sql } from 'drizzle-orm' +import { + leaderboardOrderSql, + userIsPublicRankedSql, +} from '../cache/leaderboard' +import { preparedPerDb } from '../lib/prepared' +import { scoringKindOf } from './challenge-queries' +import { getChallenge, getLeaderboardChallengeData } from './challenges' +import { userNameSearchFilter } from './users' + +type LeaderboardEntryRow = Pick< + typeof users.$inferSelect, + 'id' | 'name' | 'division' | 'score' | 'globalRank' | 'divisionRank' +> + +const leaderboardEntrySelection = { + id: users.id, + name: users.name, + score: users.score, + division: users.division, + divisionRank: users.divisionRank, + globalRank: users.globalRank, +} + +const hydrateLeaderboardEntries = async ( + db: DatabaseClient, + entries: LeaderboardEntryRow[] +) => { + if (entries.length === 0) { + return [] + } + + const { + solves: userSolves, + dynamicScores, + userInfo, + } = await getLeaderboardChallengeData( + db, + entries.map(entry => entry.id) + ) + + return entries.map(entry => { + const info = userInfo.get(entry.id) + return { + id: entry.id, + name: entry.name, + division: entry.division, + score: entry.score, + divisionPlace: entry.divisionRank ?? 0, + globalPlace: entry.globalRank ?? null, + avatarUrl: info?.avatarUrl ?? null, + countryCode: info?.countryCode ?? null, + statusText: info?.statusText ?? null, + solves: Array.from(userSolves.get(entry.id) ?? []).map(solve => ({ + id: solve.challengeId, + solveTime: solve.solveTime, + })), + dynamicScores: dynamicScores.get(entry.id) ?? [], + } + }) +} + +export const searchLeaderboard = async ( + db: DatabaseClient, + search: string, + limit: number, + offset: number, + division?: string +) => { + const searchFilter = userNameSearchFilter(search) + + const whereClause = and( + searchFilter, + userIsPublicRankedSql, + division ? eq(users.division, division) : undefined + ) + + const [totalRow, matchingUsers] = await Promise.all([ + db + .select({ count: sql`count(*)::int` }) + .from(users) + .where(whereClause) + .then(takeUnique), + db + .select(leaderboardEntrySelection) + .from(users) + .where(whereClause) + .orderBy( + sql`similarity(${users.name}, ${search}) DESC`, + asc(users.createdAt) + ) + .limit(limit) + .offset(offset), + ]) + + return { + total: totalRow?.count ?? 0, + leaderboard: await hydrateLeaderboardEntries(db, matchingUsers), + } +} + +const divisionWhereClause = and( + userIsPublicRankedSql, + eq(users.division, sql.placeholder('division')) +) + +const preparedLeaderboardQueries = preparedPerDb(db => ({ + count: db + .select({ count: sql`count(*)::int` }) + .from(users) + .where(userIsPublicRankedSql) + .prepare('rctf_leaderboard_count'), + divisionCount: db + .select({ count: sql`count(*)::int` }) + .from(users) + .where(divisionWhereClause) + .prepare('rctf_leaderboard_count_division'), + page: db + .select(leaderboardEntrySelection) + .from(users) + .where(userIsPublicRankedSql) + .orderBy(leaderboardOrderSql) + .limit(sql.placeholder('limit')) + .offset(sql.placeholder('offset')) + .prepare('rctf_leaderboard_page'), + divisionPage: db + .select(leaderboardEntrySelection) + .from(users) + .where(divisionWhereClause) + .orderBy(leaderboardOrderSql) + .limit(sql.placeholder('limit')) + .offset(sql.placeholder('offset')) + .prepare('rctf_leaderboard_page_division'), +})) + +export const getLeaderboardWithTotal = async ( + db: DatabaseClient, + limit: number, + offset: number, + division?: string +) => { + const prepared = preparedLeaderboardQueries(db) + + const [totalRow, leaderboard] = await Promise.all([ + (division + ? prepared.divisionCount.execute({ division }) + : prepared.count.execute() + ).then(takeUnique), + limit > 0 + ? division + ? prepared.divisionPage.execute({ division, limit, offset }) + : prepared.page.execute({ limit, offset }) + : Promise.resolve([]), + ]) + + return { + total: totalRow?.count ?? 0, + leaderboard: await hydrateLeaderboardEntries(db, leaderboard), + } +} + +export const getChallengeLeaderboardWithTotal = async ( + db: DatabaseClient, + challengeId: string, + limit: number, + offset: number, + division?: string, + search?: string +) => { + const challenge = await getChallenge(db, challengeId) + if (!challenge) { + return { total: 0, leaderboard: [] } + } + + const whereClause = and( + userIsPublicRankedSql, + division ? eq(users.division, division) : undefined, + search ? userNameSearchFilter(search) : undefined + ) + const solveJoin = and( + eq(solves.userid, users.id), + eq(solves.challengeid, challengeId) + ) + const orderBy = + scoringKindOf(challenge.data) === ChallengeScoringKind.DYNAMIC + ? [desc(solves.points), asc(solves.pointsUpdatedAt), asc(solves.userid)] + : [asc(solves.createdat), asc(solves.userid)] + + const [totalRow, leaderboard] = await Promise.all([ + db + .select({ count: sql`count(*)::int` }) + .from(users) + .innerJoin(solves, solveJoin) + .where(whereClause) + .then(takeUnique), + db + .select(leaderboardEntrySelection) + .from(users) + .innerJoin(solves, solveJoin) + .where(whereClause) + .orderBy(...orderBy) + .limit(limit) + .offset(offset), + ]) + + return { + total: totalRow?.count ?? 0, + leaderboard: await hydrateLeaderboardEntries(db, leaderboard), + } +} + +type LeaderboardFilters = { + limit: number + offset: number + division?: string + search?: string + challenge?: string +} + +export const getLeaderboardWithFilters = ( + db: DatabaseClient, + { limit, offset, division, search, challenge }: LeaderboardFilters +) => { + if (challenge) { + return getChallengeLeaderboardWithTotal( + db, + challenge, + limit, + offset, + division, + search + ) + } + + if (search) { + return searchLeaderboard(db, search, limit, offset, division) + } + + return getLeaderboardWithTotal(db, limit, offset, division) +} diff --git a/apps/api/src/services/solve-points.ts b/apps/api/src/services/solve-points.ts index ffb4ae8f1..b3b9dcd81 100644 --- a/apps/api/src/services/solve-points.ts +++ b/apps/api/src/services/solve-points.ts @@ -11,15 +11,15 @@ import { and, asc, eq, sql } from 'drizzle-orm' import type { PinoLogger } from 'hono-pino' import type { TypedRedis } from '../cache/scripts' import { inJsonbArray, insertInChunks } from '../lib/db-bulk' -import { scoreProvider } from '../providers' +import { scoreProvider } from '../providers/instances/score' import { requestChallengeRecompute } from '../workers' +import { userIsNotBanned } from './challenge-queries' import { getDecayChallenge, getDecayChallenges, getMaxSolveCount, getPrivateChallenge, lockChallenge, - userIsNotBanned, type DecayChallenge, } from './challenges' import { getCompetitionTiming, type CompetitionTiming } from './settings' diff --git a/apps/api/src/services/users.ts b/apps/api/src/services/users.ts index b957eb4ac..35bd55d92 100644 --- a/apps/api/src/services/users.ts +++ b/apps/api/src/services/users.ts @@ -44,7 +44,7 @@ import { setFilter } from '../lib/db-filters' import { preparedPerDb } from '../lib/prepared' import { createToken, TokenKind } from '../lib/tokens' import { forceLeaderboardUpdate, requestChallengeRecompute } from '../workers' -import { isDecayKind } from './challenges' +import { isDecayKind } from './challenge-queries' import { getCompetitionTiming } from './settings' import { emitBanScoreEvents, diff --git a/apps/api/src/util/captcha.ts b/apps/api/src/util/captcha.ts index 675971300..0f5a5bcf5 100644 --- a/apps/api/src/util/captcha.ts +++ b/apps/api/src/util/captcha.ts @@ -1,6 +1,6 @@ import { config } from '@rctf/config' import type { ProtectedAction } from '@rctf/types' -import { captchaProvider } from '../providers' +import { captchaProvider } from '../providers/instances/captcha' export const isActionProtected = (action: ProtectedAction): boolean => { return config.captcha?.protectedEndpoints?.includes(action) ?? false diff --git a/apps/api/src/workers/leaderboard-runner.ts b/apps/api/src/workers/leaderboard-runner.ts index 733020783..3f53340c5 100644 --- a/apps/api/src/workers/leaderboard-runner.ts +++ b/apps/api/src/workers/leaderboard-runner.ts @@ -1,7 +1,7 @@ import type { DatabaseClient } from '@rctf/db' import type { CalculatedLeaderboard } from '../cache/leaderboard' import type { TypedRedis } from '../cache/scripts' -import type { CachedLeaderboardComputation } from '../services/leaderboard' +import type { CachedLeaderboardComputation } from '../services/leaderboard-calculation' type Calculator = (db: DatabaseClient) => Promise diff --git a/apps/api/src/workers/leaderboard.ts b/apps/api/src/workers/leaderboard.ts index 1bc2220c3..f38e16a4c 100644 --- a/apps/api/src/workers/leaderboard.ts +++ b/apps/api/src/workers/leaderboard.ts @@ -3,7 +3,7 @@ import { ADVISORY_LOCK_KEYS, createDatabase } from '@rctf/db' import { pino } from 'pino' import { cacheLeaderboardAndGraph } from '../cache/leaderboard' import { getMaxSolveCount } from '../services/challenges' -import { createCachedLeaderboardCalculator } from '../services/leaderboard' +import { createCachedLeaderboardCalculator } from '../services/leaderboard-calculation' import { applyDecayPointsForAllChallenges, applyDecayPointsForChallenge, diff --git a/apps/cli/src/commands/deployment/generate-csp.ts b/apps/cli/src/commands/deployment/generate-csp.ts index 17b1b744e..0088b9a44 100644 --- a/apps/cli/src/commands/deployment/generate-csp.ts +++ b/apps/cli/src/commands/deployment/generate-csp.ts @@ -1,4 +1,4 @@ -import '@rctf/api/src/providers' +import '@rctf/api/src/providers/instances/all' import { registeredProviders, type Csp } from '@rctf/api/src/providers/base' import { config } from '@rctf/config' import { join } from 'path' diff --git a/apps/cli/src/commands/seed/data.ts b/apps/cli/src/commands/seed/data.ts index 35b7c6cbb..f9994d30f 100644 --- a/apps/cli/src/commands/seed/data.ts +++ b/apps/cli/src/commands/seed/data.ts @@ -1,4 +1,4 @@ -import { scoreProvider } from '@rctf/api/src/providers' +import { scoreProvider } from '@rctf/api/src/providers/instances/score' import { DynamicFlagMode, mintDynamicFlag, diff --git a/apps/cli/src/commands/seed/run.ts b/apps/cli/src/commands/seed/run.ts index 28d7cfaf8..4b872fe41 100644 --- a/apps/cli/src/commands/seed/run.ts +++ b/apps/cli/src/commands/seed/run.ts @@ -1,7 +1,7 @@ import { cacheLeaderboardAndGraph } from '@rctf/api/src/cache/leaderboard' import { insertInChunks } from '@rctf/api/src/lib/db-bulk' import { createToken, TokenKind } from '@rctf/api/src/lib/tokens' -import { calculateLeaderboard } from '@rctf/api/src/services/leaderboard' +import { calculateLeaderboard } from '@rctf/api/src/services/leaderboard-calculation' import { config } from '@rctf/config' import { challenges, diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index fb5d17d3a..cee884e63 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,9 +1,14 @@ import deepMerge from 'deepmerge' import { loadEnvConfig, loadFileConfigs } from './loader' +import { normalizeConfig } from './normalize' import { ServerConfigSchema } from './types' export * from './env' +export * from './normalize' export * from './types' -export const config = ServerConfigSchema.parse( - deepMerge.all([...loadFileConfigs(), loadEnvConfig()]) + +export const config = normalizeConfig( + ServerConfigSchema.parse( + deepMerge.all([...loadFileConfigs(), loadEnvConfig()]) + ) ) diff --git a/packages/config/src/normalize.ts b/packages/config/src/normalize.ts new file mode 100644 index 000000000..403ed3877 --- /dev/null +++ b/packages/config/src/normalize.ts @@ -0,0 +1,28 @@ +import type { ServerConfig } from './types' + +// backporting v1 configs +export const normalizeConfig = (config: ServerConfig): ServerConfig => { + if (!config.captcha?.provider && config.recaptcha) { + config.captcha = { + provider: { + name: 'captcha/recaptcha', + options: { + secretKey: config.recaptcha.secretKey, + siteKey: config.recaptcha.siteKey, + }, + }, + protectedEndpoints: config.recaptcha.protectedActions ?? [], + } + } + + if (!config.analytics?.provider && config.globalSiteTag) { + config.analytics = { + provider: { + name: 'analytics/google', + options: { siteTag: config.globalSiteTag }, + }, + } + } + + return config +} diff --git a/tests/server/setup.ts b/tests/server/setup.ts index 826f0fbe1..c924b1516 100644 --- a/tests/server/setup.ts +++ b/tests/server/setup.ts @@ -119,9 +119,12 @@ mock.module('@rctf/db/util', () => { mock.module('@rctf/config', () => { const { loadFileConfigs } = require('../../packages/config/src/loader') + const { normalizeConfig } = require('../../packages/config/src/normalize') const { ServerConfigSchema } = require('../../packages/config/src/types') const env = require('../../packages/config/src/env') - const config = ServerConfigSchema.parse(loadFileConfigs(testConfigDir)[0]) + const config = normalizeConfig( + ServerConfigSchema.parse(loadFileConfigs(testConfigDir)[0]) + ) return { ...env, config } }) diff --git a/tests/server/tests/integration/admin-users.test.ts b/tests/server/tests/integration/admin-users.test.ts index cbfa8c4d2..991bfb756 100644 --- a/tests/server/tests/integration/admin-users.test.ts +++ b/tests/server/tests/integration/admin-users.test.ts @@ -20,7 +20,7 @@ import { eq } from 'drizzle-orm' import type { Hono } from 'hono' import { cacheLeaderboardAndGraph } from '../../../../apps/api/src/cache/leaderboard' import { resendPendingTeamVerification } from '../../../../apps/api/src/services/admin-verifications' -import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard' +import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard-calculation' import { createPendingRegistrationVerification, getPendingRegistrationVerification, diff --git a/tests/server/tests/integration/challenge-visibility.test.ts b/tests/server/tests/integration/challenge-visibility.test.ts index 57741b9b6..d07c46dcb 100644 --- a/tests/server/tests/integration/challenge-visibility.test.ts +++ b/tests/server/tests/integration/challenge-visibility.test.ts @@ -15,7 +15,7 @@ import { import { afterAll, beforeAll, describe, expect, test } from 'bun:test' import { eq } from 'drizzle-orm' import type { Hono } from 'hono' -import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard' +import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard-calculation' import { getApp, request } from '../../app' import { expectResponse, diff --git a/tests/server/tests/integration/dynamic-scoring.test.ts b/tests/server/tests/integration/dynamic-scoring.test.ts index 509436c81..64609a5d3 100644 --- a/tests/server/tests/integration/dynamic-scoring.test.ts +++ b/tests/server/tests/integration/dynamic-scoring.test.ts @@ -20,7 +20,7 @@ import { getMaxSolveCount, upsertChallenge, } from '../../../../apps/api/src/services/challenges' -import { getChallengeLeaderboardWithTotal } from '../../../../apps/api/src/services/leaderboard' +import { getChallengeLeaderboardWithTotal } from '../../../../apps/api/src/services/leaderboard-queries' import { applyDecayPointsForChallenge, upsertDynamicSolves as rawUpsertDynamicSolves, diff --git a/tests/server/tests/integration/leaderboard-cache.test.ts b/tests/server/tests/integration/leaderboard-cache.test.ts index 2f5a5ef03..a76b609cb 100644 --- a/tests/server/tests/integration/leaderboard-cache.test.ts +++ b/tests/server/tests/integration/leaderboard-cache.test.ts @@ -18,11 +18,11 @@ import { afterEach, describe, expect, mock, test } from 'bun:test' import { eq } from 'drizzle-orm' import type { PinoLogger } from 'hono-pino' import type { TypedRedis } from '../../../../apps/api/src/cache/scripts' -import { scoreProvider } from '../../../../apps/api/src/providers' +import { scoreProvider } from '../../../../apps/api/src/providers/instances/score' import { calculateLeaderboard, createCachedLeaderboardCalculator, -} from '../../../../apps/api/src/services/leaderboard' +} from '../../../../apps/api/src/services/leaderboard-calculation' import { applyChallengeConfigChange, applyDecayPointsForAllChallenges, diff --git a/tests/server/tests/integration/leaderboard-e2e.test.ts b/tests/server/tests/integration/leaderboard-e2e.test.ts index c2c1ddec5..8ee14954c 100644 --- a/tests/server/tests/integration/leaderboard-e2e.test.ts +++ b/tests/server/tests/integration/leaderboard-e2e.test.ts @@ -18,7 +18,7 @@ import { eq } from 'drizzle-orm' import type { Hono } from 'hono' import { cacheLeaderboardAndGraph } from '../../../../apps/api/src/cache/leaderboard' import { createToken, TokenKind } from '../../../../apps/api/src/lib/tokens' -import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard' +import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard-calculation' import { createRedis } from '../../../../apps/api/src/util/redis' import { getApp, request } from '../../app' import { diff --git a/tests/server/tests/integration/leaderboard-global-place.test.ts b/tests/server/tests/integration/leaderboard-global-place.test.ts index 6c89760ef..a8fb838db 100644 --- a/tests/server/tests/integration/leaderboard-global-place.test.ts +++ b/tests/server/tests/integration/leaderboard-global-place.test.ts @@ -14,10 +14,10 @@ import { } from '../../../../apps/api/src/cache/leaderboard' import { getFullUser } from '../../../../apps/api/src/services/full-user' import { - calculateLeaderboard, getLeaderboardWithTotal, searchLeaderboard, -} from '../../../../apps/api/src/services/leaderboard' +} from '../../../../apps/api/src/services/leaderboard-queries' +import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard-calculation' import { createRedis } from '../../../../apps/api/src/util/redis' import { clearDatabase } from '../../util' diff --git a/tests/server/tests/integration/search.test.ts b/tests/server/tests/integration/search.test.ts index d819945f9..da4888cf7 100644 --- a/tests/server/tests/integration/search.test.ts +++ b/tests/server/tests/integration/search.test.ts @@ -22,7 +22,7 @@ import { eq } from 'drizzle-orm' import type { Hono } from 'hono' import { cacheLeaderboardAndGraph } from '../../../../apps/api/src/cache/leaderboard' import { createToken, TokenKind } from '../../../../apps/api/src/lib/tokens' -import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard' +import { calculateLeaderboard } from '../../../../apps/api/src/services/leaderboard-calculation' import { createRedis } from '../../../../apps/api/src/util/redis' import { getApp, request } from '../../app' import { expectResponse, generateChallenge } from '../../util' diff --git a/tests/server/tests/unit/leaderboard-recompute.test.ts b/tests/server/tests/unit/leaderboard-recompute.test.ts index 2e3305672..e7e45a7e6 100644 --- a/tests/server/tests/unit/leaderboard-recompute.test.ts +++ b/tests/server/tests/unit/leaderboard-recompute.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, mock, test } from 'bun:test' -import { scoreProvider } from '../../../../apps/api/src/providers' +import { scoreProvider } from '../../../../apps/api/src/providers/instances/score' import { createRecomputeQueue } from '../../../../apps/api/src/workers/leaderboard-recompute' const setRequiredFields = (fields: ReadonlyArray) => { diff --git a/tests/server/tests/unit/provider-instances-all.test.ts b/tests/server/tests/unit/provider-instances-all.test.ts new file mode 100644 index 000000000..877406cb7 --- /dev/null +++ b/tests/server/tests/unit/provider-instances-all.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test' +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' + +const instancesDir = path.resolve( + import.meta.dir, + '../../../../apps/api/src/providers/instances' +) + +describe('provider instances', () => { + test('all.ts imports every instance module', () => { + const source = readFileSync(path.join(instancesDir, 'all.ts'), 'utf8') + const modules = readdirSync(instancesDir).filter( + name => name.endsWith('.ts') && name !== 'all.ts' && name !== 'load.ts' + ) + + expect(modules.length).toBeGreaterThan(0) + for (const name of modules) { + expect(source).toContain(`'./${name.replace(/\.ts$/, '')}'`) + } + }) +})