站点同步
This commit is contained in:
1 parent
a64be50b73
commit
9cb8552a99
28 files changed
+1683
-18
No files matched your search
@@ -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 }))
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user