import { fetch as tauriFetch } from '@tauri-apps/plugin-http' export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool' /** Where the skin site stores the JWT for this launcher. Kept separate from * the web site's own `loginToken` so the two sessions never clash. */ const TOKEN_STORAGE_KEY = 'starlight-skin-site-token' export type SkinSiteResponse = | { readonly success: true; readonly payload: T; readonly timestamp: number } | { readonly success: false readonly timestamp: number readonly errorType?: string readonly errorCode: number readonly errorMessage: string } type RequestType = 'starlight' | 'skin' | 'mod' const REQUEST_TYPE_PATH: Record = { starlight: '/starlight', skin: '/starlight/skin', mod: '/starlight/mod', } // --------------------------------------------------------------------------- // Token storage // --------------------------------------------------------------------------- export function getSkinSiteToken(): string | null { try { return localStorage.getItem(TOKEN_STORAGE_KEY) } catch { return null } } export function setSkinSiteToken(token: string | null): void { try { if (token) localStorage.setItem(TOKEN_STORAGE_KEY, token) else localStorage.removeItem(TOKEN_STORAGE_KEY) } catch { // Storage may be unavailable in some environments; ignore. } } export function clearSkinSiteToken(): void { setSkinSiteToken(null) } // --------------------------------------------------------------------------- // Core request // --------------------------------------------------------------------------- function createException( errorCode: number, errorMessage: string, timestamp?: number, errorType?: string, ): SkinSiteResponse { return { success: false, timestamp: timestamp ?? Date.now(), errorMessage, errorType, errorCode, } } function handleException(status: number): SkinSiteResponse { switch (status) { case 0: return createException(0, '连接中断,请检查网络或代理后重试', undefined, 'NetworkError') case 400: return createException(status, '请求参数有误') case 401: return createException(status, '该功能需要登录后才能使用') case 403: return createException(status, '权限不足') case 404: return createException(status, '资源不存在') case 413: return createException(status, '文件超过服务器上传大小限制') case 429: return createException(status, '请求过于频繁,请稍后再试') case 502: case 503: return createException(status, '服务器暂时不可用,请稍后重试') case 504: return createException(status, '网关等待服务器处理超时') default: return createException(status, `请求失败(HTTP ${status})`) } } async function processResponse(response: Response): Promise> { const text = await response.text() if (!response.ok && text.length === 0) { return handleException(response.status) } let data: Record | null = null try { data = text.length !== 0 ? JSON.parse(text) : null } catch { return response.ok ? createException(response.status, '服务器返回了无法识别的响应', undefined, 'InvalidResponse') : handleException(response.status) } if (response.ok) { return { success: true, payload: data?.payload as T, timestamp: typeof data?.timestamp === 'number' ? data.timestamp : Date.now(), } } const errorMessage = data?.errorMessage if (typeof errorMessage === 'string' && errorMessage) { return createException( response.status, errorMessage, typeof data?.timestamp === 'number' ? data.timestamp : undefined, typeof data?.error === 'string' ? data.error : undefined, ) } return handleException(response.status) } interface SendOptions { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' type?: RequestType body?: unknown /** Send a multipart form body (e.g. issue creation with images). */ form?: FormData /** Set to false to skip the Authorization header (e.g. login). */ auth?: boolean } export async function skinSiteRequest( path: string, options: SendOptions = {}, ): Promise> { const { method = 'GET', type = 'starlight', body, form, auth = true } = options const url = `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH[type]}/${path.replace(/^\/+/, '')}` const headers: Record = { Accept: 'application/json', } if (body !== undefined) headers['Content-Type'] = 'application/json' // FormData sets its own multipart boundary; never set Content-Type for it. if (auth) { const token = getSkinSiteToken() if (token) headers['Authorization'] = `Bearer ${token}` } let response: Response try { response = await tauriFetch(url, { method, headers, body: form ?? (body !== undefined ? JSON.stringify(body) : undefined), }) } catch { return handleException(0) } return processResponse(response) } // --------------------------------------------------------------------------- // Auth types (mirrors the skin site's packet shapes) // --------------------------------------------------------------------------- export interface SkinSiteUser { uuid: string username: string email?: string qq?: number | null banned?: boolean id?: number permissions?: string[] registerTime?: number avatar?: string | null } export interface SkinSiteLoginResult { username: string jwt: string } export interface SkinSiteCaptchaRequire { clientId: string image?: string [extra: string]: unknown } // --------------------------------------------------------------------------- // Auth API // --------------------------------------------------------------------------- /** Ask the server for a captcha challenge. */ export function requestCaptcha(): Promise> { return skinSiteRequest('captcha', { auth: false }) } /** Submit the captcha rotation angle; resolves to the clientId used for login/register. */ export function verifyCaptcha(payload: { clientId: string degree: number }): Promise> { return skinSiteRequest('verifyCaptcha', { method: 'POST', body: payload, auth: false }) } export function skinSiteLogin(payload: { username: string password: string clientId: string }): Promise> { return skinSiteRequest('login', { method: 'POST', body: payload, auth: false, }) } export function skinSiteRefresh(): Promise> { return skinSiteRequest('user/refresh') } export function skinSiteCurrentUser(): Promise> { return skinSiteRequest('user') } export function skinSiteLuck(): Promise> { return skinSiteRequest<{ luck: number }>('luck') } export function skinSiteRegisterCondition(): Promise< SkinSiteResponse<{ registerAllowed: boolean; inviteCodeRequired: boolean }> > { return skinSiteRequest('register/condition', { auth: false }) } // --------------------------------------------------------------------------- // Players & skins (RequestType.SKIN) // --------------------------------------------------------------------------- export interface SkinSitePlayerInfo { uuid: string name: string isMojang: boolean ownerUsername?: string legacyInternalAdapted?: boolean } export interface SkinSitePlayerSkin { skin?: string cape?: string elytra?: string model?: 'default' | 'slim' } /** List the players owned by the signed-in account. */ export function skinSitePlayerList(): Promise> { return skinSiteRequest('player', { type: 'skin' }) } /** Fetch the skin/cape/elytra textures for a single player. */ export function skinSitePlayerSkin(uuid: string): Promise> { return skinSiteRequest(`player/skin/${encodeURIComponent(uuid)}`, { type: 'skin', }) } export type SkinTextureType = 'SKIN' | 'CAPE' | 'ELYTRA' /** Upload (or replace) a texture for a player. */ export async function skinSiteUploadTexture( uuid: string, file: File, type: SkinTextureType, metadata: Record = {}, ): Promise> { const form = new FormData() form.append('file', file) for (const [key, value] of Object.entries(metadata)) form.append(key, value) const url = `${SKIN_SITE_ORIGIN}/starlight/skin/player/skin/${encodeURIComponent(uuid)}/${type}` const headers: Record = {} const token = getSkinSiteToken() if (token) headers['Authorization'] = `Bearer ${token}` let response: Response try { response = await tauriFetch(url, { method: 'PUT', headers, body: form }) } catch { return handleException(0) } return processResponse(response) } /** Remove a texture from a player. */ export function skinSiteDeleteTexture( uuid: string, type: SkinTextureType, ): Promise> { return skinSiteRequest(`player/skin/${encodeURIComponent(uuid)}/${type}`, { method: 'DELETE', type: 'skin', }) } /** Add a player by name to the signed-in account. */ export function skinSiteAddPlayer(playerName: string): Promise> { return skinSiteRequest(`player/add/${encodeURIComponent(playerName)}`, { method: 'POST', type: 'skin', }) } export function skinSiteDeletePlayer(uuid: string): Promise> { return skinSiteRequest(`player/${encodeURIComponent(uuid)}`, { method: 'DELETE', type: 'skin', }) } export function skinSiteRenamePlayer( uuid: string, newName: string, ): Promise> { return skinSiteRequest( `player/${encodeURIComponent(uuid)}/rename/${encodeURIComponent(newName)}`, { method: 'POST', type: 'skin' }, ) } // --------------------------------------------------------------------------- // Registration & password reset // --------------------------------------------------------------------------- /** Send the email verification code used during registration. */ export function skinSiteRegisterEmailVerify(email: string): Promise> { return skinSiteRequest('register/emailVerify', { method: 'POST', body: { email }, auth: false, }) } export function skinSiteRegister(payload: { qq?: number email: string emailVerifyCode: string password: string inviteCode?: string clientId: string }): Promise> { return skinSiteRequest('register', { method: 'POST', body: payload, auth: false, }) } /** Ask the server to email a reset code. Always answers success to avoid probing. */ export function skinSitePasswordResetEmailVerify(email: string): Promise> { return skinSiteRequest('password/emailVerify', { method: 'POST', body: { email }, auth: false, }) } export function skinSitePasswordReset(payload: { clientId: string email: string emailVerifyCode: string newPassword: string }): Promise> { return skinSiteRequest('password/reset', { method: 'POST', body: payload, auth: false, }) } // --------------------------------------------------------------------------- // Donations & announcements // --------------------------------------------------------------------------- export interface SkinSitePublicDonationSummary { totalAmount: string donorCount: number } export interface SkinSitePublicDonationEntry { id: number rank: number username: string avatar: string | null amount: string } export interface SkinSitePublicDonationPage { items: SkinSitePublicDonationEntry[] summary: SkinSitePublicDonationSummary hasMore: boolean } export function skinSitePublicDonationSummary(): Promise< SkinSiteResponse > { return skinSiteRequest('donation/summary', { auth: false }) } export function skinSiteDonationList(offset = 0): Promise> { return skinSiteRequest(`donation/list?limit=20&offset=${offset}`, { auth: false, }) } export interface SkinSiteAnnouncement { id: number title: string subtitle: string imageName?: string content: string updateAt: number star: boolean importantLevel: number source: string } /** Public announcements shown on the skin site. Requires the JWT when signed in. */ export function skinSiteAnnouncements(): Promise> { return skinSiteRequest('broadcast/all', { type: 'skin' }) } // --------------------------------------------------------------------------- // Account updates (requires the JWT) // --------------------------------------------------------------------------- export function skinSiteChangeUsername(newUsername: string): Promise> { return skinSiteRequest('update/username', { method: 'POST', body: { newUsername }, }) } export function skinSiteChangePassword( oldPassword: string, newPassword: string, ): Promise> { return skinSiteRequest('update/password', { method: 'POST', body: { oldPassword, newPassword }, }) } /** Pass null to reset the Minecraft password back to the website password. */ export function skinSiteChangeYggdrasilPassword( newPassword: string | null, ): Promise> { return skinSiteRequest('update/yggdrasilPassword', { method: 'POST', body: { newPassword }, }) } /** Upload a new avatar image (multipart). */ export async function skinSiteUploadAvatar(blob: Blob): Promise> { const form = new FormData() form.append('avatar', blob) const url = `${SKIN_SITE_ORIGIN}/starlight/update/avatar` const headers: Record = {} const token = getSkinSiteToken() if (token) headers['Authorization'] = `Bearer ${token}` let response: Response try { response = await tauriFetch(url, { method: 'POST', headers, body: form }) } catch { return handleException(0) } return processResponse(response) } // --------------------------------------------------------------------------- // Downloads // --------------------------------------------------------------------------- export type SkinSitePublicDownloadStatus = 'OK' | 'NEW' | 'LEGACY' | 'OUTDATED' | 'REMOVAL' export interface SkinSitePublicDownloadRoute { name?: string | null url: string } export interface SkinSitePublicDownloadItem { id: number uploader: string url: string routes: SkinSitePublicDownloadRoute[] filename: string description: string status: SkinSitePublicDownloadStatus[] category: string[] } export interface SkinSitePublicDownloadCategory { id: number name: string } export function skinSitePublicDownloadList(options?: { limit?: number offset?: number category?: string }): Promise> { const query = new URLSearchParams({ limit: String(options?.limit ?? 200), offset: String(options?.offset ?? 0), }) if (options?.category) query.set('category', options.category) return skinSiteRequest(`download/list?${query.toString()}`, { auth: false, }) } export function skinSitePublicDownloadCount(category?: string): Promise> { const suffix = category ? `?category=${encodeURIComponent(category)}` : '' return skinSiteRequest(`download/count${suffix}`, { auth: false }) } export function skinSitePublicDownloadCategories(): Promise< SkinSiteResponse > { return skinSiteRequest('download/category/list', { auth: false, }) } // --------------------------------------------------------------------------- // Maps // --------------------------------------------------------------------------- export interface SkinSitePublicMapEntry { name: string url: string } export interface SkinSitePublicMapServer { name: string internal: boolean maps: SkinSitePublicMapEntry[] } export function skinSitePublicMapServers(): Promise> { return skinSiteRequest('maps', { auth: false }) } // --------------------------------------------------------------------------- // Player review (public side) // --------------------------------------------------------------------------- export type SkinSiteReviewQuestionType = 'SINGLE' | 'MULTIPLE' | 'TRUE_FALSE' | 'FILL' | 'ESSAY' export type SkinSiteReviewResult = 'PENDING' | 'PASSED' | 'REJECTED' export interface SkinSiteReviewPublicOption { id: string description: string correct?: boolean defaultSelected?: boolean } export interface SkinSiteReviewPublicQuestion { id: string type: SkinSiteReviewPublicQuestionType title: string annotation?: string | null score?: number optional?: boolean fatal?: boolean options?: SkinSiteReviewPublicOption[] answer?: string | null maxLength?: number | null informationQuestion?: boolean } export interface SkinSiteReviewPublicPaper { id: string title: string description?: string questions: SkinSiteReviewPublicQuestion[] [key: string]: unknown } export interface SkinSiteReviewCatalog { papers: SkinSiteReviewPaper[] [key: string]: unknown } export interface SkinSiteReviewReceipt { id?: number accessToken?: string result?: SkinSiteReviewResult title?: string [key: string]: unknown } export function skinSiteReviewCatalog(): Promise> { return skinSiteRequest('review/public', { auth: false }) } export function skinSiteReviewPaper( paperId: string, ): Promise> { return skinSiteRequest(`review/public/${encodeURIComponent(paperId)}`, { auth: false, }) } export interface SkinSiteReviewSubmitData { paperId: string email: string answers: Record inviteCode?: string [key: string]: unknown } export function skinSiteSubmitReview( data: SkinSiteReviewSubmitData, ): Promise> { return skinSiteRequest('review/submit', { method: 'POST', body: data, auth: false, }) } // --------------------------------------------------------------------------- // Bug tracker // --------------------------------------------------------------------------- export type SkinSiteBugIssueType = 'BUG' | 'FEATURE' | 'IMPROVEMENT' export type SkinSiteBugStatus = | 'PENDING' | 'CONFIRMED' | 'NEEDS_INFO' | 'IN_PROGRESS' | 'VERIFYING' | 'CLOSED' export type SkinSiteBugPriority = 'CRITICAL' | 'HIGH' | 'NORMAL' | 'LOW' export type SkinSiteBugSearchType = 'CONTENT' | 'SUBMITTER' export type SkinSiteBugResolution = | 'FIXED' | 'WONT_FIX' | 'BY_DESIGN' | 'DUPLICATE' | 'CANNOT_REPRODUCE' | 'INVALID' export interface SkinSiteBugImage { hash: string [key: string]: unknown } export interface SkinSiteBugIssue { id: number type: SkinSiteBugIssueType title: string description: string private: boolean authorUuid: string authorName: string authorAvatar: string | null status: SkinSiteBugStatus priority: SkinSiteBugPriority resolution: SkinSiteBugResolution | null assigneeUuid: string | null assigneeName: string | null commentCount: number images: SkinSiteBugImage[] createdAt: number updatedAt: number closedAt: number | null canEdit: boolean canManage: boolean } export interface SkinSiteBugIssuePage { items: SkinSiteBugIssue[] hasMore: boolean } export function skinSiteBugIssues(params: { offset?: number limit?: number type?: SkinSiteBugIssueType | '' status?: SkinSiteBugStatus | '' priority?: SkinSiteBugPriority | '' assigned?: boolean openOnly?: boolean pendingFirst?: boolean searchType?: SkinSiteBugSearchType keyword?: string }): Promise> { const query = new URLSearchParams({ offset: String(params.offset ?? 0), limit: String(params.limit ?? 20), }) if (params.type) query.set('type', params.type) if (params.status) query.set('status', params.status) if (params.priority) query.set('priority', params.priority) if (params.assigned) query.set('assigned', 'true') if (params.searchType && params.keyword) { query.set('searchType', params.searchType) query.set('keyword', params.keyword) } query.set('openOnly', String(params.openOnly ?? true)) query.set('pendingFirst', String(params.pendingFirst ?? true)) return skinSiteRequest(`bug?${query.toString()}`) } export function skinSiteBugIssue(id: number): Promise> { return skinSiteRequest(`bug/${id}`) } export interface SkinSiteBugComment { id: number issueId: number authorUuid: string authorName: string authorAvatar: string | null content: string system: boolean images: SkinSiteBugImage[] createdAt: number editedAt: number | null deletedAt: number | null canEdit: boolean canDelete: boolean } export interface SkinSiteBugCommentPage { items: SkinSiteBugComment[] hasMore: boolean } export function skinSiteBugComments( issueId: number, offset = 0, limit = 50, ): Promise> { return skinSiteRequest( `bug/${issueId}/comments?offset=${offset}&limit=${limit}`, ) } export function skinSiteCreateBugComment( issueId: number, content: string, images: File[] = [], ): Promise> { const form = new FormData() form.append('payload', JSON.stringify({ content })) for (const image of images) form.append('images', image) return skinSiteRequest(`bug/${issueId}/comments`, { method: 'POST', form, }) } export function skinSiteDeleteBugComment(id: number): Promise> { return skinSiteRequest(`bug/comments/${id}`, { method: 'DELETE' }) } export function skinSiteBugUnreadCount(): Promise> { return skinSiteRequest('bug/notifications/count') } export function skinSiteMarkBugNotificationsRead(): Promise> { return skinSiteRequest('bug/notifications/read', { method: 'POST' }) } export interface SkinSiteBugCreateRequest { type: SkinSiteBugIssueType title: string description: string private?: boolean } export function skinSiteCreateBugIssue( request: SkinSiteBugCreateRequest, images: File[] = [], ): Promise> { const form = new FormData() form.append('payload', JSON.stringify(request)) for (const image of images) form.append('images', image) return skinSiteRequest('bug', { method: 'POST', form }) } export interface SkinSiteBugNotification { id: number issueId: number actorName: string createdAt: number } export function skinSiteBugNotifications(): Promise> { return skinSiteRequest('bug/notifications') } export function skinSiteMarkBugNotificationRead( issueId?: number, ): Promise> { return skinSiteRequest('bug/notifications/read', { method: 'POST', body: issueId === undefined ? {} : { issueId }, }) } // --------------------------------------------------------------------------- // Admin console // --------------------------------------------------------------------------- export interface SkinSiteAdminStatus { bootTime: number memoryUsage: number userCount: number playerCount: number } export function skinSiteAdminStatus(): Promise> { return skinSiteRequest('admin/status') } export interface SkinSiteBroadcast { id: number title: string subtitle: string imageName?: string content: string updateAt: number star: boolean importantLevel: number source: string expireAt: number uploader: string } export interface SkinSiteBroadcastInput { title: string subtitle: string content: string expireAt: number star: boolean importantLevel: number imageFile?: File | null removeImage?: boolean } export function skinSiteBroadcasts(): Promise> { return skinSiteRequest('broadcast/all/complex', { type: 'skin' }) } export function skinSiteCreateBroadcast( input: SkinSiteBroadcastInput, ): Promise> { const form = new FormData() form.append( 'payload', JSON.stringify({ title: input.title, subtitle: input.subtitle, content: input.content, expireAt: input.expireAt, star: input.star, importantLevel: input.importantLevel, }), ) if (input.imageFile) form.append('image', input.imageFile) return skinSiteRequest('broadcast', { method: 'PUT', type: 'skin', form }) } export function skinSiteUpdateBroadcast( id: number, input: SkinSiteBroadcastInput, ): Promise> { const form = new FormData() form.append( 'payload', JSON.stringify({ title: input.title, subtitle: input.subtitle, content: input.content, expireAt: input.expireAt, star: input.star, importantLevel: input.importantLevel, removeImage: input.removeImage ?? false, }), ) if (input.imageFile) form.append('image', input.imageFile) return skinSiteRequest('broadcast/update/' + id, { method: 'PUT', type: 'skin', form, }) } export function skinSiteDeleteBroadcast(id: number): Promise> { return skinSiteRequest('broadcast/' + id, { method: 'DELETE', type: 'skin' }) } export interface SkinSiteAdminPlayer { uuid: string name: string isMojang: boolean ownerUsername: string legacyInternalAdapted: boolean } export interface SkinSiteAdminPlayerQuery { limit: number offset: number searchField?: string keyword?: string } export function skinSiteAdminPlayerCount(params?: { searchField?: string keyword?: string }): Promise> { const query = new URLSearchParams() if (params?.searchField?.trim()) query.set('searchField', params.searchField!.trim()) if (params?.keyword?.trim()) query.set('keyword', params.keyword!.trim()) const suffix = query.toString() ? '?' + query.toString() : '' return skinSiteRequest('admin/players/number' + suffix, { type: 'skin' }) } export function skinSiteAdminPlayerList( query: SkinSiteAdminPlayerQuery, ): Promise> { return skinSiteRequest('admin/players/list', { method: 'POST', type: 'skin', body: query, }) } export function skinSiteAdminUpdatePlayer(input: { uuid: string name: string isMojang: boolean }): Promise> { return skinSiteRequest('admin/update', { method: 'POST', type: 'skin', body: input, }) } export function skinSiteAdminDeletePlayer(uuid: string): Promise> { return skinSiteRequest('admin/delete/' + encodeURIComponent(uuid), { method: 'POST', type: 'skin', }) } export interface SkinSiteAdminUser { uuid: string username: string email: string qq?: number banned: boolean id: number permissions: string[] avatar?: string registerTime: number } export function skinSiteAdminUserCount(params?: { searchField?: string keyword?: string }): Promise> { const query = new URLSearchParams() if (params?.searchField?.trim()) query.set('searchField', params.searchField!.trim()) if (params?.keyword?.trim()) query.set('keyword', params.keyword!.trim()) const suffix = query.toString() ? '?' + query.toString() : '' return skinSiteRequest('admin/users/number' + suffix) } export function skinSiteAdminUserList(query: { limit: number offset: number searchField?: string keyword?: string }): Promise> { return skinSiteRequest('admin/users/list', { method: 'POST', body: query, }) } export function skinSiteAdminUpdateUser(input: { uuid: string username: string password?: string email: string qq?: number banned: boolean permissions: string[] }): Promise> { return skinSiteRequest('admin/update', { method: 'POST', body: input }) } export function skinSiteAdminDeleteUser(uuid: string): Promise> { return skinSiteRequest('admin/delete/' + encodeURIComponent(uuid), { method: 'POST', }) } export function skinSiteAdminUserPlayers( uuid: string, ): Promise> { return skinSiteRequest( 'admin/users/' + encodeURIComponent(uuid) + '/players', ) } export interface SkinSiteFrequencyLimit { dropPercent?: number | null } export function skinSiteAdminUserFrequencyLimit( uuid: string, ): Promise> { return skinSiteRequest( 'admin/users/' + encodeURIComponent(uuid) + '/frequency-limit', ) } export type SkinSiteReviewMode = 'AUTO' | 'MANUAL' export type SkinSiteReviewQuestionType = 'SINGLE' | 'MULTIPLE' | 'TRUE_FALSE' | 'FILL' | 'ESSAY' export interface SkinSiteReviewOption { id: string description: string correct: boolean defaultSelected: boolean score: number } export interface SkinSiteReviewQuestion { id: string type: SkinSiteReviewQuestionType title: string annotation?: string | null score: number optional: boolean fatal: boolean autoReview: boolean options: SkinSiteReviewOption[] answer?: string | null maxLength?: number | null informationQuestion?: boolean informationAlias?: string | null } export interface SkinSiteReviewPaper { id: string title: string annotation?: string | null mode: SkinSiteReviewMode passScore: number version: number questions: SkinSiteReviewQuestion[] } export interface SkinSiteReviewAnswer { id: string questionId?: string values?: string[] text?: string score?: number | null [key: string]: unknown } export interface SkinSiteReviewSubmission { id: number reviewVersion: number revision: number applicantName: string username?: string | null qq?: string | null userUuid?: string | null registeredAt?: number | null mode: SkinSiteReviewMode result: SkinSiteReviewResult score?: number | null targetPermissions: string[] automaticResults: Record inviteCode?: string | null inviteUsed: boolean reviewerUuid?: string | null reviewerName?: string | null createdAt: number reviewedAt?: number | null answers: SkinSiteReviewAnswer[] paper: SkinSiteReviewPaper decisionHistory?: Array<{ previousResult: SkinSiteReviewResult result: SkinSiteReviewResult reviewer: string reason: string at: number permissionNote: string }> context?: { ip: string | null ipSource: string userAgent: string activity?: { durationMs: number hiddenMs: number screenSwitches: number } | null } | null } export function skinSiteReviewSubmissions(params?: { result?: SkinSiteReviewResult | 'COMPLETED' limit?: number offset?: number paperId?: string }): Promise> { const query = new URLSearchParams({ limit: String(params?.limit ?? 20), offset: String(params?.offset ?? 0), }) if (params?.result) query.set('result', params.result) if (params?.paperId) query.set('paperId', params.paperId) return skinSiteRequest('review/admin/submissions?' + query.toString()) } export function skinSiteReviewSubmissionCount(params?: { result?: SkinSiteReviewResult | 'COMPLETED' paperId?: string }): Promise> { const query = new URLSearchParams() if (params?.result) query.set('result', params.result) if (params?.paperId) query.set('paperId', params.paperId) const suffix = query.toString() ? '?' + query.toString() : '' return skinSiteRequest('review/admin/submissions/count' + suffix) } export function skinSiteDecideReviewSubmission( id: number, accepted: boolean, manualScores: Record = {}, expectedRevision?: number, reason = '', ): Promise> { const body: Record = { accepted, manualScores, reason } if (expectedRevision !== undefined) body.expectedRevision = expectedRevision return skinSiteRequest('review/admin/submissions/' + id + '/decision', { method: 'POST', body, }) } export interface SkinSiteReviewInviteDelivery { email: string inviteCode: string emailSent: boolean emailAttempted: boolean } export function skinSiteRegenerateReviewInvite( id: number, ): Promise> { return skinSiteRequest( 'review/admin/submissions/' + id + '/invite/regenerate', { method: 'POST' }, ) } export interface SkinSiteLauncherRelease { id: number version: string channel: 'release' | 'beta' notes: string forceUpdate: boolean uploadedBy: string uploadedAt: number publishedAt: number | null active: boolean } export interface SkinSiteLauncherAdminState { latestUrl: string releases: SkinSiteLauncherRelease[] } export function skinSiteLauncherAdminState(): Promise< SkinSiteResponse > { return skinSiteRequest('launcher/admin') } export function skinSiteActivateLauncherRelease( id: number, ): Promise> { return skinSiteRequest('launcher/admin/' + id + '/activate', { method: 'POST', }) } export interface SkinSiteLauncherUploadStatus { id: string chunkSize: number size: number received: number receivedChunks: number[] uploadConcurrency: number state: 'uploading' | 'processing' | 'ready' | 'failed' | 'cancelled' release?: SkinSiteLauncherRelease | null errorMessage?: string | null } const LAUNCHER_MAX_UPLOAD_BYTES = 4 * 1024 ** 3 const LAUNCHER_MAX_CONCURRENCY = 6 function launcherUploadError(message: string, code = 0): SkinSiteResponse { return { success: false, timestamp: Date.now(), errorCode: code, errorMessage: message } } export async function skinSiteUploadLauncherRelease( file: File, forceUpdate: boolean, onProgress?: (percent: number) => void, ): Promise> { if (!file.name.toLowerCase().endsWith('.zip')) { return launcherUploadError('请选择 ZIP 格式的发布包。') } if (file.size < 1 || file.size > LAUNCHER_MAX_UPLOAD_BYTES) { return launcherUploadError('请选择不超过 4 GiB 的完整发布 ZIP。') } const uploadId = crypto.randomUUID() let result = await skinSiteRequest('launcher/admin/uploads', { method: 'POST', body: { uploadId, filename: file.name, size: file.size, forceUpdate, }, }) const requestStatus = (path: string, method: 'GET' | 'POST' = 'GET') => skinSiteRequest(path, { method }) const chunkOnce = async (path: string, part: Blob) => { const form = new FormData() form.append('file', part, 'chunk.bin') return skinSiteRequest(path, { method: 'POST', form }) } let guard = 0 while (guard++ < 200) { if (!result.success) return result const status = result.payload onProgress?.((status.received / file.size) * 100) if (status.state === 'ready') { return { success: true, timestamp: result.timestamp, payload: status.release as SkinSiteLauncherRelease, } } if (status.state === 'failed' || status.state === 'cancelled') { return launcherUploadError(status.errorMessage || '发布包处理失败。') } if (status.state === 'uploading' && status.received < file.size) { const received = new Set(status.receivedChunks) const missing: number[] = [] for (let i = 0; i < Math.ceil(file.size / status.chunkSize); i++) { if (!received.has(i)) missing.push(i) } let cursor = 0 let uploadError: SkinSiteResponse | null = null const worker = async () => { while (!uploadError && cursor < missing.length) { const index = missing[cursor++] const start = index * status.chunkSize const part = file.slice(start, Math.min(start + status.chunkSize, file.size)) let ack = await chunkOnce('launcher/admin/uploads/' + uploadId + '/chunks/' + index, part) for (let retry = 0; retry < 2 && !ack.success; retry++) { await new Promise((r) => setTimeout(r, 400 * (retry + 1))) ack = await chunkOnce('launcher/admin/uploads/' + uploadId + '/chunks/' + index, part) } if (!ack.success) { uploadError = ack return } } } await Promise.all( Array.from( { length: Math.min( status.uploadConcurrency || 1, LAUNCHER_MAX_CONCURRENCY, missing.length || 1, ), }, worker, ), ) if (uploadError) return uploadError result = await requestStatus('launcher/admin/uploads/' + uploadId) } else if (status.state === 'uploading') { result = await requestStatus('launcher/admin/uploads/' + uploadId + '/complete', 'POST') } else { await new Promise((r) => setTimeout(r, 800)) result = await requestStatus('launcher/admin/uploads/' + uploadId) } } return launcherUploadError('上传超时,请刷新版本列表确认结果。') } export type SkinSiteDownloadStatus = 'OK' | 'NEW' | 'LEGACY' | 'OUTDATED' | 'REMOVAL' export interface SkinSiteDownloadRoute { name?: string | null url: string } export interface SkinSiteDownloadItem { id: number uploader: string url: string routes: SkinSiteDownloadRoute[] filename: string description: string status: SkinSiteDownloadStatus[] category: string[] } export interface SkinSiteDownloadCategory { id: number name: string } export interface SkinSiteDownloadItemInput { routes: SkinSiteDownloadRoute[] filename: string description: string status: SkinSiteDownloadStatus[] } export function skinSiteDownloadItems(params?: { limit?: number offset?: number category?: string }): Promise> { const query = new URLSearchParams({ limit: String(params?.limit ?? 200), offset: String(params?.offset ?? 0), }) if (params?.category?.trim()) query.set('category', params.category.trim()) return skinSiteRequest('download/list?' + query.toString()) } export function skinSiteDownloadItemCount(category?: string): Promise> { const query = new URLSearchParams() if (category?.trim()) query.set('category', category.trim()) const suffix = query.toString() ? '?' + query.toString() : '' return skinSiteRequest('download/count' + suffix) } export function skinSiteAddDownloadItem( data: SkinSiteDownloadItemInput, ): Promise> { return skinSiteRequest('download/add', { method: 'POST', body: data }) } export function skinSiteUpdateDownloadItem( id: number, data: SkinSiteDownloadItemInput, ): Promise> { return skinSiteRequest('download/update/' + id, { method: 'POST', body: data }) } export function skinSiteDeleteDownloadItem(id: number): Promise> { return skinSiteRequest('download/' + id, { method: 'DELETE' }) } export function skinSiteDownloadCategories(): Promise< SkinSiteResponse > { return skinSiteRequest('download/category/list') } export function skinSiteAddDownloadCategory(name: string): Promise> { return skinSiteRequest('download/category/add/' + encodeURIComponent(name), { method: 'POST', }) } export function skinSiteDeleteDownloadCategory(id: number): Promise> { return skinSiteRequest('download/category/' + id, { method: 'DELETE' }) } export function skinSiteBindDownloadCategory( infoId: number, categoryId: number, ): Promise> { return skinSiteRequest('download/category/bind/' + infoId + '/' + categoryId, { method: 'POST', }) } export function skinSiteUnbindDownloadCategory( infoId: number, categoryId: number, ): Promise> { return skinSiteRequest('download/category/unbind/' + infoId + '/' + categoryId, { method: 'POST', }) } export interface SkinSiteMapServer { name: string internal: boolean maps: Array<{ name: string; url: string }> } export function skinSiteMapServers(): Promise> { return skinSiteRequest('maps') } export function skinSiteSaveMapServers( servers: SkinSiteMapServer[], ): Promise> { return skinSiteRequest('maps', { method: 'PUT', body: servers }) } export interface SkinSiteAccessAuditEntry { id: number subject: string action: string sourceIp: string proxy: boolean reportedPlayerIp: string | null internalServer: boolean count: number firstAt: number lastAt: number } export interface SkinSiteAccessAuditPage { items: SkinSiteAccessAuditEntry[] nextCursor: number | null retentionDays: number recordLimit: number } export function skinSiteAccessAudit(params?: { before?: number subject?: string ip?: string action?: string }): Promise> { const query = new URLSearchParams() if (params) { for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== '') query.set(key, String(value)) } } const suffix = query.toString() ? '?' + query.toString() : '' return skinSiteRequest('admin/access-audit' + suffix) } export function skinSiteUpdateAccessAuditSettings( retentionDays: number, ): Promise> { return skinSiteRequest<{ retentionDays: number }>('admin/access-audit/settings', { method: 'PUT', body: { retentionDays }, }) } export interface SkinSiteDonationEntry { id: number rank: number username: string avatar: string | null amount: string } export interface SkinSiteDonationSummary { totalAmount: string donorCount: number } export interface SkinSiteDonationAdminEntry { donor: SkinSiteDonationEntry userUuid: string | null qq: string | null external: boolean version: number updatedAt: number } export interface SkinSiteDonationAdminPage { items: SkinSiteDonationAdminEntry[] summary: SkinSiteDonationSummary hasMore: boolean } export function skinSiteAdminDonations( offset = 0, ): Promise> { return skinSiteRequest('donation/admin/list?limit=20&offset=' + offset) } export function skinSiteFindAdminDonations(identity: { userUuid?: string qq?: string }): Promise> { const query = new URLSearchParams() if (identity.userUuid) query.set('userUuid', identity.userUuid) if (identity.qq) query.set('qq', identity.qq) return skinSiteRequest('donation/admin/find?' + query.toString()) } export interface SkinSiteDonationCreate { amount: string userUuid?: string qq?: string displayName?: string } export function skinSiteCreateDonation( data: SkinSiteDonationCreate, ): Promise> { return skinSiteRequest('donation/admin', { method: 'POST', body: data }) } export interface SkinSiteDonationUpdate { amount: string version: number displayName?: string mode?: 'REPLACE' | 'APPEND' } export function skinSiteUpdateDonation( id: number, data: SkinSiteDonationUpdate, ): Promise> { return skinSiteRequest('donation/admin/' + id, { method: 'PUT', body: data }) } export function skinSiteDeleteDonation( id: number, version: number, ): Promise> { return skinSiteRequest('donation/admin/' + id + '?version=' + version, { method: 'DELETE', }) } export type SkinSiteModLoaderType = 'FABRIC' | 'FORGE' export interface SkinSiteModMetadata { modId?: string name?: string version?: string iconBytes?: string loaderType: SkinSiteModLoaderType[] } export interface SkinSiteModInfo { domainRules: string[] hash: string name: string size: number metadata: SkinSiteModMetadata } export interface SkinSiteModRuleTag { id: number name: string rules: string[] } export function skinSiteModCount(params?: { serverAddr?: string ruleTagId?: number | null }): Promise> { const query = new URLSearchParams() const addr = params?.serverAddr?.trim() if (addr) query.set('server_addr', addr) if (params?.ruleTagId != null) query.set('rule_tag_id', String(params.ruleTagId)) const suffix = query.toString() ? '?' + query.toString() : '' return skinSiteRequest('query/mod/number' + suffix, { type: 'mod' }) } export function skinSiteModList(params: { limit: number offset: number serverAddr?: string ruleTagId?: number | null }): Promise> { return skinSiteRequest('query/mod/list', { method: 'POST', type: 'mod', body: { offset: params.offset || 0, limit: params.limit || 0, serverAddr: params.serverAddr?.trim() || undefined, ruleTagId: params.ruleTagId ?? undefined, }, }) } export function skinSiteModSearch( name: string, ruleTagId?: number | null, ): Promise> { const query = new URLSearchParams() if (ruleTagId != null) query.set('rule_tag_id', String(ruleTagId)) const suffix = query.toString() ? '?' + query.toString() : '' return skinSiteRequest( 'query/mod/search/' + encodeURIComponent(name) + suffix, { type: 'mod' }, ) } export function skinSiteUpdateModInfo(info: SkinSiteModInfo): Promise> { return skinSiteRequest('update/info', { method: 'POST', type: 'mod', body: info }) } export function skinSiteDeleteModFile(hash: string): Promise> { return skinSiteRequest(hash, { method: 'DELETE', type: 'mod' }) } export function skinSiteDeleteMultiModFile(hashes: string[]): Promise> { return skinSiteRequest('delete/multimod', { method: 'POST', type: 'mod', body: { modHashes: hashes }, }) } export function skinSiteModRuleTags(): Promise> { return skinSiteRequest('rule-tags', { type: 'mod' }) } export function skinSiteCreateModRuleTag( name: string, rules: string[], ): Promise> { return skinSiteRequest('rule-tags', { method: 'POST', type: 'mod', body: { name, rules }, }) } export function skinSiteUpdateModRuleTag( id: number, name: string, rules: string[], ): Promise> { return skinSiteRequest('rule-tags/' + id, { method: 'POST', type: 'mod', body: { name, rules }, }) } export function skinSiteDeleteModRuleTag(id: number): Promise> { return skinSiteRequest('rule-tags/' + id, { method: 'DELETE', type: 'mod' }) } export interface SkinSiteModRuntimeRelease { id: number version: string fileName: string sha256: string fileSize: number uploadedBy: string uploadedAt: number publishedAt: number | null active: boolean available: boolean downloadUrl: string } export interface SkinSiteModRuntimeAdminState { manifestUrl: string releases: SkinSiteModRuntimeRelease[] } export function skinSiteModRuntimeReleases(): Promise< SkinSiteResponse > { return skinSiteRequest('runtime/admin', { type: 'mod' }) } export function skinSiteUploadModRuntime( version: string, file: File, ): Promise> { const form = new FormData() form.append('runtime', file, file.name) return skinSiteRequest( 'runtime/admin/upload?version=' + encodeURIComponent(version), { method: 'POST', type: 'mod', form }, ) } export function skinSiteActivateModRuntime( id: number, ): Promise> { return skinSiteRequest('runtime/admin/' + id + '/activate', { method: 'POST', type: 'mod', }) } export function skinSiteDeleteModRuntime(id: number): Promise> { return skinSiteRequest('runtime/admin/' + id, { method: 'DELETE', type: 'mod' }) } export type SkinSiteWikiReadPolicy = 'PUBLIC' | 'MEMBER' export type SkinSiteWikiEditPolicy = 'MEMBER' | 'WEB_ADMIN' export interface SkinSiteWikiPageDetail { title: string revisionId: number source: string nodes: unknown[] categories: string[] updatedAt: number authorName: string summary: string readPolicy: SkinSiteWikiReadPolicy editPolicy: SkinSiteWikiEditPolicy redirectTarget?: string | null canEdit: boolean watched: boolean } export interface SkinSiteWikiSettings { domains: string[] homePage: string defaultSkin: string maxPageUtf8Bytes: number maxAttachmentBytes: number } export interface SkinSiteWikiAuditInfo { id: number actorName: string action: string target?: string | null detail?: string | null createdAt: number } export interface SkinSiteWikiAuditPage { items: SkinSiteWikiAuditInfo[] nextCursor?: number | null } export function skinSiteWikiPage(path: string): Promise> { return skinSiteRequest('wiki/page?path=' + encodeURIComponent(path)) } export function skinSiteWikiProtectPage( path: string, readPolicy: SkinSiteWikiReadPolicy, editPolicy: SkinSiteWikiEditPolicy, ): Promise> { return skinSiteRequest('wiki/admin/protect', { method: 'POST', body: { path, readPolicy, editPolicy }, }) } export function skinSiteWikiSetHomePage(path: string): Promise> { return skinSiteRequest('wiki/admin/homepage', { method: 'POST', body: { path } }) } export function skinSiteWikiSetPageDeleted( path: string, deleted: boolean, ): Promise> { return skinSiteRequest('wiki/admin/delete', { method: 'POST', body: { path, deleted }, }) } export function skinSiteWikiAudit( before?: number, ): Promise> { const suffix = before ? '?before=' + before : '' return skinSiteRequest('wiki/admin/audit' + suffix) } export function skinSiteWikiSettings(): Promise> { return skinSiteRequest('wiki/settings') } export function skinSiteWikiUpdateSettings( settings: Partial, ): Promise> { return skinSiteRequest('wiki/admin/settings', { method: 'POST', body: settings, }) } export interface SkinSiteReviewPaperSummary { id: string enabled: boolean title: string annotation?: string | null mode: SkinSiteReviewMode passScore: number targetPermissions: string[] version: number questionCount: number totalScore: number updatedAt: number prerequisiteMode: 'ANY' | 'ALL' prerequisitePermissions: string[] eligible: boolean } export interface SkinSiteReviewMailConfig { enabled: boolean subject: string message: string includeInviteCode: boolean extraText: string } export interface SkinSiteReviewConfigData { preventDuplicate?: boolean mail?: SkinSiteReviewMailConfig id: string enabled: boolean title: string annotation?: string | null mode: SkinSiteReviewMode passScore: number targetPermissions: string[] version: number questions: SkinSiteReviewQuestion[] updatedAt: number prerequisiteMode: 'ANY' | 'ALL' prerequisitePermissions: string[] } export function skinSiteReviewPaperList(): Promise< SkinSiteResponse > { return skinSiteRequest('review/admin/papers') } export function skinSiteReviewPaperAdmin( paperId: string, ): Promise> { return skinSiteRequest( 'review/admin/papers/' + encodeURIComponent(paperId), ) } export function skinSiteCreateReviewPaperAdmin( data: SkinSiteReviewConfigData, ): Promise> { return skinSiteRequest('review/admin/papers', { method: 'POST', body: data, }) } export function skinSiteUpdateReviewPaperAdmin( paperId: string, data: SkinSiteReviewConfigData, ): Promise> { return skinSiteRequest( 'review/admin/papers/' + encodeURIComponent(paperId), { method: 'PUT', body: data }, ) } export interface SkinSiteBotInfo { exists: boolean username?: string | null active: boolean creationBanned: boolean tokenHint?: string | null createdAt?: number | null updatedAt?: number | null } export interface SkinSiteUserFrequencyLimit { dropPercent?: number | null } export type SkinSiteUserAdminAuditAction = | 'USER_UPDATED' | 'USER_DELETED' | 'RATE_LIMIT_UPDATED' | 'BOT_USERNAME_UPDATED' | 'BOT_TOKEN_INVALIDATED' | 'BOT_CREATION_BANNED' | 'BOT_CREATION_UNBANNED' export interface SkinSiteUserAdminAuditChange { field: string before?: string | null after?: string | null } export interface SkinSiteUserAdminAuditInfo { id: number actorUuid: string actorName: string targetUuid: string targetName: string action: SkinSiteUserAdminAuditAction changes: SkinSiteUserAdminAuditChange[] createdAt: number } export interface SkinSiteUserAdminAuditPage { items: SkinSiteUserAdminAuditInfo[] nextCursor?: number | null } export interface SkinSiteInviteCode { email: string code: string } export function skinSiteAdminBotInfo( userUuid: string, ): Promise> { return skinSiteRequest('admin/users/' + encodeURIComponent(userUuid) + '/bot') } export function skinSiteAdminRenameBot( userUuid: string, username: string, ): Promise> { return skinSiteRequest( 'admin/users/' + encodeURIComponent(userUuid) + '/bot/username', { method: 'POST', body: { username } }, ) } export function skinSiteAdminInvalidateBotToken( userUuid: string, ): Promise> { return skinSiteRequest( 'admin/users/' + encodeURIComponent(userUuid) + '/bot/invalidate', { method: 'POST' }, ) } export function skinSiteAdminSetBotCreationBanned( userUuid: string, banned: boolean, ): Promise> { return skinSiteRequest( 'admin/users/' + encodeURIComponent(userUuid) + '/bot/ban', { method: 'POST', body: { banned } }, ) } export function skinSiteAdminUpdateUserFrequencyLimit( userUuid: string, dropPercent: number | null, ): Promise> { return skinSiteRequest( 'admin/users/' + encodeURIComponent(userUuid) + '/frequency-limit', { method: 'POST', body: { dropPercent } }, ) } export function skinSiteAdminUserAudit(params?: { targetUuid?: string before?: number }): Promise> { const query = new URLSearchParams() if (params?.targetUuid) query.set('targetUuid', params.targetUuid) if (params?.before) query.set('before', String(params.before)) const suffix = query.size > 0 ? '?' + query.toString() : '' return skinSiteRequest('admin/users/audit' + suffix) } export function skinSiteAdminInviteUser(email: string): Promise> { return skinSiteRequest('admin/inviteCode', { method: 'POST', body: { email }, }) }