fix(storage): correct presign content-type claim; head returns contentType
Spec review (live negative test) found s3-request-presigner unconditionally unsigns content-type: header-less PUT returns 200, so the previous docblock claiming signature enforcement was a false guarantee. Content-Type is now documented as convention-only; head() returns contentType so the complete step (Task 19) can verify it server-side against the session declaration. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -46,10 +46,12 @@ export class MinioStorageAdapter implements StoragePort {
|
||||
}
|
||||
|
||||
/**
|
||||
* 预签名上传。S3 预签名无法对 Content-Length 签名(SigV4 规范排除该头),
|
||||
* bytes 参数仅作为端口契约保留:字节数核验发生在上传完成后的 head/verify 阶段。
|
||||
* Content-Type 经 PutObjectCommand 的 ContentType 参数参与签名,客户端必须
|
||||
* 原样携带返回的 headers,否则 403 SignatureDoesNotMatch。
|
||||
* 预签名上传。SigV4 预签名无法对 Content-Length 签名(规范排除);所装版本的
|
||||
* s3-request-presigner 亦将 Content-Type 列为不可签名头(prepareRequest 强制
|
||||
* unsignableHeaders.add("content-type"),实测不带头的 PUT 返回 200)。
|
||||
* 因此返回的 headers 是「约定必须发送」而非签名强制:字节完整性由 verify 阶段
|
||||
* 的 head 核验,Content-Type 一致性由 complete 时服务端比对 head().contentType
|
||||
* 与会话申明值兜底(见 Task 19)。bytes 参数仅作为端口契约保留。
|
||||
*/
|
||||
async presignPut(objectKey: string, bytes: number, contentType: string, ttlSeconds: number): Promise<UploadCredential> {
|
||||
void bytes; // 见上:签名不含 Content-Length,大小核验由 verify 阶段负责
|
||||
@@ -73,10 +75,10 @@ export class MinioStorageAdapter implements StoragePort {
|
||||
});
|
||||
}
|
||||
|
||||
async head(objectKey: string): Promise<{ bytes: number; etag: string } | null> {
|
||||
async head(objectKey: string): Promise<{ bytes: number; etag: string; contentType: string } | null> {
|
||||
try {
|
||||
const out = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: objectKey }));
|
||||
return { bytes: out.ContentLength ?? 0, etag: out.ETag ?? '' };
|
||||
return { bytes: out.ContentLength ?? 0, etag: out.ETag ?? '', contentType: out.ContentType ?? '' };
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return null;
|
||||
throw error;
|
||||
|
||||
@@ -9,9 +9,15 @@ export interface UploadCredential {
|
||||
expiresAt: string; // ISO
|
||||
}
|
||||
export interface StoragePort {
|
||||
/**
|
||||
* 预签名上传凭证。注意:headers 为「约定必须发送」而非签名强制——SigV4 预签名
|
||||
* 不含 Content-Length,且 SDK 将 Content-Type 列为不可签名头。字节核验在
|
||||
* complete 阶段经 head().bytes 比对,Content-Type 一致性经 head().contentType
|
||||
* 与会话申明值比对(服务端兜底,见 Task 19)。
|
||||
*/
|
||||
presignPut(objectKey: string, bytes: number, contentType: string, ttlSeconds: number): Promise<UploadCredential>;
|
||||
presignGet(objectKey: string, ttlSeconds: number): Promise<string>;
|
||||
head(objectKey: string): Promise<{ bytes: number; etag: string } | null>;
|
||||
head(objectKey: string): Promise<{ bytes: number; etag: string; contentType: string } | null>;
|
||||
getStream(objectKey: string): Promise<NodeJS.ReadableStream>;
|
||||
put(objectKey: string, body: Buffer, contentType: string): Promise<{ etag: string }>;
|
||||
/** 复制到正式键;目标已存在时校验一致性并幂等返回(禁止覆盖语义)。 */
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('MinioStorageAdapter(真实 MinIO 集成)', () => {
|
||||
expect(head).not.toBeNull();
|
||||
expect(head?.bytes).toBe(body.length);
|
||||
expect(head?.etag).toBeTruthy();
|
||||
expect(head?.contentType).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('put + getStream: 写入后流式读回的字节与写入一致', async () => {
|
||||
|
||||
Reference in New Issue
Block a user