修复接单平台并发、分页与补资料
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildFileUrl,
|
||||
normalizeStoredFileUrl,
|
||||
refreshStoredFileUrl,
|
||||
verifyFileAccessSignature,
|
||||
} from './file-storage-service.js'
|
||||
|
||||
const NOW_SECONDS = 1_800_000_000
|
||||
const OBJECT_KEY = 'worker-acceptance/2026/07/26/example.jpg'
|
||||
|
||||
test('file access URL carries a valid short-lived signature', () => {
|
||||
const url = buildFileUrl(OBJECT_KEY, NOW_SECONDS)
|
||||
const parsed = new URL(url, 'http://localhost')
|
||||
|
||||
assert.equal(parsed.searchParams.get('key'), OBJECT_KEY)
|
||||
assert.doesNotThrow(() =>
|
||||
verifyFileAccessSignature(
|
||||
OBJECT_KEY,
|
||||
parsed.searchParams.get('expires'),
|
||||
parsed.searchParams.get('signature'),
|
||||
NOW_SECONDS + 899,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test('file access URL rejects expired and tampered signatures', () => {
|
||||
const parsed = new URL(buildFileUrl(OBJECT_KEY, NOW_SECONDS), 'http://localhost')
|
||||
const expires = parsed.searchParams.get('expires')
|
||||
const signature = parsed.searchParams.get('signature')
|
||||
|
||||
assert.throws(() => verifyFileAccessSignature(OBJECT_KEY, expires, signature, NOW_SECONDS + 901))
|
||||
assert.throws(() =>
|
||||
verifyFileAccessSignature(`${OBJECT_KEY}.changed`, expires, signature, NOW_SECONDS),
|
||||
)
|
||||
})
|
||||
|
||||
test('stored file URL drops signatures and refreshes them for responses', () => {
|
||||
const signedUrl = buildFileUrl(OBJECT_KEY, NOW_SECONDS)
|
||||
const storedUrl = normalizeStoredFileUrl(signedUrl)
|
||||
const refreshedUrl = refreshStoredFileUrl(storedUrl)
|
||||
|
||||
assert.equal(storedUrl, `/api/v1/files/object?key=${encodeURIComponent(OBJECT_KEY)}`)
|
||||
assert.match(refreshedUrl, /[?&]expires=\d+/)
|
||||
assert.match(refreshedUrl, /[?&]signature=/)
|
||||
assert.equal(
|
||||
refreshStoredFileUrl('https://example.com/image.jpg'),
|
||||
'https://example.com/image.jpg',
|
||||
)
|
||||
})
|
||||
@@ -13,6 +13,7 @@ const IMAGE_VARIANT_THUMB = 'thumb'
|
||||
const IMAGE_VARIANT_MEDIUM = 'medium'
|
||||
const THUMB_MAX_SIDE = 480
|
||||
const MEDIUM_MAX_SIDE = 1280
|
||||
const FILE_URL_TTL_SECONDS = 15 * 60
|
||||
|
||||
const ALLOWED_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf'])
|
||||
|
||||
@@ -98,9 +99,9 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const fileUrl = buildFileUrl(objectKey)
|
||||
const thumbnailUrl = thumbnailObjectKey ? buildFileUrl(thumbnailObjectKey) : fileUrl
|
||||
const mediumUrl = mediumObjectKey ? buildFileUrl(mediumObjectKey) : fileUrl
|
||||
const fileUrl = buildUnsignedFileUrl(objectKey)
|
||||
const thumbnailUrl = thumbnailObjectKey ? buildUnsignedFileUrl(thumbnailObjectKey) : fileUrl
|
||||
const mediumUrl = mediumObjectKey ? buildUnsignedFileUrl(mediumObjectKey) : fileUrl
|
||||
const asset = await createFileAsset({
|
||||
objectKey,
|
||||
scene,
|
||||
@@ -134,8 +135,13 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStoredFileObject(key: unknown, variant: unknown = '') {
|
||||
export async function getStoredFileObject(
|
||||
key: unknown,
|
||||
variant: unknown = '',
|
||||
access: { expires?: unknown; signature?: unknown } = {},
|
||||
) {
|
||||
const objectKey = normalizeObjectKey(key)
|
||||
verifyFileAccessSignature(objectKey, access.expires, access.signature)
|
||||
const variantName = normalizeVariant(variant)
|
||||
const candidates = variantName ? imageVariantFallbackKeys(objectKey, variantName) : [objectKey]
|
||||
let lastError: unknown = null
|
||||
@@ -159,8 +165,67 @@ export function getMaxUploadSizeBytes() {
|
||||
return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024
|
||||
}
|
||||
|
||||
export function buildFileUrl(objectKey: string) {
|
||||
return `/api/v1/files/object?key=${encodeURIComponent(objectKey)}`
|
||||
export function buildFileUrl(objectKey: string, nowSeconds = Math.floor(Date.now() / 1000)) {
|
||||
const normalizedKey = normalizeObjectKey(objectKey)
|
||||
const expires = nowSeconds + FILE_URL_TTL_SECONDS
|
||||
const signature = createFileAccessSignature(normalizedKey, expires)
|
||||
return `${buildUnsignedFileUrl(normalizedKey)}&expires=${expires}&signature=${encodeURIComponent(signature)}`
|
||||
}
|
||||
|
||||
export function refreshStoredFileUrl(value: unknown, fallbackObjectKey = '') {
|
||||
const rawUrl = String(value || '').trim()
|
||||
const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim()
|
||||
return objectKey ? buildFileUrl(objectKey) : rawUrl
|
||||
}
|
||||
|
||||
export function normalizeStoredFileUrl(value: unknown, fallbackObjectKey = '') {
|
||||
const rawUrl = String(value || '').trim()
|
||||
const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim()
|
||||
return objectKey ? buildUnsignedFileUrl(objectKey) : rawUrl
|
||||
}
|
||||
|
||||
export function refreshUploadedFileUrls(value: unknown) {
|
||||
const source =
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
const objectKey = String(source.objectKey || source.object_key || '').trim()
|
||||
if (!objectKey) return source
|
||||
return {
|
||||
...source,
|
||||
objectKey,
|
||||
url: buildFileUrl(objectKey),
|
||||
thumbnailUrl: refreshStoredFileUrl(source.thumbnailUrl || source.thumbnail_url, objectKey),
|
||||
mediumUrl: refreshStoredFileUrl(source.mediumUrl || source.medium_url, objectKey),
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyFileAccessSignature(
|
||||
objectKey: string,
|
||||
expiresValue: unknown,
|
||||
signatureValue: unknown,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
) {
|
||||
const expires = Number(expiresValue || 0)
|
||||
const signature = String(signatureValue || '').trim()
|
||||
if (!Number.isSafeInteger(expires) || expires < nowSeconds || !signature) {
|
||||
throw createHttpError('文件访问链接无效或已过期', {
|
||||
statusCode: 403,
|
||||
errorCode: 'file_access_expired',
|
||||
})
|
||||
}
|
||||
const expected = createFileAccessSignature(normalizeObjectKey(objectKey), expires)
|
||||
const expectedBuffer = Buffer.from(expected)
|
||||
const actualBuffer = Buffer.from(signature)
|
||||
if (
|
||||
expectedBuffer.length !== actualBuffer.length ||
|
||||
!crypto.timingSafeEqual(expectedBuffer, actualBuffer)
|
||||
) {
|
||||
throw createHttpError('文件访问签名不正确', {
|
||||
statusCode: 403,
|
||||
errorCode: 'file_access_signature_invalid',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function imageVariantKey(objectKey: string, variant: string) {
|
||||
@@ -183,15 +248,45 @@ function mapUploadedFile(
|
||||
): UploadedFileDto {
|
||||
return {
|
||||
objectKey: asset?.object_key || fallback.objectKey,
|
||||
url: asset?.url || fallback.fileUrl,
|
||||
thumbnailUrl: asset?.thumbnail_url || fallback.thumbnailUrl,
|
||||
mediumUrl: asset?.medium_url || fallback.mediumUrl,
|
||||
url: buildFileUrl(asset?.object_key || fallback.objectKey),
|
||||
thumbnailUrl: buildFileUrl(
|
||||
asset?.thumbnail_object_key ||
|
||||
extractObjectKeyFromFileUrl(fallback.thumbnailUrl) ||
|
||||
fallback.objectKey,
|
||||
),
|
||||
mediumUrl: buildFileUrl(
|
||||
asset?.medium_object_key ||
|
||||
extractObjectKeyFromFileUrl(fallback.mediumUrl) ||
|
||||
fallback.objectKey,
|
||||
),
|
||||
filename: asset?.original_filename || fallback.filename,
|
||||
contentType: asset?.content_type || fallback.contentType,
|
||||
size: Number(asset?.size_bytes || fallback.size || 0),
|
||||
}
|
||||
}
|
||||
|
||||
function buildUnsignedFileUrl(objectKey: string) {
|
||||
return `/api/v1/files/object?key=${encodeURIComponent(normalizeObjectKey(objectKey))}`
|
||||
}
|
||||
|
||||
function createFileAccessSignature(objectKey: string, expires: number) {
|
||||
const secret =
|
||||
String(runtimeConfig.admin.sessionSecret || '').trim() ||
|
||||
String(runtimeConfig.storage.secretAccessKey || '').trim()
|
||||
return crypto.createHmac('sha256', secret).update(`${objectKey}\n${expires}`).digest('base64url')
|
||||
}
|
||||
|
||||
function extractObjectKeyFromFileUrl(value: string) {
|
||||
if (!value) return ''
|
||||
try {
|
||||
const parsed = new URL(value, 'http://localhost')
|
||||
if (parsed.pathname !== '/api/v1/files/object') return ''
|
||||
return normalizeObjectKey(parsed.searchParams.get('key'))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContentType(mimeType: unknown, data: Buffer) {
|
||||
const detected = detectContentType(data)
|
||||
if (detected) {
|
||||
|
||||
Reference in New Issue
Block a user