feat(admin)!: move the admin api to rest and fix its defects (#2515)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 15:36:41 +02:00
1 parent a70924d4b0
commit 7f8f09ee51
143 files changed
+15238 -12790

No files matched your search

+129 -87
View File
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {
GIFT_CODE_DURATION_TYPE_DEFINITIONS,
MAX_GIFT_CODES_PER_REQUEST,
@@ -12,6 +11,7 @@ import {
SystemChannelFlagsDescriptions,
} from '@fluxer/constants/src/GuildConstants';
import {LIMIT_KEYS} from '@fluxer/constants/src/LimitConfigMetadata';
import {ADMIN_ACL_COUNT, AdminAclType} from '@fluxer/schema/src/domains/admin/AdminAclType';
import {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
import {UserAdminResponseSchema} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {
@@ -30,6 +30,7 @@ import {
NSFWLevelSchema,
} from '@fluxer/schema/src/primitives/GuildValidators';
import {PermissionStringType} from '@fluxer/schema/src/primitives/PermissionValidators';
import {createQueryIntegerType, QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators';
import {
createBitflagInt32Type,
createInt32EnumType,
@@ -45,8 +46,6 @@ import {
import {EmailType} from '@fluxer/schema/src/primitives/UserValidators';
import {z} from 'zod';
const ADMIN_ACL_COUNT = Object.keys(AdminACLs).length;
const ReportStatusSchema = withOpenApiType(
createInt32EnumType(
[
@@ -113,28 +112,26 @@ const SearchIndexTypeEnum = createNamedStringLiteralUnion(
],
'Type of search index to refresh',
);
export const ListAuditLogsRequest = z.object({
admin_user_id: SnowflakeType.optional().describe('Filter by admin user who performed the action'),
target_type: createStringType(1, 64).optional().describe('Filter by target entity type'),
target_id: z.string().optional().describe('Filter by target entity ID (user, channel, role, invite code, etc.)'),
limit: z.number().int().min(1).max(200).default(50).describe('Maximum number of entries to return'),
offset: z.number().int().min(0).default(0).describe('Number of entries to skip'),
export const AuditLogIdParam = z.object({
log_id: SnowflakeType.describe('The ID of the audit log entry'),
});
export type ListAuditLogsRequest = z.infer<typeof ListAuditLogsRequest>;
export type AuditLogIdParam = z.infer<typeof AuditLogIdParam>;
export const SearchAuditLogsRequest = z.object({
query: createStringType(1, 1024).optional().describe('Search query string'),
export const ListAdminAuditLogsQuery = z.object({
q: createStringType(1, 1024).optional().describe('Free-text query run against the audit log search index'),
admin_user_id: SnowflakeType.optional().describe('Filter by admin user who performed the action'),
target_type: createStringType(1, 64).optional().describe('Filter by target entity type'),
target_id: z.string().optional().describe('Filter by target entity ID (user, channel, role, invite code, etc.)'),
sort_by: AuditLogSortByEnum.default('createdAt'),
sort_order: SortOrderEnum.default('desc'),
limit: z.number().int().min(1).max(200).default(50).describe('Maximum number of entries to return'),
offset: z.number().int().min(0).default(0).describe('Number of entries to skip'),
limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 200}).describe(
'Maximum number of entries to return',
),
offset: createQueryIntegerType({defaultValue: 0, minValue: 0}).describe('Number of entries to skip'),
});
export type SearchAuditLogsRequest = z.infer<typeof SearchAuditLogsRequest>;
export type ListAdminAuditLogsQuery = z.infer<typeof ListAdminAuditLogsQuery>;
export const SearchReportsRequest = z.object({
query: createStringType(1, 1024).optional().describe('Search query string'),
@@ -155,34 +152,78 @@ export const SearchReportsRequest = z.object({
export type SearchReportsRequest = z.infer<typeof SearchReportsRequest>;
export const ListReportsRequest = z.object({
status: ReportStatusSchema.optional(),
limit: z.number().int().min(1).max(200).optional().describe('Maximum number of reports to return'),
offset: z.number().int().min(0).optional().describe('Number of reports to skip'),
const ReportStatusFilterEnum = createNamedStringLiteralUnion(
[
['pending', 'pending', 'Only reports that are waiting to be reviewed'],
['resolved', 'resolved', 'Only reports that have been resolved'],
],
'Only return reports with this status',
);
const ReportTypeFilterEnum = createNamedStringLiteralUnion(
[
['message', 'message', 'Only reports about a message'],
['user', 'user', 'Only reports about a user'],
['guild', 'guild', 'Only reports about a community'],
],
'Only return reports about this kind of entity',
);
const ReportSortByQueryEnum = createNamedStringLiteralUnion(
[
['created_at', 'created_at', 'Sort by the time the report was created'],
['reported_at', 'reported_at', 'Sort by the time the report was submitted'],
['resolved_at', 'resolved_at', 'Sort by the time the report was resolved'],
],
'The field to sort the reports by',
);
export const ListReportsQuery = z.object({
q: createStringType(1, 1024).optional().describe('Free-text query matched against the searchable report fields'),
status: ReportStatusFilterEnum.optional().describe('Only return reports with this status'),
report_type: ReportTypeFilterEnum.optional().describe('Only return reports about this kind of entity'),
category: createStringType(1, 128).optional().describe('Only return reports filed under this category'),
reporter_id: SnowflakeType.optional().describe('Only return reports submitted by this user'),
reported_user_id: SnowflakeType.optional().describe('Only return reports about this user'),
reported_guild_id: SnowflakeType.optional().describe('Only return reports about this community'),
reported_channel_id: SnowflakeType.optional().describe('Only return reports about content in this channel'),
guild_context_id: SnowflakeType.optional().describe('Only return reports filed from within this community'),
resolved_by_admin_id: SnowflakeType.optional().describe('Only return reports resolved by this admin'),
sort_by: ReportSortByQueryEnum.default('reported_at').describe('The field to sort the reports by'),
sort_order: SortOrderEnum.default('desc').describe('The direction to sort the reports in'),
limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 200}).describe(
'Maximum number of reports to return (1-200, default 50)',
),
offset: createQueryIntegerType({defaultValue: 0, minValue: 0, maxValue: 10000}).describe('Number of reports to skip'),
});
export type ListReportsRequest = z.infer<typeof ListReportsRequest>;
export type ListReportsQuery = z.infer<typeof ListReportsQuery>;
export const ResolveReportRequest = z.object({
report_id: SnowflakeType.describe('The ID of the report to resolve'),
export const UpdateReportRequest = z.object({
status: z.literal('resolved').describe('The status to move the report to'),
public_comment: createStringType(0, 512).optional().describe('Public comment to include with the resolution'),
});
export type ResolveReportRequest = z.infer<typeof ResolveReportRequest>;
export type UpdateReportRequest = z.infer<typeof UpdateReportRequest>;
export const SearchIndexNameParam = z.object({
index_name: SearchIndexTypeEnum.describe('The name of the search index'),
});
export type SearchIndexNameParam = z.infer<typeof SearchIndexNameParam>;
export const RefreshSearchIndexRequest = z.object({
index_type: SearchIndexTypeEnum,
guild_id: SnowflakeType.optional().describe('Specific guild ID to reindex'),
user_id: SnowflakeType.optional().describe('Specific user ID to reindex'),
});
export type RefreshSearchIndexRequest = z.infer<typeof RefreshSearchIndexRequest>;
export const GetIndexRefreshStatusRequest = z.object({
job_id: createStringType(1, 128).describe('ID of the index refresh job to check'),
export const SearchIndexRefreshIdParam = z.object({
job_id: createStringType(1, 128).describe('ID of the index refresh to read'),
});
export type GetIndexRefreshStatusRequest = z.infer<typeof GetIndexRefreshStatusRequest>;
export type SearchIndexRefreshIdParam = z.infer<typeof SearchIndexRefreshIdParam>;
export const PurgeGuildAssetsRequest = z.object({
ids: z.array(createStringType(1, 64)).max(100).describe('List of asset IDs to purge'),
@@ -190,29 +231,33 @@ export const PurgeGuildAssetsRequest = z.object({
export type PurgeGuildAssetsRequest = z.infer<typeof PurgeGuildAssetsRequest>;
export const TriggerUserArchiveRequest = z.object({
user_id: SnowflakeType.describe('ID of the user to archive'),
export const AdminArchiveCreateRequest = z.object({
include_attachments: z.boolean().default(false).describe('Whether to include attachment binaries in the archive'),
});
export type TriggerUserArchiveRequest = z.infer<typeof TriggerUserArchiveRequest>;
export type AdminArchiveCreateRequest = z.infer<typeof AdminArchiveCreateRequest>;
export const TriggerGuildArchiveRequest = z.object({
guild_id: SnowflakeType.describe('ID of the guild to archive'),
include_attachments: z.boolean().default(false).describe('Whether to include attachment binaries in the archive'),
});
export const ListArchivesQuery = z
.object({
subject_type: ArchiveListSubjectTypeEnum.default('all'),
subject_id: SnowflakeType.optional().describe('Filter by specific subject ID'),
requested_by: SnowflakeType.optional().describe('Filter by user who requested the archive'),
limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 200}).describe(
'Maximum number of archives to return (1-200, default 50)',
),
include_expired: QueryBooleanType.describe('Whether to include archives past their expires_at'),
})
.superRefine((value, ctx) => {
if (value.subject_id !== undefined && value.subject_type === 'all') {
ctx.addIssue({
code: 'custom',
message: 'subject_type must name user or guild when subject_id is supplied',
path: ['subject_type'],
});
}
});
export type TriggerGuildArchiveRequest = z.infer<typeof TriggerGuildArchiveRequest>;
export const ListArchivesRequest = z.object({
subject_type: ArchiveListSubjectTypeEnum.default('all'),
subject_id: SnowflakeType.optional().describe('Filter by specific subject ID'),
requested_by: SnowflakeType.optional().describe('Filter by user who requested the archive'),
limit: z.number().min(1).max(200).default(50).describe('Maximum number of archives to return'),
include_expired: z.boolean().default(false).describe('Whether to include expired archives'),
});
export type ListArchivesRequest = z.infer<typeof ListArchivesRequest>;
export type ListArchivesQuery = z.infer<typeof ListArchivesQuery>;
const IP_OR_CIDR_REGEX = /^(?:(?:\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?|(?:[a-fA-F0-9:]+)(?:\/\d{1,3})?)$/;
export const BanIpRequest = z.object({
@@ -268,12 +313,6 @@ export const BanUrlRequest = z.object({
export type BanUrlRequest = z.infer<typeof BanUrlRequest>;
export const UnbanUrlRequest = z.object({
url: createStringType(1, 2048).describe('URL to unban (must match the canonicalized form in storage)'),
});
export type UnbanUrlRequest = z.infer<typeof UnbanUrlRequest>;
export const BanUrlDomainRequest = z.object({
domain: createStringType(1, 253)
.refine(
@@ -296,12 +335,6 @@ export const BanUrlDomainRequest = z.object({
export type BanUrlDomainRequest = z.infer<typeof BanUrlDomainRequest>;
export const UnbanUrlDomainRequest = z.object({
domain: createStringType(1, 253).describe('Domain to unban'),
});
export type UnbanUrlDomainRequest = z.infer<typeof UnbanUrlDomainRequest>;
export const BanFileShaRequest = z.object({
sha256_hex: createStringType(64, 64)
.refine((v) => /^[0-9a-fA-F]{64}$/.test(v), 'Must be a 64-character hex SHA-256')
@@ -315,24 +348,6 @@ export const BanFileShaRequest = z.object({
export type BanFileShaRequest = z.infer<typeof BanFileShaRequest>;
export const UnbanFileShaRequest = z.object({
sha256_hex: createStringType(64, 64).refine((v) => /^[0-9a-fA-F]{64}$/.test(v), 'Must be a 64-character hex SHA-256'),
});
export type UnbanFileShaRequest = z.infer<typeof UnbanFileShaRequest>;
export const CheckUrlBlocklistRequest = z.object({
url: createStringType(1, 2048).describe('URL to check against the blocklist'),
});
export type CheckUrlBlocklistRequest = z.infer<typeof CheckUrlBlocklistRequest>;
export const CheckFileShaRequest = z.object({
sha256_hex: createStringType(64, 64).refine((v) => /^[0-9a-fA-F]{64}$/.test(v), 'Must be a 64-character hex SHA-256'),
});
export type CheckFileShaRequest = z.infer<typeof CheckFileShaRequest>;
const AvatarHashShortType = createStringType(8, 10).refine(
(v) => /^(a_)?[0-9a-fA-F]{8}$/.test(v),
'Must be an 8-character MD5 prefix (with optional "a_" prefix)',
@@ -732,6 +747,7 @@ export const InstanceConfigUpdateRequest = z.object({
single_community_enabled: z.boolean().optional(),
single_community_name: z.string().trim().min(1).max(100).optional(),
direct_messages_disabled: z.boolean().optional(),
direct_messages_locked: z.literal(false).optional(),
premium_mode: z.enum(['mirror', 'everyone']).optional(),
services: z
.object({
@@ -794,14 +810,22 @@ export const CreateRegistrationUrlResponse = z.object({
export type CreateRegistrationUrlResponse = z.infer<typeof CreateRegistrationUrlResponse>;
export const RegistrationUrlActionRequest = z.object({
id: createStringType(1, 128),
export const RegistrationUrlIdParam = z.object({
registration_url_id: createStringType(1, 128).describe('The ID of the registration URL'),
});
export type RegistrationUrlActionRequest = z.infer<typeof RegistrationUrlActionRequest>;
export type RegistrationUrlIdParam = z.infer<typeof RegistrationUrlIdParam>;
const PendingRegistrationStatusSchema = createNamedStringLiteralUnion(
[
['approved', 'approved', 'The account may log in'],
['rejected', 'rejected', 'The account is blocked from logging in'],
],
'Pending registration decision',
);
export const PendingRegistrationActionRequest = z.object({
user_id: SnowflakeStringType,
status: PendingRegistrationStatusSchema,
});
export type PendingRegistrationActionRequest = z.infer<typeof PendingRegistrationActionRequest>;
@@ -860,7 +884,7 @@ export const CreateAdminApiKeyRequest = z.object({
.refine((value) => value.trim().length > 0, 'Name cannot be empty')
.describe('Display name for the API key'),
expires_in_days: z.number().int().min(1).max(365).optional().describe('Number of days until the key expires'),
acls: z.array(z.string()).max(ADMIN_ACL_COUNT).describe('List of access control permissions for the key'),
acls: z.array(AdminAclType).max(ADMIN_ACL_COUNT).describe('List of access control permissions for the key'),
});
export type CreateAdminApiKeyRequest = z.infer<typeof CreateAdminApiKeyRequest>;
@@ -888,6 +912,23 @@ export const ListAdminApiKeyResponse = z.object({
export type ListAdminApiKeyResponse = z.infer<typeof ListAdminApiKeyResponse>;
export const UpdateAdminApiKeyRequest = z.object({
name: z
.string()
.min(1)
.max(100)
.refine((value) => value.trim().length > 0, 'Name cannot be empty')
.optional()
.describe('New display name for the API key'),
acls: z
.array(AdminAclType)
.max(ADMIN_ACL_COUNT)
.optional()
.describe('Replacement list of access control permissions for the key'),
});
export type UpdateAdminApiKeyRequest = z.infer<typeof UpdateAdminApiKeyRequest>;
export const SearchGuildsResponse = z.object({
guilds: z.array(GuildAdminResponse),
total: z.number(),
@@ -1016,7 +1057,7 @@ const AdminAuditLogChannelSummarySchema = z.object({
type: ChannelTypeSchema,
guild_id: SnowflakeStringType.nullable(),
});
const AdminAuditLogResponseSchema = z.object({
export const AdminAuditLogResponseSchema = z.object({
log_id: SnowflakeStringType,
admin_user_id: SnowflakeStringType,
admin_user: AdminAuditLogUserSummarySchema.nullable(),
@@ -1228,8 +1269,8 @@ const AdminLookupGuildSchema = z.object({
rules_channel_id: SnowflakeStringType.nullable(),
disabled_operations: Int32Type,
member_count: Int32Type,
channels: z.array(AdminGuildChannelSummarySchema).max(500),
roles: z.array(AdminGuildRoleSummarySchema).max(250),
channels: z.array(AdminGuildChannelSummarySchema),
roles: z.array(AdminGuildRoleSummarySchema),
});
export const LookupGuildResponse = z.object({
guild: AdminLookupGuildSchema.nullable(),
@@ -1250,14 +1291,14 @@ const GuildAssetItemSchema = z.object({
export const ListGuildEmojisResponse = z.object({
guild_id: SnowflakeStringType,
emojis: z.array(GuildAssetItemSchema).max(500),
emojis: z.array(GuildAssetItemSchema),
});
export type ListGuildEmojisResponse = z.infer<typeof ListGuildEmojisResponse>;
export const ListGuildStickersResponse = z.object({
guild_id: SnowflakeStringType,
stickers: z.array(GuildAssetItemSchema).max(500),
stickers: z.array(GuildAssetItemSchema),
});
export type ListGuildStickersResponse = z.infer<typeof ListGuildStickersResponse>;
@@ -1403,7 +1444,7 @@ export const ReportAdminResponseSchema = z.object({
message_context: z.array(ReportMessageContextSchema).optional(),
message_responses: z.array(MessageResponseSchema).optional(),
});
export const ListReportsResponse = z.object({
const ListReportsResponse = z.object({
reports: z.array(ReportAdminResponseSchema),
});
export const ResolveReportResponse = z.object({
@@ -1412,12 +1453,13 @@ export const ResolveReportResponse = z.object({
resolved_at: z.string().nullable(),
public_comment: z.string().nullable(),
});
export const SearchReportsResponse = z.object({
const SearchReportsResponse = z.object({
reports: z.array(ReportAdminResponseSchema),
total: z.number(),
offset: z.number(),
limit: z.number(),
});
export const AdminReportListResponse = z.union([SearchReportsResponse, ListReportsResponse]);
const LimitKeyMetadataSchema = z.object({
key: z.string(),
label: z.string(),