站点同步

This commit is contained in:
tianmo committed 2026-10-17 14:41:32 +08:00
1 parent a64be50b73
commit 9cb8552a99
28 files changed
+1683 -18

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
import { requireSiteSyncAuth } from '../../utils/access'
import { listAllImageIndexRows } from '../../utils/image-index'
import { listUserIdUsernameMap } from '../../utils/db'
import { collectMediaAssetKeys } from '../../utils/media-assets'
import { getBackendForKey } from '../../utils/storage/resolver'
import type { SiteSyncInventory } from '../../utils/site-sync/protocol'
export default defineEventHandler(async (event): Promise<SiteSyncInventory> => {
await requireSiteSyncAuth(event)
const rows = listAllImageIndexRows()
const objects = new Map<string, number>()
for (const row of rows) {
const backend = await getBackendForKey(row.key)
let keys: string[]
try {
keys = await collectMediaAssetKeys(row, backend)
} catch {
keys = [
...(Number(row.source_deleted ?? 0) !== 1 ? [row.key] : []),
...(row.thumb_key ? [row.thumb_key] : []),
...(row.hls_key ? [row.hls_key] : [])
]
}
for (const key of new Set(keys)) {
const stored = await backend.head(key)
if (stored) objects.set(key, stored.size)
}
}
return {
users: [...listUserIdUsernameMap().values()].sort(),
mediaKeys: rows.map(row => row.key),
objects: [...objects].map(([key, size]) => ({ key, size }))
}
})
+75
View File
@@ -0,0 +1,75 @@
import { getQuery } from 'h3'
import { requireSiteSyncAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db'
import {
getImageIndexRow,
getImageTotals,
listImageIndexRowsAfter
} from '../../utils/image-index'
import { validateStorageKey } from '../../utils/image-key'
import { collectMediaAssetKeys } from '../../utils/media-assets'
import { getBackendForKey } from '../../utils/storage/resolver'
import { getTagsForImageKeys } from '../../utils/tags'
import { toSiteSyncMediaTransfer } from '../../utils/site-sync/manifest'
import type {
SiteSyncMediaAsset,
SiteSyncMediaBundle,
SiteSyncMediaPage
} from '../../utils/site-sync/protocol'
export default defineEventHandler(async (event): Promise<SiteSyncMediaPage> => {
await requireSiteSyncAuth(event)
const query = getQuery(event)
const key = typeof query.key === 'string' ? query.key.trim() : ''
const after = typeof query.after === 'string' ? query.after : ''
const limit = Math.min(
100,
Math.max(1, Number.parseInt(String(query.limit ?? '50'), 10) || 50)
)
if (key && !validateStorageKey(key)) {
createApiError(event, 'INVALID_REQUEST', '媒体 key 无效', 400)
}
const rows = key
? [getImageIndexRow(key)].filter(row => row !== null)
: listImageIndexRowsAfter(after, limit)
if (key && rows.length === 0) {
createApiError(event, 'IMAGE_NOT_FOUND', '目标媒体不存在', 404)
}
const ownerMap = listUserIdUsernameMap()
const tagMap = getTagsForImageKeys(rows.map(row => row.key))
const items: SiteSyncMediaBundle[] = []
for (const row of rows) {
const backend = await getBackendForKey(row.key)
const keys = await collectMediaAssetKeys(row, backend)
const assets: SiteSyncMediaAsset[] = []
for (const assetKey of new Set(keys)) {
const stored = await backend.head(assetKey)
if (!stored) {
throw new Error(`目标媒体对象不存在:${assetKey}`)
}
assets.push({ key: assetKey, size: stored.size })
}
items.push({
media: toSiteSyncMediaTransfer(
row,
row.user_id == null ? null : ownerMap.get(row.user_id) ?? null,
tagMap.get(row.key) ?? []
),
assets
})
}
return {
items,
totalMedia: getImageTotals().count,
nextCursor: key || rows.length < limit
? null
: rows.at(-1)?.key ?? null
}
})
+26
View File
@@ -0,0 +1,26 @@
import { requireSiteSyncAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { validateStorageKey } from '../../utils/image-key'
import { applySiteSyncMedia } from '../../utils/site-sync/apply'
import type { SiteSyncMediaTransfer } from '../../utils/site-sync/protocol'
interface Body {
media?: SiteSyncMediaTransfer
conflictMode?: 'skip' | 'overwrite'
}
export default defineEventHandler(async (event) => {
const admin = await requireSiteSyncAuth(event)
const body = await readBody<Body>(event).catch(() => null)
const media = body?.media
if (!media || !validateStorageKey(media.key)) {
createApiError(event, 'INVALID_REQUEST', '媒体同步参数无效', 400)
}
const conflictMode = body?.conflictMode === 'skip' ? 'skip' : 'overwrite'
try {
return await applySiteSyncMedia(media, conflictMode, admin.id)
} catch (error) {
const message = error instanceof Error ? error.message : '媒体同步失败'
createApiError(event, 'INVALID_REQUEST', message, 400)
}
})
+26
View File
@@ -0,0 +1,26 @@
import { getQuery, sendStream, setResponseHeader } from 'h3'
import { requireSiteSyncAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { validateStorageKey } from '../../utils/image-key'
import { getBackendForKey } from '../../utils/storage/resolver'
export default defineEventHandler(async (event) => {
await requireSiteSyncAuth(event)
const query = getQuery(event)
const key = typeof query.key === 'string' ? query.key.trim() : ''
if (!validateStorageKey(key)) {
createApiError(event, 'INVALID_REQUEST', '同步对象 key 无效', 400)
}
const backend = await getBackendForKey(key)
const stored = await backend.head(key)
if (!stored) {
createApiError(event, 'IMAGE_NOT_FOUND', `目标对象不存在:${key}`, 404)
}
setResponseHeader(event, 'Content-Type', stored.contentType)
setResponseHeader(event, 'Content-Length', stored.size)
setResponseHeader(event, 'Cache-Control', 'no-store')
return sendStream(event, await backend.createStream(key))
})
+54
View File
@@ -0,0 +1,54 @@
import { readMultipartFormData } from 'h3'
import { requireSiteSyncAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { MAX_VIDEO_FILE_SIZE } from '../../utils/constants'
import { findUserByUsername } from '../../utils/db'
import { validateStorageKey } from '../../utils/image-key'
import { getActiveBackend } from '../../utils/storage/resolver'
function field(form: Awaited<ReturnType<typeof readMultipartFormData>>, name: string): string {
const part = form?.find(item => item.name === name)
return part?.data?.length ? new TextDecoder().decode(part.data).trim() : ''
}
export default defineEventHandler(async (event) => {
await requireSiteSyncAuth(event)
const form = await readMultipartFormData(event)
if (!form?.length) createApiError(event, 'INVALID_REQUEST', '未收到同步数据', 400)
const key = field(form, 'key')
const file = form.find(part => part.name === 'file' && part.data?.length)
if (!validateStorageKey(key) || !file?.data) {
createApiError(event, 'INVALID_REQUEST', '同步文件参数无效', 400)
}
if (file.data.length > MAX_VIDEO_FILE_SIZE) {
createApiError(event, 'FILE_TOO_LARGE', '同步文件超过 500 MB', 413)
}
const conflictMode = field(form, 'conflictMode') === 'skip' ? 'skip' : 'overwrite'
const backend = await getActiveBackend()
if (conflictMode === 'skip' && await backend.head(key)) {
return { success: true, skipped: true }
}
const ownerUsername = field(form, 'ownerUsername')
const owner = ownerUsername ? findUserByUsername(ownerUsername) : null
if (ownerUsername && !owner) {
createApiError(event, 'INVALID_REQUEST', `目标站点缺少用户:${ownerUsername}`, 400)
}
await backend.put(key, new Uint8Array(file.data), {
originalName: field(form, 'originalName') || file.filename || key.split('/').pop() || 'file',
uploadedAt: field(form, 'uploadedAt') || new Date().toISOString(),
contentType: file.type || field(form, 'contentType') || 'application/octet-stream',
size: file.data.length,
userId: owner?.id ?? null,
mediaType: key.startsWith('videos/')
? 'video'
: key.startsWith('files/')
? 'file'
: 'image'
})
return { success: true, skipped: false }
})
+10
View File
@@ -0,0 +1,10 @@
import { requireSiteSyncAuth } from '../../utils/access'
import { listUserIdUsernameMap } from '../../utils/db'
import type { SiteSyncUsers } from '../../utils/site-sync/protocol'
export default defineEventHandler(async (event): Promise<SiteSyncUsers> => {
await requireSiteSyncAuth(event)
return {
users: [...listUserIdUsernameMap().values()].sort()
}
})