AWS SDK v3のflexible-checksumsはストリームBodyにContentLengthが 未指定だと x-amz-decoded-content-length: undefined を送信して ERR_HTTP_INVALID_HEADER_VALUE になる。expectedSizeを明示的に渡し、 writeStream側もsizeBytesを常に指定するよう修正した。
213 lines
8.9 KiB
TypeScript
213 lines
8.9 KiB
TypeScript
import { createReadStream, createWriteStream } from "node:fs";
|
|
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import { dirname, normalize, relative, resolve } from "node:path";
|
|
import { pipeline } from "node:stream/promises";
|
|
import { Readable } from "node:stream";
|
|
import {
|
|
CreateBucketCommand,
|
|
CompleteMultipartUploadCommand,
|
|
GetObjectCommand,
|
|
HeadBucketCommand,
|
|
HeadObjectCommand,
|
|
PutObjectCommand,
|
|
S3Client,
|
|
} from "@aws-sdk/client-s3";
|
|
import type { AppConfig } from "./config";
|
|
|
|
export interface StoredObject {
|
|
body: Readable;
|
|
sizeBytes: number;
|
|
contentType: string;
|
|
}
|
|
|
|
export type UploadPlan =
|
|
{ mode: "single"; uploadUrl: string };
|
|
|
|
export interface StorageAdapter {
|
|
createUploadPlan(key: string, contentType: string, sizeBytes: number): Promise<UploadPlan>;
|
|
completeMultipartUpload(key: string, uploadId: string | undefined, parts: Array<{ partNumber: number; etag: string }> | undefined): Promise<void>;
|
|
writeUploadFromStream(key: string, stream: NodeJS.ReadableStream, sizeBytes?: number): Promise<number>;
|
|
writeObject(key: string, body: Buffer, contentType: string): Promise<void>;
|
|
writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream): Promise<void>;
|
|
downloadToFile(key: string, filePath: string): Promise<void>;
|
|
getObject(key: string): Promise<StoredObject | null>;
|
|
headObject(key: string): Promise<{ sizeBytes: number; contentType: string } | null>;
|
|
ensureBucket?(): Promise<void>;
|
|
}
|
|
|
|
function contentTypeForPath(path: string) {
|
|
if (path.endsWith(".json")) return "application/json; charset=utf-8";
|
|
if (path.endsWith(".ipa")) return "application/octet-stream";
|
|
return "application/octet-stream";
|
|
}
|
|
|
|
export class LocalStorage implements StorageAdapter {
|
|
private readonly root: string;
|
|
|
|
constructor(root: string) {
|
|
this.root = resolve(root);
|
|
}
|
|
|
|
private pathFor(key: string) {
|
|
const candidate = resolve(this.root, normalize(key));
|
|
const relativePath = relative(this.root, candidate);
|
|
if (!relativePath || relativePath.startsWith("..") || relativePath.includes("..")) {
|
|
throw new Error("STORAGE_PATH_INVALID");
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
async createUploadPlan(key: string) {
|
|
return { mode: "single" as const, uploadUrl: `/api/v1/uploads/content/${encodeURIComponent(key)}` };
|
|
}
|
|
|
|
async completeMultipartUpload() {
|
|
return;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- sizeBytes is only needed by S3.
|
|
async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream, sizeBytes?: number) {
|
|
const filePath = this.pathFor(key);
|
|
await mkdir(dirname(filePath), { recursive: true });
|
|
await pipeline(stream, createWriteStream(filePath, { flags: "w" }));
|
|
return Number((await stat(filePath)).size);
|
|
}
|
|
|
|
async writeObject(key: string, body: Buffer) {
|
|
const filePath = this.pathFor(key);
|
|
await mkdir(dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, body);
|
|
}
|
|
|
|
async writeStream(key: string, transform: NodeJS.ReadWriteStream, _contentType: string, _sizeBytes: number, source: NodeJS.ReadableStream) {
|
|
const filePath = this.pathFor(key);
|
|
await mkdir(dirname(filePath), { recursive: true });
|
|
await pipeline(source, transform, createWriteStream(filePath, { flags: "w" }));
|
|
}
|
|
|
|
async downloadToFile(key: string, filePath: string) {
|
|
await mkdir(dirname(filePath), { recursive: true });
|
|
await pipeline(createReadStream(this.pathFor(key)), createWriteStream(filePath, { flags: "w" }));
|
|
}
|
|
|
|
async getObject(key: string) {
|
|
const filePath = this.pathFor(key);
|
|
try {
|
|
const fileStat = await stat(filePath);
|
|
return { body: createReadStream(filePath), sizeBytes: Number(fileStat.size), contentType: contentTypeForPath(key) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async headObject(key: string) {
|
|
try {
|
|
const fileStat = await stat(this.pathFor(key));
|
|
return { sizeBytes: Number(fileStat.size), contentType: contentTypeForPath(key) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export class S3Storage implements StorageAdapter {
|
|
private readonly client: S3Client;
|
|
|
|
constructor(private readonly appConfig: AppConfig) {
|
|
this.client = new S3Client({
|
|
region: appConfig.S3_REGION,
|
|
endpoint: appConfig.S3_ENDPOINT,
|
|
forcePathStyle: appConfig.S3_FORCE_PATH_STYLE,
|
|
credentials:
|
|
appConfig.S3_ACCESS_KEY_ID && appConfig.S3_SECRET_ACCESS_KEY
|
|
? { accessKeyId: appConfig.S3_ACCESS_KEY_ID, secretAccessKey: appConfig.S3_SECRET_ACCESS_KEY }
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
// Idempotently create the configured bucket so the service is self-contained
|
|
// and does not depend on an external init step (e.g. a separate minio-init
|
|
// container) before it can store objects. Safe to call on every startup.
|
|
async ensureBucket() {
|
|
const bucket = this.appConfig.S3_BUCKET;
|
|
try {
|
|
await this.client.send(new HeadBucketCommand({ Bucket: bucket }));
|
|
return;
|
|
} catch (error: any) {
|
|
const notFound = error?.$metadata?.httpStatusCode === 404 || error?.name === "NotFound" || error?.name === "NoSuchBucket";
|
|
if (!notFound) {
|
|
// Re-throw unexpected errors (auth, network) so they surface loudly.
|
|
throw error;
|
|
}
|
|
}
|
|
// us-east-1 / "auto" regions must NOT send a LocationConstraint.
|
|
await this.client.send(new CreateBucketCommand({ Bucket: bucket }));
|
|
}
|
|
|
|
|
|
// Uploads always flow through the API (browser -> API -> MinIO), so there is
|
|
// no presigned/multipart plan returned to the client. The API exposes a
|
|
// single PUT endpoint that streams the body straight into object storage,
|
|
// which keeps MinIO fully private and removes the need for a public S3 URL.
|
|
async createUploadPlan(key: string) {
|
|
return { mode: "single" as const, uploadUrl: `/api/v1/uploads/content/${encodeURIComponent(key)}` };
|
|
}
|
|
|
|
async completeMultipartUpload(key: string, uploadId: string | undefined, parts: Array<{ partNumber: number; etag: string }> | undefined) {
|
|
if (!uploadId || !parts?.length) throw new Error("MULTIPART_PARTS_REQUIRED");
|
|
await this.client.send(new CompleteMultipartUploadCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts.sort((a, b) => a.partNumber - b.partNumber).map((part) => ({ PartNumber: part.partNumber, ETag: part.etag })) } }));
|
|
}
|
|
|
|
async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream, sizeBytes?: number) {
|
|
// ContentLength must be provided for streaming bodies, otherwise the SDK's
|
|
// flexible-checksums middleware sends `x-amz-decoded-content-length:
|
|
// undefined` and the upload fails with ERR_HTTP_INVALID_HEADER_VALUE.
|
|
await this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: stream as any, ContentLength: sizeBytes }));
|
|
const metadata = await this.headObject(key);
|
|
return metadata?.sizeBytes || 0;
|
|
}
|
|
|
|
async writeObject(key: string, body: Buffer, contentType: string) {
|
|
await this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: body, ContentType: contentType }));
|
|
}
|
|
|
|
async writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream) {
|
|
const upload = this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: transform as any, ContentType: contentType, ContentLength: sizeBytes }));
|
|
await pipeline(source, transform);
|
|
await upload;
|
|
}
|
|
|
|
async downloadToFile(key: string, filePath: string) {
|
|
const response = await this.client.send(new GetObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key }));
|
|
if (!response.Body) throw new Error("STORAGE_OBJECT_EMPTY");
|
|
await mkdir(dirname(filePath), { recursive: true });
|
|
const body = response.Body instanceof Readable ? response.Body : Readable.fromWeb(response.Body as any);
|
|
await pipeline(body, createWriteStream(filePath, { flags: "w" }));
|
|
}
|
|
|
|
async getObject(key: string) {
|
|
try {
|
|
const response = await this.client.send(new GetObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key }));
|
|
if (!response.Body) return null;
|
|
const body = response.Body instanceof Readable ? response.Body : Readable.fromWeb(response.Body as any);
|
|
return { body, sizeBytes: Number(response.ContentLength || 0), contentType: response.ContentType || contentTypeForPath(key) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async headObject(key: string) {
|
|
try {
|
|
const response = await this.client.send(new HeadObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key }));
|
|
return { sizeBytes: Number(response.ContentLength || 0), contentType: response.ContentType || contentTypeForPath(key) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createStorage(appConfig: AppConfig): StorageAdapter {
|
|
return appConfig.STORAGE_MODE === "s3" ? new S3Storage(appConfig) : new LocalStorage(appConfig.LOCAL_STORAGE_DIR);
|
|
}
|