fix(files): 去掉文件链接的签名与有效期,修客服端凭证裂图

问题:寄卖凭证的图片地址是带签名的临时链接(15 分钟有效),而入库时会把当时的链接
原样存进 screenshots_json;工单验收那条线出参时会用 refreshStoredFileUrl() 重签,
寄卖这条线(提报凭证/赠送凭证/卖家列表)漏了这一步,于是入库 15 分钟后链接必然失效,
客服看到裂图。图片本身没丢,是取的时候没重签。

业务定调:签名这套意义不大,直接去掉。

- buildFileUrl() 只产出不带签名、不带有效期的普通链接(对象 key 带随机段,不可枚举),
  删除签名生成/校验与 15 分钟 TTL 常量,文件读取路由不再校验签名
- refreshStoredFileUrl() 语义改为"归一化":把历史存的签名链接洗成普通链接,老数据不用改也能看
- 寄卖出参统一归一化:提报凭证、赠送凭证(imageUrls + files[])、库存截图、卖家列表截图

真实库验证:库里早在 2026-09-15 就过期的老链接现在返回 HTTP 200(image/webp,52632 字节),
改造前是 403;客服接口下发地址已无签名;不存在的 key 仍 404。

取舍:文件路由原本靠签名把关,去掉后知道链接即可看图(key 含 24 位随机段,实际不可枚举)。
如需收紧可给该路由加后台登录校验。
This commit is contained in:
yml2213
2026-09-17 11:52:40 +08:00
parent e731348bb0
commit 0fd09976a5
7 changed files with 138 additions and 108 deletions
@@ -5,46 +5,24 @@ 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('文件链接就是普通链接:不带签名、不带有效期(存多久都还能看)', () => {
const url = buildFileUrl(OBJECT_KEY)
assert.equal(url, `/api/v1/files/object?key=${encodeURIComponent(OBJECT_KEY)}`)
assert.equal(url.includes('expires='), false)
assert.equal(url.includes('signature='), false)
})
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')
test('历史存下来的签名链接会被归一化成普通链接(老数据不用改也能看)', () => {
const legacySignedUrl = `/api/v1/files/object?key=${encodeURIComponent(OBJECT_KEY)}&expires=1789463063&signature=OLD_SIGNATURE`
const normalized = refreshStoredFileUrl(legacySignedUrl)
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(normalized, `/api/v1/files/object?key=${encodeURIComponent(OBJECT_KEY)}`)
// 归一化不重签、不丢 key,所以早就过期的老链接修完之后照样能打开
assert.equal(normalizeStoredFileUrl(legacySignedUrl), normalized)
assert.equal(
refreshStoredFileUrl('https://example.com/image.jpg'),
'https://example.com/image.jpg',
@@ -53,7 +31,7 @@ test('stored file URL drops signatures and refreshes them for responses', () =>
test('public file scenes return stable unsigned URLs for browser caching', () => {
const publicObjectKey = 'category_icon/2026/09/13/game-icon.png'
const url = buildFileUrl(publicObjectKey, NOW_SECONDS)
const url = buildFileUrl(publicObjectKey)
assert.equal(url, `/api/v1/files/object?key=${encodeURIComponent(publicObjectKey)}`)
assert.equal(url.includes('expires='), false)
assert.equal(url.includes('signature='), false)
@@ -14,7 +14,6 @@ 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'])
@@ -160,13 +159,10 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
}
}
export async function getStoredFileObject(
key: unknown,
variant: unknown = '',
access: { expires?: unknown; signature?: unknown } = {},
) {
export async function getStoredFileObject(key: unknown, variant: unknown = '') {
// 不再校验签名:文件链接就是普通链接(见 buildFileUrl 的说明),
// 对象 key 里带随机段,本身就是不可枚举的。
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
@@ -190,26 +186,27 @@ export function getMaxUploadSizeBytes() {
return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024
}
export function buildFileUrl(objectKey: string, nowSeconds = Math.floor(Date.now() / 1000)) {
const normalizedKey = normalizeObjectKey(objectKey)
if (isPublicObjectKey(normalizedKey)) {
return buildUnsignedFileUrl(normalizedKey)
}
const expires = nowSeconds + FILE_URL_TTL_SECONDS
const signature = createFileAccessSignature(normalizedKey, expires)
return `${buildUnsignedFileUrl(normalizedKey)}&expires=${expires}&signature=${encodeURIComponent(signature)}`
/**
* 文件链接:不再带签名与有效期。
*
* 以前这里是"15 分钟有效的签名链接",结果是**入库时把当时的链接原样存下来**,
* 之后任何不重签的地方过一会儿必然打不开(客服看到裂图),要每一处读取都记得重签才不会出错。
* 现在链接就是稳定的普通链接(对象 key 带随机段,不可枚举),存下来过多久都能看。
*/
export function buildFileUrl(objectKey: string) {
return buildUnsignedFileUrl(normalizeObjectKey(objectKey))
}
/** 把库里历史存的(带签名/带有效期的)链接归一化成普通链接;非文件链接原样返回。 */
export function refreshStoredFileUrl(value: unknown, fallbackObjectKey = '') {
const rawUrl = String(value || '').trim()
const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim()
return objectKey ? buildFileUrl(objectKey) : rawUrl
}
/** @deprecated 用 refreshStoredFileUrl;两者现在完全等价(都只做归一化,不再重签)。 */
export function normalizeStoredFileUrl(value: unknown, fallbackObjectKey = '') {
const rawUrl = String(value || '').trim()
const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim()
return objectKey ? buildUnsignedFileUrl(objectKey) : rawUrl
return refreshStoredFileUrl(value, fallbackObjectKey)
}
export function refreshUploadedFileUrls(value: unknown) {
@@ -236,37 +233,6 @@ export function isPublicObjectKey(objectKey: string) {
return PUBLIC_SCENES.has(firstSegment)
}
export function verifyFileAccessSignature(
objectKey: string,
expiresValue: unknown,
signatureValue: unknown,
nowSeconds = Math.floor(Date.now() / 1000),
) {
if (isPublicObjectKey(objectKey)) {
return
}
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) {
const ext = path.extname(objectKey)
const base = ext ? objectKey.slice(0, -ext.length) : objectKey
@@ -308,13 +274,6 @@ 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 {