Files
zpan/src/lib/api.ts
T

1265 lines
38 KiB
TypeScript

import type { OAuthProviderConfig } from '@shared/oauth-providers'
import type {
AllowedImageMime,
AnnouncementInput,
CloudCreditBalanceResponse,
CloudCreditLedgerResponse,
CloudProductInput,
CloudProductPatchInput,
ConflictStrategy,
CreateBackgroundJobRequest,
CreateDownloadTaskInput,
CreateGiftCardInput,
CreateObjectUploadSessionInput,
CreateShareRequest,
CreateStorageInput,
DownloaderHeartbeatInput,
GiftCardStatus,
PatchObjectUploadSessionInput,
PresignObjectUploadPartsInput,
RedeemGiftCardResponse,
UpdateDownloaderInput,
UpdateDownloadTaskInput,
UpdateStorageInput,
} from '@shared/schemas'
import type {
ActivityEvent,
AdminAuditEvent,
Announcement,
AuthProvider,
BackgroundJob,
BackgroundJobStatus,
BindingState,
BrandingConfig,
BrandingField,
BrandingThemeMode,
BrandingThemePresetId,
BrandingThemeValues,
CloudGiftCard,
CloudOrder,
CloudProduct,
CloudStoreSettings,
CloudStoreTarget,
Downloader,
DownloadTask,
IhostConfigResponse,
ImageHosting,
Notification,
ObjectUploadSession,
OrgQuota,
OrgQuotaEntitlement,
PaginatedResponse,
ShareListItem,
ShareView,
SiteInvitation,
Storage,
StorageObject,
} from '@shared/types'
import {
adminAnnouncementsApi,
adminAuditApi,
adminAuthProviders,
adminCloudStoreApi,
adminDownloadersApi,
adminQuotas,
adminSiteInvitations,
announcementsApi,
authedSharesApi,
authProviders,
backgroundJobsApi,
brandingAdminApi,
cloudStoreApi,
downloaderSelfApi,
downloadTasksApi,
emailConfig,
ihostApi,
ihostConfigApi,
inviteCodes,
licensingAdminApi,
licensingApi,
meApi,
notificationsApi,
objects,
profiles,
publicBrandingApi,
publicSharesApi,
publicSiteInvitations,
storages,
system,
teamsApi,
trash,
userQuotas,
users,
} from './rpc'
export type { Storage, StorageObject }
export type UserQuota = Pick<
OrgQuota,
| 'orgId'
| 'baseQuota'
| 'entitlementQuota'
| 'quota'
| 'used'
| 'baseTrafficQuota'
| 'entitlementTrafficQuota'
| 'trafficQuota'
| 'trafficUsed'
| 'trafficPeriod'
| 'storagePlanName'
| 'storageExtraNames'
| 'trafficPlanName'
| 'trafficExtraNames'
| 'currentPlan'
>
export interface ApiErrorBody {
error?: string
code?: string
[key: string]: unknown
}
export class ApiError extends Error {
readonly status: number
readonly body: ApiErrorBody
constructor(status: number, body: ApiErrorBody) {
super(body.error ?? `HTTP ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
}
export interface NameConflictBody extends ApiErrorBody {
code: 'NAME_CONFLICT'
conflictingName: string
conflictingId: string
}
export function isNameConflictError(err: unknown): err is ApiError & { body: NameConflictBody } {
return err instanceof ApiError && err.status === 409 && err.body.code === 'NAME_CONFLICT'
}
async function unwrap<T>(promise: Promise<Response>): Promise<T> {
const res = await promise
if (!res.ok) {
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
const body: ApiErrorBody = { ...parsed, error: parsed.error ?? res.statusText }
throw new ApiError(res.status, body)
}
return res.json() as Promise<T>
}
// Objects API
export function listObjects(parent: string, status = 'active', page = 1, pageSize = 500) {
return unwrap<PaginatedResponse<StorageObject>>(
objects.index.$get({ query: { parent, status, page: String(page), pageSize: String(pageSize) } }),
)
}
export function listObjectsByPath(
path: string,
status = 'active',
page = 1,
pageSize = 500,
opts?: { type?: string; search?: string },
) {
const query: Record<string, string> = { path, status, page: String(page), pageSize: String(pageSize) }
if (opts?.type) query.type = opts.type
if (opts?.search) query.search = opts.search
return unwrap<PaginatedResponse<StorageObject>>(objects.index.$get({ query }))
}
export function getObject(id: string) {
return unwrap<StorageObject & { downloadUrl?: string }>(objects[':id'].$get({ param: { id } }))
}
export interface CreateObjectResult extends StorageObject {
uploadUrl?: string
contentDisposition?: string
}
export function createObject(data: {
name: string
type: string
size?: number
parent: string
dirtype: number
onConflict?: ConflictStrategy
}) {
return unwrap<CreateObjectResult>(objects.index.$post({ json: data }))
}
export function updateObject(id: string, data: { name?: string; parent?: string; onConflict?: ConflictStrategy }) {
return unwrap<StorageObject>(objects[':id'].$patch({ param: { id }, json: { action: 'update' as const, ...data } }))
}
export function confirmUpload(id: string, onConflict?: ConflictStrategy) {
return unwrap<StorageObject>(
objects[':id'].$patch({ param: { id }, json: { action: 'confirm' as const, onConflict } }),
)
}
export function cancelUpload(id: string) {
return unwrap<{ id: string; cancelled: boolean }>(
objects[':id'].$patch({ param: { id }, json: { action: 'cancel' as const } }),
)
}
export function deleteObject(id: string) {
return unwrap<{ id: string; deleted: boolean; purged?: number }>(objects[':id'].$delete({ param: { id } }))
}
export function copyObject(id: string, parent: string, onConflict?: ConflictStrategy) {
return unwrap<StorageObject>(objects.copy.$post({ json: { copyFrom: id, parent, onConflict } }))
}
export function trashObject(id: string) {
return unwrap<StorageObject>(objects[':id'].$patch({ param: { id }, json: { action: 'trash' as const } }))
}
export function restoreObject(id: string, onConflict?: ConflictStrategy) {
return unwrap<StorageObject>(
objects[':id'].$patch({ param: { id }, json: { action: 'restore' as const, onConflict } }),
)
}
export function batchMoveObjects(ids: string[], parent: string, onConflict?: ConflictStrategy) {
return unwrap<{ moved: number }>(objects.batch.$patch({ json: { action: 'move' as const, ids, parent, onConflict } }))
}
export function batchTrashObjects(ids: string[]) {
return unwrap<{ trashed: number }>(objects.batch.$patch({ json: { action: 'trash' as const, ids } }))
}
export function batchDeleteObjects(ids: string[]) {
return unwrap<{ deleted: number }>(objects.batch.$delete({ json: { ids } }))
}
export function emptyTrash() {
return unwrap<{ purged: number }>(trash.index.$delete())
}
export function createObjectUploadSession(id: string, data: CreateObjectUploadSessionInput) {
return unwrap<ObjectUploadSession & { object: StorageObject }>(
objects[':id'].uploads.$post({ param: { id }, json: data }),
)
}
export function presignObjectUploadParts(id: string, uploadSessionId: string, data: PresignObjectUploadPartsInput) {
return unwrap<{ parts: Array<{ partNumber: number; uploadUrl: string }> }>(
objects[':id'].uploads[':uploadSessionId'].parts.$post({
param: { id, uploadSessionId },
json: data,
}),
)
}
export function patchObjectUploadSession(id: string, uploadSessionId: string, data: PatchObjectUploadSessionInput) {
return unwrap<ObjectUploadSession & { object?: StorageObject }>(
objects[':id'].uploads[':uploadSessionId'].$patch({
param: { id, uploadSessionId },
json: data,
}),
)
}
// Remote Download API
export interface ListDownloadTasksOptions {
status?: string
assignedTo?: 'me'
page?: number
pageSize?: number
}
export function listDownloadTasks(opts: ListDownloadTasksOptions = {}) {
const query: Record<string, string> = {
page: String(opts.page ?? 1),
pageSize: String(opts.pageSize ?? 50),
}
if (opts.status) query.status = opts.status
if (opts.assignedTo) query.assignedTo = opts.assignedTo
return unwrap<PaginatedResponse<DownloadTask>>(downloadTasksApi.index.$get({ query }))
}
export function createDownloadTask(data: CreateDownloadTaskInput) {
return unwrap<DownloadTask>(downloadTasksApi.index.$post({ json: data }))
}
export function updateDownloadTask(id: string, data: UpdateDownloadTaskInput) {
return unwrap<DownloadTask>(downloadTasksApi[':id'].$patch({ param: { id }, json: data }))
}
export function listDownloaders() {
return unwrap<PaginatedResponse<Downloader>>(adminDownloadersApi.index.$get())
}
export function updateDownloader(id: string, data: UpdateDownloaderInput) {
return unwrap<Downloader>(adminDownloadersApi[':id'].$patch({ param: { id }, json: data }))
}
export function deleteDownloader(id: string) {
return unwrap<{ id: string; deleted: true }>(adminDownloadersApi[':id'].$delete({ param: { id } }))
}
export function sendDownloaderHeartbeat(data: DownloaderHeartbeatInput) {
return unwrap<Downloader>(downloaderSelfApi.heartbeat.$post({ json: data }))
}
// Background Jobs API
export interface ListBackgroundJobsOptions {
status?: BackgroundJobStatus
type?: string
page?: number
pageSize?: number
}
export function listBackgroundJobs(opts: ListBackgroundJobsOptions = {}) {
const query: Record<string, string> = {
page: String(opts.page ?? 1),
pageSize: String(opts.pageSize ?? 20),
}
if (opts.status) query.status = opts.status
if (opts.type) query.type = opts.type
return unwrap<PaginatedResponse<BackgroundJob>>(backgroundJobsApi.index.$get({ query }))
}
export function createBackgroundJob(data: CreateBackgroundJobRequest) {
return unwrap<BackgroundJob>(backgroundJobsApi.index.$post({ json: data }))
}
export function getBackgroundJob(id: string) {
return unwrap<BackgroundJob>(backgroundJobsApi[':id'].$get({ param: { id } }))
}
export function cancelBackgroundJob(id: string) {
return unwrap<BackgroundJob>(backgroundJobsApi[':id'].cancel.$post({ param: { id } }))
}
export function retryBackgroundJob(id: string) {
return unwrap<BackgroundJob>(backgroundJobsApi[':id'].retry.$post({ param: { id } }))
}
// Admin Storages API
export function listStorages() {
return unwrap<{ items: Storage[]; total: number }>(storages.index.$get())
}
export function createStorage(data: CreateStorageInput) {
return unwrap<Storage>(storages.index.$post({ json: data }))
}
export function getStorage(id: string) {
return unwrap<Storage>(storages[':id'].$get({ param: { id } }))
}
export function updateStorage(id: string, data: UpdateStorageInput) {
return unwrap<Storage>(storages[':id'].$put({ param: { id }, json: data }))
}
export function deleteStorage(id: string) {
return unwrap<{ id: string; deleted: boolean }>(storages[':id'].$delete({ param: { id } }))
}
// Admin Users API
export interface UserWithOrg {
id: string
name: string
username: string
email: string
image: string | null
role: string | null
banned: boolean
createdAt: number
orgId: string | null
orgName: string | null
quotaUsed: number
quotaDefault: number
quotaTotal: number
}
export type UserEntitlementsResponse = { orgId: string; items: OrgQuotaEntitlement[] }
export function listUsers(page: number, pageSize: number, search?: string) {
const query: Record<string, string> = { page: String(page), pageSize: String(pageSize) }
if (search?.trim()) query.search = search.trim()
return unwrap<{ items: UserWithOrg[]; total: number }>(users.index.$get({ query }))
}
export function getUser(userId: string) {
return unwrap<UserWithOrg>(users[':id'].$get({ param: { id: userId } }))
}
export function updateUserStatus(userId: string, status: 'active' | 'disabled') {
return unwrap<{ id: string; status: string }>(users[':id'].$patch({ param: { id: userId }, json: { status } }))
}
export function deleteUser(userId: string) {
return unwrap<{ id: string; deleted: boolean }>(users[':id'].$delete({ param: { id: userId } }))
}
export function batchUpdateUserStatus(ids: string[], status: 'active' | 'disabled') {
const action = status === 'disabled' ? 'disable' : 'enable'
return unwrap<{ updated: number; ids: string[]; status: string }>(users.batch.$patch({ json: { action, ids } }))
}
export function batchDeleteUsers(ids: string[]) {
return unwrap<{ deleted: number; ids: string[] }>(users.batch.$delete({ json: { ids } }))
}
export function listUserEntitlements(userId: string) {
return unwrap<UserEntitlementsResponse>(users[':id'].entitlements.$get({ param: { id: userId } }))
}
export function grantUserEntitlement(
userId: string,
data: { resourceType: 'storage'; bytes: number; expiresAt?: string | null; note?: string | null },
) {
return unwrap<{ orgId: string; entitlement: OrgQuotaEntitlement }>(
users[':id'].entitlements.$post({ param: { id: userId }, json: data }),
)
}
// Admin Quotas API
export type QuotaItem = Pick<
OrgQuota,
| 'orgId'
| 'baseQuota'
| 'entitlementQuota'
| 'quota'
| 'used'
| 'baseTrafficQuota'
| 'entitlementTrafficQuota'
| 'trafficQuota'
| 'trafficUsed'
| 'trafficPeriod'
> & {
orgName?: string
orgType?: string
}
export function listQuotas() {
return unwrap<{ items: QuotaItem[]; total: number }>(adminQuotas.index.$get())
}
// User Quotas API
export function getUserQuota() {
return unwrap<UserQuota>(userQuotas.me.$get())
}
// Quota Store API
export function getCloudStoreSettings() {
return unwrap<CloudStoreSettings | null>(adminCloudStoreApi.settings.$get())
}
export function updateCloudStoreSettings(data: { enabled: boolean }) {
return unwrap<CloudStoreSettings>(adminCloudStoreApi.settings.$put({ json: data }))
}
export function listAdminCloudProducts() {
return unwrap<{ items: CloudProduct[]; total: number }>(adminCloudStoreApi.packages.$get())
}
export function listAdminCloudCreditProducts() {
return unwrap<{ items: CloudProduct[]; total: number }>(adminCloudStoreApi.credits.products.$get())
}
export function createCloudProduct(data: CloudProductInput) {
return unwrap<CloudProduct>(adminCloudStoreApi.packages.$post({ json: data }))
}
export function updateCloudProduct(id: string, data: CloudProductPatchInput) {
return unwrap<CloudProduct>(adminCloudStoreApi.packages[':id'].$patch({ param: { id }, json: data }))
}
export function deleteCloudProduct(id: string) {
return unwrap<{ id: string; deleted: boolean }>(adminCloudStoreApi.packages[':id'].$delete({ param: { id } }))
}
export function listCloudGiftCards(status?: GiftCardStatus) {
const query = status ? { status } : {}
return unwrap<{ items: CloudGiftCard[]; total: number }>(adminCloudStoreApi['gift-cards'].$get({ query }))
}
export function createCloudGiftCards(data: CreateGiftCardInput) {
return unwrap<CloudGiftCard[]>(adminCloudStoreApi['gift-cards'].$post({ json: data }))
}
export function disableCloudGiftCard(code: string) {
return unwrap<{ code: string; disabled: true }>(
adminCloudStoreApi['gift-cards'][':code'].$patch({
param: { code },
json: { disabled: true },
}),
)
}
export function deleteCloudGiftCard(code: string) {
return unwrap<{ code: string; deleted: boolean }>(
adminCloudStoreApi['gift-cards'][':code'].$delete({ param: { code } }),
)
}
export function listAdminCloudOrders(options: { limit?: number; offset?: number } = {}) {
const query = {
...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
...(options.offset !== undefined ? { offset: String(options.offset) } : {}),
}
return unwrap<{ items: CloudOrder[]; total: number }>(adminCloudStoreApi.orders.$get({ query }))
}
export function listCloudProducts() {
return unwrap<{ items: CloudProduct[]; total: number }>(cloudStoreApi.packages.$get())
}
export function listCloudCreditProducts() {
return unwrap<{ items: CloudProduct[]; total: number }>(cloudStoreApi.credits.products.$get())
}
export function listCloudStoreTargets() {
return unwrap<{ items: CloudStoreTarget[]; total: number }>(cloudStoreApi.targets.$get())
}
export function getCloudCredits() {
return unwrap<CloudCreditBalanceResponse>(cloudStoreApi.credits.$get())
}
export function listCloudCreditLedgerEntries() {
return unwrap<CloudCreditLedgerResponse>(cloudStoreApi.credits['ledger-entries'].$get())
}
export function redeemCloudGiftCard(code: string) {
return unwrap<RedeemGiftCardResponse>(cloudStoreApi.credits.redemptions.$post({ json: { code } }))
}
export function createCloudCheckout(packageId: string, priceId?: string) {
return unwrap<{ orderId: string; url: string; paymentId?: string }>(
cloudStoreApi.checkouts.$post({ json: { packageId, priceId } }),
)
}
export function createCloudBillingPortalSession() {
return unwrap<{ url: string; stripeSubscriptionId: string }>(cloudStoreApi['billing-portal-sessions'].$post())
}
export function continueCloudOrderPayment(orderId: string) {
return unwrap<{ orderId: string; url: string; paymentId?: string }>(
cloudStoreApi.orders[':orderId'].payments.$post({ param: { orderId } }),
)
}
export function cancelCloudOrder(orderId: string) {
return unwrap<CloudOrder>(
cloudStoreApi.orders[':orderId'].$patch({ param: { orderId }, json: { status: 'canceled' } }),
)
}
export function listCloudOrders(options: { limit?: number; offset?: number } = {}) {
const query = {
...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
...(options.offset !== undefined ? { offset: String(options.offset) } : {}),
}
return unwrap<{ items: CloudOrder[]; total: number }>(cloudStoreApi.orders.$get({ query }))
}
// System Options API
export interface SiteOption {
key: string
value: string
public: boolean
}
export function listSystemOptions() {
return unwrap<{ items: SiteOption[]; total: number }>(system.options.$get())
}
export function getSystemOption(key: string) {
return unwrap<SiteOption>(system.options[':key'].$get({ param: { key } }))
}
export function setSystemOption(key: string, value: string, isPublic?: boolean) {
const body: { value: string; public?: boolean } = { value }
if (isPublic !== undefined) body.public = isPublic
return unwrap<SiteOption>(system.options[':key'].$put({ param: { key }, json: body }))
}
// Auth Providers API
export type { AuthProvider }
export function listAuthProviders() {
return unwrap<{ items: AuthProvider[] }>(authProviders.index.$get())
}
export function listAdminAuthProviders() {
return unwrap<{ items: OAuthProviderConfig[] }>(adminAuthProviders.index.$get())
}
export function upsertAuthProvider(providerId: string, data: Omit<OAuthProviderConfig, 'providerId'>) {
return unwrap<OAuthProviderConfig>(adminAuthProviders[':providerId'].$put({ param: { providerId }, json: data }))
}
export function deleteAuthProvider(providerId: string) {
return unwrap<{ providerId: string; deleted: boolean }>(
adminAuthProviders[':providerId'].$delete({ param: { providerId } }),
)
}
// Invite Codes API
export interface InviteCode {
id: string
code: string
createdBy: string
usedBy: string | null
usedAt: string | null
expiresAt: string | null
createdAt: string
}
export function listInviteCodes(page = 1, pageSize = 20) {
return unwrap<{ items: InviteCode[]; total: number }>(
inviteCodes.index.$get({ query: { page: String(page), pageSize: String(pageSize) } }),
)
}
export function generateInviteCodes(count: number, expiresInDays?: number) {
const body: { count: number; expiresInDays?: number } = { count }
if (expiresInDays !== undefined) body.expiresInDays = expiresInDays
return unwrap<{ codes: InviteCode[] }>(inviteCodes.index.$post({ json: body }))
}
export function deleteInviteCode(id: string) {
return unwrap<{ id: string; deleted: boolean }>(inviteCodes[':id'].$delete({ param: { id } }))
}
// Site Invitations API
export function listSiteInvitations(page = 1, pageSize = 20) {
return unwrap<{ items: SiteInvitation[]; total: number }>(
adminSiteInvitations.index.$get({ query: { page: String(page), pageSize: String(pageSize) } }),
)
}
export function createSiteInvitation(email: string) {
return unwrap<SiteInvitation>(adminSiteInvitations.index.$post({ json: { email } }))
}
export function resendSiteInvitation(id: string) {
return unwrap<SiteInvitation>(adminSiteInvitations[':id'].resend.$post({ param: { id } }))
}
export function revokeSiteInvitation(id: string) {
return unwrap<{ id: string; revoked: boolean }>(adminSiteInvitations[':id'].$delete({ param: { id } }))
}
export function getSiteInvitation(token: string) {
return unwrap<SiteInvitation>(publicSiteInvitations[':token'].$get({ param: { token } }))
}
// Email Config API
export interface SmtpEmailConfig {
enabled: boolean
provider: 'smtp'
from: string
smtp: { host: string; port: number; user: string; pass: string; secure: boolean }
}
export interface HttpEmailConfig {
enabled: boolean
provider: 'http'
from: string
http: { url: string; apiKey: string }
}
export interface CloudflareEmailConfig {
enabled: boolean
provider: 'cloudflare'
from: string
}
export type EmailConfigData = SmtpEmailConfig | HttpEmailConfig | CloudflareEmailConfig
export interface EmptyEmailConfigData {
enabled: boolean
provider: null
}
export function getEmailConfig() {
return unwrap<EmailConfigData | EmptyEmailConfigData>(emailConfig.index.$get())
}
export function saveEmailConfig(data: EmailConfigData) {
return unwrap<{ success: boolean }>(emailConfig.index.$put({ json: data }))
}
export function testEmail(to: string) {
return unwrap<{ success: boolean; error?: string }>(emailConfig['test-messages'].$post({ json: { to } }))
}
// Profile API (public, no auth)
export interface PublicUser {
username: string
name: string
image: string | null
}
export interface PublicMatter extends StorageObject {
downloadUrl?: string
}
export function getProfile(username: string) {
return unwrap<{ user: PublicUser; shares: PublicMatter[] }>(profiles[':username'].$get({ param: { username } }))
}
// Teams Activity API
export function listTeamActivities(teamId: string, page = 1, pageSize = 20) {
return unwrap<PaginatedResponse<ActivityEvent>>(
teamsApi[':teamId'].activity.$get({ param: { teamId }, query: { page: String(page), pageSize: String(pageSize) } }),
)
}
// Notifications API
export type NotificationListResult = {
items: Notification[]
total: number
unreadCount: number
page: number
pageSize: number
}
export function listNotifications(page = 1, pageSize = 20, unreadOnly = false) {
return unwrap<NotificationListResult>(
notificationsApi.index.$get({
query: { page: String(page), pageSize: String(pageSize), unread: String(unreadOnly) },
}),
)
}
export function getUnreadCount() {
return unwrap<{ count: number }>(notificationsApi.stats.$get())
}
export function markNotificationRead(id: string) {
return notificationsApi[':id'].$patch({ param: { id } }).then((res) => {
if (!res.ok) throw new ApiError(res.status, { error: res.statusText })
})
}
export function markAllNotificationsRead() {
return unwrap<{ count: number }>(notificationsApi.index.$patch())
}
// Announcements API
export type { Announcement, AnnouncementInput }
export type AnnouncementListResult = {
items: Announcement[]
total: number
page: number
pageSize: number
}
export function listAnnouncements(page = 1, pageSize = 20) {
return unwrap<AnnouncementListResult>(
announcementsApi.index.$get({ query: { page: String(page), pageSize: String(pageSize) } }),
)
}
export function listActiveAnnouncements() {
return unwrap<AnnouncementListResult>(
announcementsApi.index.$get({ query: { scope: 'active', page: '1', pageSize: '20' } }),
)
}
export function listAdminAnnouncements(page = 1, pageSize = 20, status?: Announcement['status']) {
const query: { page: string; pageSize: string; status?: Announcement['status'] } = {
page: String(page),
pageSize: String(pageSize),
}
if (status) query.status = status
return unwrap<AnnouncementListResult>(adminAnnouncementsApi.index.$get({ query }))
}
export function createAnnouncement(data: AnnouncementInput) {
return unwrap<Announcement>(adminAnnouncementsApi.index.$post({ json: data }))
}
export function getAnnouncement(id: string) {
return unwrap<Announcement>(adminAnnouncementsApi[':id'].$get({ param: { id } }))
}
export function updateAnnouncement(id: string, data: AnnouncementInput) {
return unwrap<Announcement>(adminAnnouncementsApi[':id'].$put({ param: { id }, json: data }))
}
export function deleteAnnouncement(id: string) {
return unwrap<{ id: string; deleted: boolean }>(adminAnnouncementsApi[':id'].$delete({ param: { id } }))
}
// Shares API
export type { ShareListItem, ShareView }
export function listShares(page = 1, pageSize = 20, status?: 'active' | 'revoked') {
const query: Record<string, string> = { page: String(page), pageSize: String(pageSize) }
if (status) query.status = status
return unwrap<{ items: ShareListItem[]; total: number; page: number; pageSize: number }>(
authedSharesApi.index.$get({ query }),
)
}
export function getShare(token: string) {
return unwrap<ShareView>(publicSharesApi[':token'].$get({ param: { token } }))
}
export function deleteShare(token: string) {
return authedSharesApi[':token'].$delete({ param: { token } }).then((res) => {
if (!res.ok) throw new ApiError(res.status, { error: res.statusText })
})
}
export interface CreateShareResult {
token: string
kind: ShareView['kind']
urls: { landing?: string; direct?: string }
expiresAt: string | null
downloadLimit: number | null
}
export function createShare(data: CreateShareRequest) {
return unwrap<CreateShareResult>(authedSharesApi.index.$post({ json: data }))
}
export function verifySharePassword(token: string, password: string) {
return unwrap<{ ok: boolean }>(publicSharesApi[':token'].sessions.$post({ param: { token }, json: { password } }))
}
export interface ShareChildItem {
ref: string
name: string
type: string
size: number
isFolder: boolean
}
export interface ShareChildrenResponse {
items: ShareChildItem[]
total: number
page: number
pageSize: number
breadcrumb: Array<{ name: string; path: string }>
}
export function listShareObjects(token: string, parent = '', page = 1, pageSize = 50) {
return unwrap<ShareChildrenResponse>(
publicSharesApi[':token'].objects.$get({
param: { token },
query: { parent, page: String(page), pageSize: String(pageSize) },
}),
)
}
export function buildShareObjectUrl(token: string, ref: string): string {
return `/api/shares/${token}/objects/${ref}`
}
export interface SaveShareInput {
targetOrgId: string
targetParent: string
}
export interface SaveShareResult {
saved: Array<{ id: string; name: string }>
skipped: Array<{ name: string; reason: string }>
}
export function saveShareToDrive(token: string, data: SaveShareInput) {
return unwrap<SaveShareResult>(authedSharesApi[':token'].objects.$post({ param: { token }, json: data }))
}
// Image Host Config API
export type { IhostConfigResponse }
export function getIhostConfig() {
return unwrap<IhostConfigResponse | null>(ihostConfigApi.index.$get())
}
export function enableIhostFeature() {
return unwrap<IhostConfigResponse>(ihostConfigApi.index.$put({ json: { enabled: true } }))
}
export function updateIhostConfig(data: { customDomain?: string | null; refererAllowlist?: string[] | null }) {
return unwrap<IhostConfigResponse>(ihostConfigApi.index.$put({ json: { enabled: true, ...data } }))
}
export function deleteIhostConfig() {
return ihostConfigApi.index.$delete().then((res) => {
if (!res.ok) throw new ApiError(res.status, { error: res.statusText })
})
}
// Image Host API Keys (via better-auth apiKey plugin)
export interface IhostApiKey {
id: string
name: string | null
start: string | null
prefix: string | null
createdAt: string
lastRequest: string | null
permissions: Record<string, string[]> | null
referenceId: string
enabled: boolean
}
export interface CreateIhostApiKeyResult extends IhostApiKey {
key: string
}
async function apiKeyFetch<T>(path: string, options: RequestInit): Promise<T> {
const res = await fetch(`/api/auth${path}`, { credentials: 'include', ...options })
if (!res.ok) {
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
throw new ApiError(res.status, { ...parsed, error: parsed.error ?? res.statusText })
}
return res.json() as Promise<T>
}
export function listIhostApiKeys(organizationId: string) {
return apiKeyFetch<{ apiKeys: IhostApiKey[] }>(`/api-key/list?organizationId=${encodeURIComponent(organizationId)}`, {
method: 'GET',
}).then((res) => res.apiKeys.filter((k) => k.permissions?.['image-hosting']?.includes('upload')))
}
export function createIhostApiKey(organizationId: string, name: string) {
return apiKeyFetch<CreateIhostApiKeyResult>('/api-key/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
organizationId,
}),
})
}
export function revokeIhostApiKey(keyId: string) {
return apiKeyFetch<{ success: boolean }>('/api-key/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyId }),
})
}
// WebDAV App Passwords (via better-auth apiKey plugin)
export type WebDavAppPassword = IhostApiKey
export interface CreateWebDavAppPasswordResult extends WebDavAppPassword {
key: string
}
export function listWebDavAppPasswords() {
return apiKeyFetch<{ apiKeys: WebDavAppPassword[] }>('/api-key/list?configId=webdav', {
method: 'GET',
}).then((res) => res.apiKeys.filter((k) => k.permissions?.webdav?.includes('read')))
}
export function createWebDavAppPassword(name: string) {
return apiKeyFetch<CreateWebDavAppPasswordResult>('/api-key/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
configId: 'webdav',
name,
}),
})
}
export function revokeWebDavAppPassword(keyId: string) {
return apiKeyFetch<{ success: boolean }>('/api-key/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ configId: 'webdav', keyId }),
})
}
// Licensing API
export type { BindingState }
export interface PairingInfo {
code: string
pairingUrl: string
expiresAt: string
}
export interface PairingPollResult {
status: 'pending' | 'approved' | 'denied' | 'expired'
plan?: string
}
export function getLicensingStatus() {
return unwrap<BindingState>(licensingApi.status.$get())
}
export function connectCloud() {
return unwrap<PairingInfo>(licensingAdminApi.pair.$post())
}
export function pollPairing(code: string) {
return unwrap<PairingPollResult>(licensingAdminApi.pair[':code'].poll.$get({ param: { code } }))
}
export function refreshLicense() {
return unwrap<{ success: boolean; last_refresh_at: number | null }>(licensingAdminApi.refresh.$post())
}
export function disconnectCloud() {
return unwrap<{ deleted: boolean }>(licensingAdminApi.binding.$delete())
}
// Auth API — Better Auth passthrough, not typed via Hono RPC
export async function getSession(): Promise<{ session: unknown; user: unknown } | null> {
const res = await fetch('/api/auth/get-session', { credentials: 'include' })
if (!res.ok) return null
return res.json()
}
export interface UploadProgress {
loaded: number
total: number
}
export interface UploadToS3Options {
onProgress?: (progress: UploadProgress) => void
signal?: AbortSignal
contentDisposition?: string
}
// S3 direct upload (external presigned URL, not our API)
export function uploadToS3(url: string, file: File, options: UploadToS3Options = {}): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const abort = () => {
xhr.abort()
reject(new DOMException('Upload cancelled', 'AbortError'))
}
if (options.signal?.aborted) {
reject(new DOMException('Upload cancelled', 'AbortError'))
return
}
options.signal?.addEventListener('abort', abort, { once: true })
xhr.upload.onprogress = (event) => {
options.onProgress?.({
loaded: event.loaded,
total: event.lengthComputable ? event.total : file.size,
})
}
xhr.onload = () => {
options.signal?.removeEventListener('abort', abort)
if (xhr.status >= 200 && xhr.status < 300) {
options.onProgress?.({ loaded: file.size, total: file.size })
resolve()
return
}
reject(new Error('Upload failed'))
}
xhr.onerror = () => {
options.signal?.removeEventListener('abort', abort)
reject(new Error('Upload failed'))
}
xhr.onabort = () => {
options.signal?.removeEventListener('abort', abort)
}
xhr.open('PUT', url)
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
if (options.contentDisposition) {
xhr.setRequestHeader('Content-Disposition', options.contentDisposition)
}
xhr.send(file)
})
}
// Image Host Images API
export type { ImageHosting }
export interface IhostImageListResult {
items: ImageHosting[]
nextCursor: string | null
}
export interface IhostImageDraft {
id: string
token: string
path: string
uploadUrl: string
storageKey: string
}
export function listIhostImages(opts?: { pathPrefix?: string; cursor?: string; limit?: number }) {
const query: Record<string, string> = {}
if (opts?.pathPrefix) query.pathPrefix = opts.pathPrefix
if (opts?.cursor) query.cursor = opts.cursor
if (opts?.limit != null) query.limit = String(opts.limit)
return unwrap<IhostImageListResult>(ihostApi.images.$get({ query }))
}
export function createIhostImagePresign(data: { path: string; mime: AllowedImageMime; size: number }) {
return unwrap<IhostImageDraft>(ihostApi.images.presign.$post({ json: data }))
}
export function confirmIhostImage(id: string) {
return unwrap<ImageHosting>(ihostApi.images[':id'].$patch({ param: { id }, json: { action: 'confirm' as const } }))
}
export async function deleteIhostImage(id: string) {
const res = await ihostApi.images[':id'].$delete({ param: { id } })
if (!res.ok) {
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
throw new ApiError(res.status, { ...parsed, error: parsed.error ?? res.statusText })
}
}
// Public image upload (avatar / org logo)
//
// PUT endpoints use multipart/form-data, which Hono RPC doesn't express
// cleanly — we use raw fetch for PUT and keep Hono RPC for DELETE. Returns
// the permanent public URL that was just written to user.image /
// organization.logo.
async function putImageMultipart(url: string, file: File): Promise<{ url: string }> {
const form = new FormData()
form.set('file', file)
const res = await fetch(url, {
method: 'PUT',
body: form,
credentials: 'include',
})
if (!res.ok) {
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
throw new ApiError(res.status, { ...parsed, error: parsed.error ?? res.statusText })
}
return res.json() as Promise<{ url: string }>
}
export function uploadAvatar(file: File) {
return putImageMultipart('/api/me/avatar', file)
}
export async function deleteAvatar() {
const res = await meApi.avatar.$delete()
if (!res.ok) {
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
throw new ApiError(res.status, { ...parsed, error: parsed.error ?? res.statusText })
}
}
export function uploadTeamLogo(teamId: string, file: File) {
return putImageMultipart(`/api/teams/${encodeURIComponent(teamId)}/logo`, file)
}
export async function deleteTeamLogo(teamId: string) {
const res = await teamsApi[':teamId'].logo.$delete({ param: { teamId } })
if (!res.ok) {
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
throw new ApiError(res.status, { ...parsed, error: parsed.error ?? res.statusText })
}
}
// Branding API
export type { BrandingConfig, BrandingField, BrandingThemeMode, BrandingThemePresetId, BrandingThemeValues }
export function getBranding() {
return unwrap<BrandingConfig>(publicBrandingApi.index.$get())
}
// PUT uses multipart/form-data (logo/favicon are File objects).
// Hono RPC does not express multipart cleanly — same documented exception as avatar/team logo uploads.
export async function saveBranding(data: {
logo?: File | null
favicon?: File | null
wordmark_text?: string
hide_powered_by?: boolean
theme_mode?: BrandingThemeMode
theme_preset?: BrandingThemePresetId
theme_custom?: BrandingThemeValues
}): Promise<BrandingConfig> {
const form = new FormData()
if (data.logo) form.set('logo', data.logo)
if (data.favicon) form.set('favicon', data.favicon)
// Empty string is submitted as an explicit clear; server treats it as setting wordmark to "".
// Use resetBrandingField('wordmark_text') to fully remove the key.
if (data.wordmark_text !== undefined) form.set('wordmark_text', data.wordmark_text)
if (data.hide_powered_by !== undefined) form.set('hide_powered_by', data.hide_powered_by ? 'true' : 'false')
if (data.theme_mode !== undefined) form.set('theme_mode', data.theme_mode)
if (data.theme_preset !== undefined) form.set('theme_preset', data.theme_preset)
if (data.theme_custom) {
form.set('theme_primary_color', data.theme_custom.primary_color)
form.set('theme_primary_foreground', data.theme_custom.primary_foreground)
form.set('theme_canvas_color', data.theme_custom.canvas_color)
form.set('theme_sidebar_accent_color', data.theme_custom.sidebar_accent_color)
form.set('theme_ring_color', data.theme_custom.ring_color)
}
const res = await fetch('/api/admin/branding', {
method: 'PUT',
body: form,
credentials: 'include',
})
if (!res.ok) {
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
throw new ApiError(res.status, { ...parsed, error: parsed.error ?? res.statusText })
}
return res.json() as Promise<BrandingConfig>
}
export async function resetBrandingField(field: BrandingField): Promise<void> {
const res = await brandingAdminApi[':field'].$delete({ param: { field } })
if (!res.ok) {
const parsed = (await res.json().catch(() => ({}))) as ApiErrorBody
throw new ApiError(res.status, { ...parsed, error: parsed.error ?? res.statusText })
}
}
// Admin Audit Logs API
export interface AdminAuditFilter {
orgId?: string
userId?: string
action?: string
targetType?: string
}
export function listAdminAuditLogs(page = 1, pageSize = 20, filter: AdminAuditFilter = {}) {
const query: Record<string, string> = {
page: String(page),
pageSize: String(pageSize),
}
if (filter.orgId) query.orgId = filter.orgId
if (filter.userId) query.userId = filter.userId
if (filter.action) query.action = filter.action
if (filter.targetType) query.targetType = filter.targetType
return unwrap<PaginatedResponse<AdminAuditEvent>>(adminAuditApi.index.$get({ query }))
}