Files
Starlight_Lancher/apps/app-frontend/src/helpers/skin-site-api.ts
T
AxTps fbcbb803ac
Sync source to CNB / Sync Git ref (push) Skipped
feat: 补全皮肤站管理台用户页
- 用户列表:搜索(用户名/邮箱/UUID)、分页、封禁状态、注册时间
- 编辑用户:用户名、邮箱、QQ、密码、权限组、封禁状态
- 绑定玩家:展示 Mojang / StarLight 来源与旧内服适配标记
- Bot 管理:查看状态、修改机器人用户名、使 Token 失效、封禁/解封 Bot 创建
- 频率限制:滑块与数字输入设置丢包频率,支持保存与取消
- 管理审计:按用户或全局查看,游标分页加载更早记录
- 邀请码:按邮箱生成邀请码并复制
- 补充对应 API 与类型定义,中英文各补 39 个文案
2026-10-18 11:18:55 +08:00

2018 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<T> =
| { 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<RequestType, string> = {
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<never> {
return {
success: false,
timestamp: timestamp ?? Date.now(),
errorMessage,
errorType,
errorCode,
}
}
function handleException(status: number): SkinSiteResponse<never> {
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<T>(response: Response): Promise<SkinSiteResponse<T>> {
const text = await response.text()
if (!response.ok && text.length === 0) {
return handleException(response.status)
}
let data: Record<string, unknown> | 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<T>(
path: string,
options: SendOptions = {},
): Promise<SkinSiteResponse<T>> {
const { method = 'GET', type = 'starlight', body, form, auth = true } = options
const url = `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH[type]}/${path.replace(/^\/+/, '')}`
const headers: Record<string, string> = {
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<T>(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<SkinSiteResponse<SkinSiteCaptchaRequire>> {
return skinSiteRequest<SkinSiteCaptchaRequire>('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<SkinSiteResponse<string>> {
return skinSiteRequest<string>('verifyCaptcha', { method: 'POST', body: payload, auth: false })
}
export function skinSiteLogin(payload: {
username: string
password: string
clientId: string
}): Promise<SkinSiteResponse<SkinSiteLoginResult>> {
return skinSiteRequest<SkinSiteLoginResult>('login', {
method: 'POST',
body: payload,
auth: false,
})
}
export function skinSiteRefresh(): Promise<SkinSiteResponse<SkinSiteLoginResult>> {
return skinSiteRequest<SkinSiteLoginResult>('user/refresh')
}
export function skinSiteCurrentUser(): Promise<SkinSiteResponse<SkinSiteUser>> {
return skinSiteRequest<SkinSiteUser>('user')
}
export function skinSiteLuck(): Promise<SkinSiteResponse<{ luck: number }>> {
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<SkinSiteResponse<SkinSitePlayerInfo[]>> {
return skinSiteRequest<SkinSitePlayerInfo[]>('player', { type: 'skin' })
}
/** Fetch the skin/cape/elytra textures for a single player. */
export function skinSitePlayerSkin(uuid: string): Promise<SkinSiteResponse<SkinSitePlayerSkin>> {
return skinSiteRequest<SkinSitePlayerSkin>(`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<string, string> = {},
): Promise<SkinSiteResponse<string>> {
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<string, string> = {}
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<string>(response)
}
/** Remove a texture from a player. */
export function skinSiteDeleteTexture(
uuid: string,
type: SkinTextureType,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`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<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`player/add/${encodeURIComponent(playerName)}`, {
method: 'POST',
type: 'skin',
})
}
export function skinSiteDeletePlayer(uuid: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`player/${encodeURIComponent(uuid)}`, {
method: 'DELETE',
type: 'skin',
})
}
export function skinSiteRenamePlayer(
uuid: string,
newName: string,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(
`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<SkinSiteResponse<string>> {
return skinSiteRequest<string>('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<SkinSiteResponse<SkinSiteLoginResult>> {
return skinSiteRequest<SkinSiteLoginResult>('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<SkinSiteResponse<string>> {
return skinSiteRequest<string>('password/emailVerify', {
method: 'POST',
body: { email },
auth: false,
})
}
export function skinSitePasswordReset(payload: {
clientId: string
email: string
emailVerifyCode: string
newPassword: string
}): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('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<SkinSitePublicDonationSummary>
> {
return skinSiteRequest<SkinSitePublicDonationSummary>('donation/summary', { auth: false })
}
export function skinSiteDonationList(offset = 0): Promise<SkinSiteResponse<SkinSiteDonationPage>> {
return skinSiteRequest<SkinSiteDonationPage>(`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<SkinSiteResponse<SkinSiteAnnouncement[]>> {
return skinSiteRequest<SkinSiteAnnouncement[]>('broadcast/all', { type: 'skin' })
}
// ---------------------------------------------------------------------------
// Account updates (requires the JWT)
// ---------------------------------------------------------------------------
export function skinSiteChangeUsername(newUsername: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('update/username', {
method: 'POST',
body: { newUsername },
})
}
export function skinSiteChangePassword(
oldPassword: string,
newPassword: string,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('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<SkinSiteResponse<string>> {
return skinSiteRequest<string>('update/yggdrasilPassword', {
method: 'POST',
body: { newPassword },
})
}
/** Upload a new avatar image (multipart). */
export async function skinSiteUploadAvatar(blob: Blob): Promise<SkinSiteResponse<string>> {
const form = new FormData()
form.append('avatar', blob)
const url = `${SKIN_SITE_ORIGIN}/starlight/update/avatar`
const headers: Record<string, string> = {}
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<string>(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<SkinSiteResponse<SkinSitePublicDownloadItem[]>> {
const query = new URLSearchParams({
limit: String(options?.limit ?? 200),
offset: String(options?.offset ?? 0),
})
if (options?.category) query.set('category', options.category)
return skinSiteRequest<SkinSitePublicDownloadItem[]>(`download/list?${query.toString()}`, {
auth: false,
})
}
export function skinSitePublicDownloadCount(category?: string): Promise<SkinSiteResponse<number>> {
const suffix = category ? `?category=${encodeURIComponent(category)}` : ''
return skinSiteRequest<number>(`download/count${suffix}`, { auth: false })
}
export function skinSitePublicDownloadCategories(): Promise<
SkinSiteResponse<SkinSitePublicDownloadCategory[]>
> {
return skinSiteRequest<SkinSitePublicDownloadCategory[]>('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<SkinSiteResponse<SkinSitePublicMapServer[]>> {
return skinSiteRequest<SkinSitePublicMapServer[]>('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<SkinSiteResponse<SkinSiteReviewCatalog>> {
return skinSiteRequest<SkinSiteReviewCatalog>('review/public', { auth: false })
}
export function skinSiteReviewPaper(
paperId: string,
): Promise<SkinSiteResponse<SkinSiteReviewPaper>> {
return skinSiteRequest<SkinSiteReviewPaper>(`review/public/${encodeURIComponent(paperId)}`, {
auth: false,
})
}
export interface SkinSiteReviewSubmitData {
paperId: string
email: string
answers: Record<string, unknown>
inviteCode?: string
[key: string]: unknown
}
export function skinSiteSubmitReview(
data: SkinSiteReviewSubmitData,
): Promise<SkinSiteResponse<SkinSiteReviewReceipt>> {
return skinSiteRequest<SkinSiteReviewReceipt>('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<SkinSiteResponse<SkinSiteBugIssuePage>> {
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<SkinSiteBugIssuePage>(`bug?${query.toString()}`)
}
export function skinSiteBugIssue(id: number): Promise<SkinSiteResponse<SkinSiteBugIssue>> {
return skinSiteRequest<SkinSiteBugIssue>(`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<SkinSiteResponse<SkinSiteBugCommentPage>> {
return skinSiteRequest<SkinSiteBugCommentPage>(
`bug/${issueId}/comments?offset=${offset}&limit=${limit}`,
)
}
export function skinSiteCreateBugComment(
issueId: number,
content: string,
images: File[] = [],
): Promise<SkinSiteResponse<SkinSiteBugComment>> {
const form = new FormData()
form.append('payload', JSON.stringify({ content }))
for (const image of images) form.append('images', image)
return skinSiteRequest<SkinSiteBugComment>(`bug/${issueId}/comments`, {
method: 'POST',
form,
})
}
export function skinSiteDeleteBugComment(id: number): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`bug/comments/${id}`, { method: 'DELETE' })
}
export function skinSiteBugUnreadCount(): Promise<SkinSiteResponse<number>> {
return skinSiteRequest<number>('bug/notifications/count')
}
export function skinSiteMarkBugNotificationsRead(): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('bug/notifications/read', { method: 'POST' })
}
export interface SkinSiteBugCreateRequest {
type: SkinSiteBugIssueType
title: string
description: string
private?: boolean
}
export function skinSiteCreateBugIssue(
request: SkinSiteBugCreateRequest,
images: File[] = [],
): Promise<SkinSiteResponse<SkinSiteBugIssue>> {
const form = new FormData()
form.append('payload', JSON.stringify(request))
for (const image of images) form.append('images', image)
return skinSiteRequest<SkinSiteBugIssue>('bug', { method: 'POST', form })
}
export interface SkinSiteBugNotification {
id: number
issueId: number
actorName: string
createdAt: number
}
export function skinSiteBugNotifications(): Promise<SkinSiteResponse<SkinSiteBugNotification[]>> {
return skinSiteRequest<SkinSiteBugNotification[]>('bug/notifications')
}
export function skinSiteMarkBugNotificationRead(
issueId?: number,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('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<SkinSiteResponse<SkinSiteAdminStatus>> {
return skinSiteRequest<SkinSiteAdminStatus>('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<SkinSiteResponse<SkinSiteBroadcast[]>> {
return skinSiteRequest<SkinSiteBroadcast[]>('broadcast/all/complex', { type: 'skin' })
}
export function skinSiteCreateBroadcast(
input: SkinSiteBroadcastInput,
): Promise<SkinSiteResponse<string>> {
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<string>('broadcast', { method: 'PUT', type: 'skin', form })
}
export function skinSiteUpdateBroadcast(
id: number,
input: SkinSiteBroadcastInput,
): Promise<SkinSiteResponse<string>> {
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<string>('broadcast/update/' + id, {
method: 'PUT',
type: 'skin',
form,
})
}
export function skinSiteDeleteBroadcast(id: number): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('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<SkinSiteResponse<number>> {
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<number>('admin/players/number' + suffix, { type: 'skin' })
}
export function skinSiteAdminPlayerList(
query: SkinSiteAdminPlayerQuery,
): Promise<SkinSiteResponse<SkinSiteAdminPlayer[]>> {
return skinSiteRequest<SkinSiteAdminPlayer[]>('admin/players/list', {
method: 'POST',
type: 'skin',
body: query,
})
}
export function skinSiteAdminUpdatePlayer(input: {
uuid: string
name: string
isMojang: boolean
}): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('admin/update', {
method: 'POST',
type: 'skin',
body: input,
})
}
export function skinSiteAdminDeletePlayer(uuid: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('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<SkinSiteResponse<number>> {
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<number>('admin/users/number' + suffix)
}
export function skinSiteAdminUserList(query: {
limit: number
offset: number
searchField?: string
keyword?: string
}): Promise<SkinSiteResponse<SkinSiteAdminUser[]>> {
return skinSiteRequest<SkinSiteAdminUser[]>('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<SkinSiteResponse<string>> {
return skinSiteRequest<string>('admin/update', { method: 'POST', body: input })
}
export function skinSiteAdminDeleteUser(uuid: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('admin/delete/' + encodeURIComponent(uuid), {
method: 'POST',
})
}
export function skinSiteAdminUserPlayers(
uuid: string,
): Promise<SkinSiteResponse<SkinSiteAdminPlayer[]>> {
return skinSiteRequest<SkinSiteAdminPlayer[]>(
'admin/users/' + encodeURIComponent(uuid) + '/players',
)
}
export interface SkinSiteFrequencyLimit {
dropPercent?: number | null
}
export function skinSiteAdminUserFrequencyLimit(
uuid: string,
): Promise<SkinSiteResponse<SkinSiteFrequencyLimit>> {
return skinSiteRequest<SkinSiteFrequencyLimit>(
'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<string, boolean>
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<SkinSiteResponse<SkinSiteReviewSubmission[]>> {
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<SkinSiteReviewSubmission[]>('review/admin/submissions?' + query.toString())
}
export function skinSiteReviewSubmissionCount(params?: {
result?: SkinSiteReviewResult | 'COMPLETED'
paperId?: string
}): Promise<SkinSiteResponse<number>> {
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<number>('review/admin/submissions/count' + suffix)
}
export function skinSiteDecideReviewSubmission(
id: number,
accepted: boolean,
manualScores: Record<string, number> = {},
expectedRevision?: number,
reason = '',
): Promise<SkinSiteResponse<SkinSiteReviewSubmission>> {
const body: Record<string, unknown> = { accepted, manualScores, reason }
if (expectedRevision !== undefined) body.expectedRevision = expectedRevision
return skinSiteRequest<SkinSiteReviewSubmission>('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<SkinSiteResponse<SkinSiteReviewInviteDelivery>> {
return skinSiteRequest<SkinSiteReviewInviteDelivery>(
'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<SkinSiteLauncherAdminState>
> {
return skinSiteRequest<SkinSiteLauncherAdminState>('launcher/admin')
}
export function skinSiteActivateLauncherRelease(
id: number,
): Promise<SkinSiteResponse<SkinSiteLauncherRelease>> {
return skinSiteRequest<SkinSiteLauncherRelease>('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<never> {
return { success: false, timestamp: Date.now(), errorCode: code, errorMessage: message }
}
export async function skinSiteUploadLauncherRelease(
file: File,
forceUpdate: boolean,
onProgress?: (percent: number) => void,
): Promise<SkinSiteResponse<SkinSiteLauncherRelease>> {
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<SkinSiteLauncherUploadStatus>('launcher/admin/uploads', {
method: 'POST',
body: {
uploadId,
filename: file.name,
size: file.size,
forceUpdate,
},
})
const requestStatus = (path: string, method: 'GET' | 'POST' = 'GET') =>
skinSiteRequest<SkinSiteLauncherUploadStatus>(path, { method })
const chunkOnce = async (path: string, part: Blob) => {
const form = new FormData()
form.append('file', part, 'chunk.bin')
return skinSiteRequest<SkinSiteLauncherUploadStatus>(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<never> | 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<SkinSiteResponse<SkinSiteDownloadItem[]>> {
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<SkinSiteDownloadItem[]>('download/list?' + query.toString())
}
export function skinSiteDownloadItemCount(category?: string): Promise<SkinSiteResponse<number>> {
const query = new URLSearchParams()
if (category?.trim()) query.set('category', category.trim())
const suffix = query.toString() ? '?' + query.toString() : ''
return skinSiteRequest<number>('download/count' + suffix)
}
export function skinSiteAddDownloadItem(
data: SkinSiteDownloadItemInput,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('download/add', { method: 'POST', body: data })
}
export function skinSiteUpdateDownloadItem(
id: number,
data: SkinSiteDownloadItemInput,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('download/update/' + id, { method: 'POST', body: data })
}
export function skinSiteDeleteDownloadItem(id: number): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('download/' + id, { method: 'DELETE' })
}
export function skinSiteDownloadCategories(): Promise<
SkinSiteResponse<SkinSiteDownloadCategory[]>
> {
return skinSiteRequest<SkinSiteDownloadCategory[]>('download/category/list')
}
export function skinSiteAddDownloadCategory(name: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('download/category/add/' + encodeURIComponent(name), {
method: 'POST',
})
}
export function skinSiteDeleteDownloadCategory(id: number): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('download/category/' + id, { method: 'DELETE' })
}
export function skinSiteBindDownloadCategory(
infoId: number,
categoryId: number,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('download/category/bind/' + infoId + '/' + categoryId, {
method: 'POST',
})
}
export function skinSiteUnbindDownloadCategory(
infoId: number,
categoryId: number,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('download/category/unbind/' + infoId + '/' + categoryId, {
method: 'POST',
})
}
export interface SkinSiteMapServer {
name: string
internal: boolean
maps: Array<{ name: string; url: string }>
}
export function skinSiteMapServers(): Promise<SkinSiteResponse<SkinSiteMapServer[]>> {
return skinSiteRequest<SkinSiteMapServer[]>('maps')
}
export function skinSiteSaveMapServers(
servers: SkinSiteMapServer[],
): Promise<SkinSiteResponse<SkinSiteMapServer[]>> {
return skinSiteRequest<SkinSiteMapServer[]>('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<SkinSiteResponse<SkinSiteAccessAuditPage>> {
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<SkinSiteAccessAuditPage>('admin/access-audit' + suffix)
}
export function skinSiteUpdateAccessAuditSettings(
retentionDays: number,
): Promise<SkinSiteResponse<{ retentionDays: number }>> {
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<SkinSiteResponse<SkinSiteDonationAdminPage>> {
return skinSiteRequest<SkinSiteDonationAdminPage>('donation/admin/list?limit=20&offset=' + offset)
}
export function skinSiteFindAdminDonations(identity: {
userUuid?: string
qq?: string
}): Promise<SkinSiteResponse<SkinSiteDonationAdminEntry[]>> {
const query = new URLSearchParams()
if (identity.userUuid) query.set('userUuid', identity.userUuid)
if (identity.qq) query.set('qq', identity.qq)
return skinSiteRequest<SkinSiteDonationAdminEntry[]>('donation/admin/find?' + query.toString())
}
export interface SkinSiteDonationCreate {
amount: string
userUuid?: string
qq?: string
displayName?: string
}
export function skinSiteCreateDonation(
data: SkinSiteDonationCreate,
): Promise<SkinSiteResponse<number>> {
return skinSiteRequest<number>('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<SkinSiteResponse<string>> {
return skinSiteRequest<string>('donation/admin/' + id, { method: 'PUT', body: data })
}
export function skinSiteDeleteDonation(
id: number,
version: number,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('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<SkinSiteResponse<number>> {
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<number>('query/mod/number' + suffix, { type: 'mod' })
}
export function skinSiteModList(params: {
limit: number
offset: number
serverAddr?: string
ruleTagId?: number | null
}): Promise<SkinSiteResponse<SkinSiteModInfo[]>> {
return skinSiteRequest<SkinSiteModInfo[]>('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<SkinSiteResponse<SkinSiteModInfo[]>> {
const query = new URLSearchParams()
if (ruleTagId != null) query.set('rule_tag_id', String(ruleTagId))
const suffix = query.toString() ? '?' + query.toString() : ''
return skinSiteRequest<SkinSiteModInfo[]>(
'query/mod/search/' + encodeURIComponent(name) + suffix,
{ type: 'mod' },
)
}
export function skinSiteUpdateModInfo(info: SkinSiteModInfo): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('update/info', { method: 'POST', type: 'mod', body: info })
}
export function skinSiteDeleteModFile(hash: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(hash, { method: 'DELETE', type: 'mod' })
}
export function skinSiteDeleteMultiModFile(hashes: string[]): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('delete/multimod', {
method: 'POST',
type: 'mod',
body: { modHashes: hashes },
})
}
export function skinSiteModRuleTags(): Promise<SkinSiteResponse<SkinSiteModRuleTag[]>> {
return skinSiteRequest<SkinSiteModRuleTag[]>('rule-tags', { type: 'mod' })
}
export function skinSiteCreateModRuleTag(
name: string,
rules: string[],
): Promise<SkinSiteResponse<SkinSiteModRuleTag>> {
return skinSiteRequest<SkinSiteModRuleTag>('rule-tags', {
method: 'POST',
type: 'mod',
body: { name, rules },
})
}
export function skinSiteUpdateModRuleTag(
id: number,
name: string,
rules: string[],
): Promise<SkinSiteResponse<SkinSiteModRuleTag>> {
return skinSiteRequest<SkinSiteModRuleTag>('rule-tags/' + id, {
method: 'POST',
type: 'mod',
body: { name, rules },
})
}
export function skinSiteDeleteModRuleTag(id: number): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('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<SkinSiteModRuntimeAdminState>
> {
return skinSiteRequest<SkinSiteModRuntimeAdminState>('runtime/admin', { type: 'mod' })
}
export function skinSiteUploadModRuntime(
version: string,
file: File,
): Promise<SkinSiteResponse<SkinSiteModRuntimeRelease>> {
const form = new FormData()
form.append('runtime', file, file.name)
return skinSiteRequest<SkinSiteModRuntimeRelease>(
'runtime/admin/upload?version=' + encodeURIComponent(version),
{ method: 'POST', type: 'mod', form },
)
}
export function skinSiteActivateModRuntime(
id: number,
): Promise<SkinSiteResponse<SkinSiteModRuntimeRelease>> {
return skinSiteRequest<SkinSiteModRuntimeRelease>('runtime/admin/' + id + '/activate', {
method: 'POST',
type: 'mod',
})
}
export function skinSiteDeleteModRuntime(id: number): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('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<SkinSiteResponse<SkinSiteWikiPageDetail>> {
return skinSiteRequest<SkinSiteWikiPageDetail>('wiki/page?path=' + encodeURIComponent(path))
}
export function skinSiteWikiProtectPage(
path: string,
readPolicy: SkinSiteWikiReadPolicy,
editPolicy: SkinSiteWikiEditPolicy,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('wiki/admin/protect', {
method: 'POST',
body: { path, readPolicy, editPolicy },
})
}
export function skinSiteWikiSetHomePage(path: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('wiki/admin/homepage', { method: 'POST', body: { path } })
}
export function skinSiteWikiSetPageDeleted(
path: string,
deleted: boolean,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('wiki/admin/delete', {
method: 'POST',
body: { path, deleted },
})
}
export function skinSiteWikiAudit(
before?: number,
): Promise<SkinSiteResponse<SkinSiteWikiAuditPage>> {
const suffix = before ? '?before=' + before : ''
return skinSiteRequest<SkinSiteWikiAuditPage>('wiki/admin/audit' + suffix)
}
export function skinSiteWikiSettings(): Promise<SkinSiteResponse<SkinSiteWikiSettings>> {
return skinSiteRequest<SkinSiteWikiSettings>('wiki/settings')
}
export function skinSiteWikiUpdateSettings(
settings: Partial<SkinSiteWikiSettings>,
): Promise<SkinSiteResponse<SkinSiteWikiSettings>> {
return skinSiteRequest<SkinSiteWikiSettings>('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<SkinSiteReviewPaperSummary[]>
> {
return skinSiteRequest<SkinSiteReviewPaperSummary[]>('review/admin/papers')
}
export function skinSiteReviewPaperAdmin(
paperId: string,
): Promise<SkinSiteResponse<SkinSiteReviewConfigData>> {
return skinSiteRequest<SkinSiteReviewConfigData>(
'review/admin/papers/' + encodeURIComponent(paperId),
)
}
export function skinSiteCreateReviewPaperAdmin(
data: SkinSiteReviewConfigData,
): Promise<SkinSiteResponse<SkinSiteReviewConfigData>> {
return skinSiteRequest<SkinSiteReviewConfigData>('review/admin/papers', {
method: 'POST',
body: data,
})
}
export function skinSiteUpdateReviewPaperAdmin(
paperId: string,
data: SkinSiteReviewConfigData,
): Promise<SkinSiteResponse<SkinSiteReviewConfigData>> {
return skinSiteRequest<SkinSiteReviewConfigData>(
'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<SkinSiteResponse<SkinSiteBotInfo>> {
return skinSiteRequest<SkinSiteBotInfo>('admin/users/' + encodeURIComponent(userUuid) + '/bot')
}
export function skinSiteAdminRenameBot(
userUuid: string,
username: string,
): Promise<SkinSiteResponse<SkinSiteBotInfo>> {
return skinSiteRequest<SkinSiteBotInfo>(
'admin/users/' + encodeURIComponent(userUuid) + '/bot/username',
{ method: 'POST', body: { username } },
)
}
export function skinSiteAdminInvalidateBotToken(
userUuid: string,
): Promise<SkinSiteResponse<SkinSiteBotInfo>> {
return skinSiteRequest<SkinSiteBotInfo>(
'admin/users/' + encodeURIComponent(userUuid) + '/bot/invalidate',
{ method: 'POST' },
)
}
export function skinSiteAdminSetBotCreationBanned(
userUuid: string,
banned: boolean,
): Promise<SkinSiteResponse<SkinSiteBotInfo>> {
return skinSiteRequest<SkinSiteBotInfo>(
'admin/users/' + encodeURIComponent(userUuid) + '/bot/ban',
{ method: 'POST', body: { banned } },
)
}
export function skinSiteAdminUpdateUserFrequencyLimit(
userUuid: string,
dropPercent: number | null,
): Promise<SkinSiteResponse<SkinSiteUserFrequencyLimit>> {
return skinSiteRequest<SkinSiteUserFrequencyLimit>(
'admin/users/' + encodeURIComponent(userUuid) + '/frequency-limit',
{ method: 'POST', body: { dropPercent } },
)
}
export function skinSiteAdminUserAudit(params?: {
targetUuid?: string
before?: number
}): Promise<SkinSiteResponse<SkinSiteUserAdminAuditPage>> {
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<SkinSiteUserAdminAuditPage>('admin/users/audit' + suffix)
}
export function skinSiteAdminInviteUser(email: string): Promise<SkinSiteResponse<SkinSiteInviteCode>> {
return skinSiteRequest<SkinSiteInviteCode>('admin/inviteCode', {
method: 'POST',
body: { email },
})
}