414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
import { ResultAsync, errAsync, okAsync } from "neverthrow";
|
|
import { FlowExecCtx } from "@core/flow.execution.context";
|
|
import type {
|
|
PresignedFileAccessResponse,
|
|
PresignedUploadResponse,
|
|
} from "./data";
|
|
import { R2StorageClient } from "@pkg/objectstorage";
|
|
import { type Err } from "@pkg/result";
|
|
import { fileErrors } from "./errors";
|
|
import { logDomainEvent } from "@pkg/logger";
|
|
|
|
export type StorageConfig = {
|
|
bucketName: string;
|
|
region: string;
|
|
endpoint: string;
|
|
accessKey: string;
|
|
secretKey: string;
|
|
publicUrl: string;
|
|
maxFileSize: number;
|
|
allowedMimeTypes: string[];
|
|
allowedExtensions: string[];
|
|
};
|
|
|
|
export type UploadOptions = {
|
|
visibility: "public" | "private";
|
|
metadata?: Record<string, any>;
|
|
tags?: string[];
|
|
processImage?: boolean;
|
|
processDocument?: boolean;
|
|
processVideo?: boolean;
|
|
};
|
|
|
|
export type UploadedFileMetadata = {
|
|
id: string;
|
|
filename: string;
|
|
originalName: string;
|
|
mimeType: string;
|
|
size: number;
|
|
hash: string;
|
|
bucketName: string;
|
|
objectKey: string;
|
|
r2Url: string;
|
|
visibility: string;
|
|
userId: string;
|
|
metadata?: Record<string, any>;
|
|
tags?: string[];
|
|
uploadedAt: Date;
|
|
};
|
|
|
|
export class StorageRepository {
|
|
private storageClient: R2StorageClient;
|
|
|
|
constructor(config: StorageConfig) {
|
|
this.storageClient = new R2StorageClient(config);
|
|
}
|
|
|
|
uploadFile(
|
|
fctx: FlowExecCtx,
|
|
buffer: Buffer,
|
|
filename: string,
|
|
mimeType: string,
|
|
userId: string,
|
|
options: UploadOptions,
|
|
): ResultAsync<UploadedFileMetadata, Err> {
|
|
const startedAt = Date.now();
|
|
logDomainEvent({
|
|
event: "files.storage.upload.started",
|
|
fctx,
|
|
meta: {
|
|
userId,
|
|
filename,
|
|
mimeType,
|
|
size: buffer.length,
|
|
visibility: options.visibility,
|
|
},
|
|
});
|
|
|
|
return ResultAsync.fromPromise(
|
|
this.storageClient.uploadFile(
|
|
buffer,
|
|
filename,
|
|
mimeType,
|
|
userId,
|
|
options,
|
|
),
|
|
(error) => {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "files.storage.upload.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error,
|
|
meta: { userId, filename },
|
|
});
|
|
return fileErrors.uploadFailed(
|
|
fctx,
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
},
|
|
).andThen((uploadResult) => {
|
|
if (uploadResult.error) {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "files.storage.upload.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error: uploadResult.error,
|
|
meta: { userId, filename, stage: "storage_response" },
|
|
});
|
|
return errAsync(
|
|
fileErrors.uploadFailed(fctx, String(uploadResult.error)),
|
|
);
|
|
}
|
|
|
|
const uploadData = uploadResult.data;
|
|
if (!uploadData || !uploadData.file) {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "files.storage.upload.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error: {
|
|
code: "NO_FILE_METADATA",
|
|
message: "Storage upload returned no file metadata",
|
|
},
|
|
meta: { userId, filename },
|
|
});
|
|
return errAsync(fileErrors.noFileMetadata(fctx));
|
|
}
|
|
|
|
logDomainEvent({
|
|
event: "files.storage.upload.succeeded",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
meta: { userId, fileId: uploadData.file.id, filename },
|
|
});
|
|
return okAsync(uploadData.file as UploadedFileMetadata);
|
|
});
|
|
}
|
|
|
|
generatePresignedUploadUrl(
|
|
fctx: FlowExecCtx,
|
|
objectKey: string,
|
|
mimeType: string,
|
|
expiresIn: number,
|
|
): ResultAsync<PresignedUploadResponse, Err> {
|
|
const startedAt = Date.now();
|
|
logDomainEvent({
|
|
event: "files.storage.presigned.started",
|
|
fctx,
|
|
meta: { objectKey, mimeType, expiresIn },
|
|
});
|
|
|
|
return ResultAsync.fromPromise(
|
|
this.storageClient.generatePresignedUploadUrl(
|
|
objectKey,
|
|
mimeType,
|
|
expiresIn,
|
|
),
|
|
(error) => {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "files.storage.presigned.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error,
|
|
meta: { objectKey },
|
|
});
|
|
return fileErrors.presignedUrlFailed(
|
|
fctx,
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
},
|
|
).andThen((result) => {
|
|
if (result.error) {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "files.storage.presigned.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error: result.error,
|
|
meta: { objectKey, stage: "storage_response" },
|
|
});
|
|
return errAsync(
|
|
fileErrors.presignedUrlFailed(fctx, String(result.error)),
|
|
);
|
|
}
|
|
|
|
const presignedData = result.data;
|
|
if (!presignedData) {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "files.storage.presigned.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error: {
|
|
code: "NO_PRESIGNED_DATA",
|
|
message: "No presigned data returned",
|
|
},
|
|
meta: { objectKey },
|
|
});
|
|
return errAsync(fileErrors.noPresignedData(fctx));
|
|
}
|
|
|
|
logDomainEvent({
|
|
event: "files.storage.presigned.succeeded",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
meta: { objectKey },
|
|
});
|
|
return okAsync(presignedData as PresignedUploadResponse);
|
|
});
|
|
}
|
|
|
|
generatePresignedDownloadUrl(
|
|
fctx: FlowExecCtx,
|
|
objectKey: string,
|
|
expiresIn: number,
|
|
): ResultAsync<PresignedFileAccessResponse, Err> {
|
|
const startedAt = Date.now();
|
|
logDomainEvent({
|
|
event: "files.storage.presigned_download.started",
|
|
fctx,
|
|
meta: { objectKey, expiresIn },
|
|
});
|
|
|
|
return ResultAsync.fromPromise(
|
|
this.storageClient.generatePresignedDownloadUrl(objectKey, expiresIn),
|
|
(error) => {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "files.storage.presigned_download.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error,
|
|
meta: { objectKey },
|
|
});
|
|
return fileErrors.presignedUrlFailed(
|
|
fctx,
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
},
|
|
).andThen((result) => {
|
|
if (result.error) {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "files.storage.presigned_download.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error: result.error,
|
|
meta: { objectKey, stage: "storage_response" },
|
|
});
|
|
return errAsync(
|
|
fileErrors.presignedUrlFailed(fctx, String(result.error)),
|
|
);
|
|
}
|
|
|
|
const data = result.data;
|
|
if (!data?.downloadUrl) {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "files.storage.presigned_download.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error: {
|
|
code: "NO_PRESIGNED_DATA",
|
|
message: "No presigned download data returned",
|
|
},
|
|
meta: { objectKey },
|
|
});
|
|
return errAsync(fileErrors.noPresignedData(fctx));
|
|
}
|
|
|
|
logDomainEvent({
|
|
event: "files.storage.presigned_download.succeeded",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
meta: { objectKey, expiresIn },
|
|
});
|
|
return okAsync({
|
|
url: data.downloadUrl,
|
|
expiresIn: data.expiresIn,
|
|
});
|
|
});
|
|
}
|
|
|
|
deleteFile(
|
|
fctx: FlowExecCtx,
|
|
objectKey: string,
|
|
): ResultAsync<boolean, Err> {
|
|
const startedAt = Date.now();
|
|
logDomainEvent({
|
|
event: "files.storage.delete.started",
|
|
fctx,
|
|
meta: { objectKey },
|
|
});
|
|
|
|
return ResultAsync.fromPromise(
|
|
this.storageClient.deleteFile(objectKey),
|
|
(error) => {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "files.storage.delete.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error,
|
|
meta: { objectKey },
|
|
});
|
|
return fileErrors.storageError(
|
|
fctx,
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
},
|
|
).andThen((result) => {
|
|
if (result.error) {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "files.storage.delete.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error: result.error,
|
|
meta: { objectKey, stage: "storage_response" },
|
|
});
|
|
return errAsync(
|
|
fileErrors.storageError(fctx, String(result.error)),
|
|
);
|
|
}
|
|
|
|
logDomainEvent({
|
|
event: "files.storage.delete.succeeded",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
meta: { objectKey },
|
|
});
|
|
return okAsync(true);
|
|
});
|
|
}
|
|
|
|
deleteFiles(
|
|
fctx: FlowExecCtx,
|
|
objectKeys: string[],
|
|
): ResultAsync<boolean, Err> {
|
|
const startedAt = Date.now();
|
|
logDomainEvent({
|
|
event: "files.storage.delete_many.started",
|
|
fctx,
|
|
meta: { fileCount: objectKeys.length },
|
|
});
|
|
|
|
return ResultAsync.combine(
|
|
objectKeys.map((key) => this.deleteFile(fctx, key)),
|
|
).map(() => {
|
|
logDomainEvent({
|
|
event: "files.storage.delete_many.succeeded",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
meta: { fileCount: objectKeys.length },
|
|
});
|
|
return true;
|
|
});
|
|
}
|
|
|
|
listObjectKeys(
|
|
fctx: FlowExecCtx,
|
|
prefix?: string,
|
|
): ResultAsync<string[], Err> {
|
|
const startedAt = Date.now();
|
|
logDomainEvent({
|
|
event: "files.storage.list.started",
|
|
fctx,
|
|
meta: { prefix: prefix || null },
|
|
});
|
|
|
|
return ResultAsync.fromPromise(
|
|
this.storageClient.listObjectKeys(prefix),
|
|
(error) => {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "files.storage.list.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error,
|
|
meta: { prefix: prefix || null },
|
|
});
|
|
return fileErrors.storageError(
|
|
fctx,
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
},
|
|
).andThen((result) => {
|
|
if (result.error) {
|
|
logDomainEvent({
|
|
level: "error",
|
|
event: "files.storage.list.failed",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
error: result.error,
|
|
meta: { prefix: prefix || null, stage: "storage_response" },
|
|
});
|
|
return errAsync(fileErrors.storageError(fctx, String(result.error)));
|
|
}
|
|
|
|
const keys = result.data || [];
|
|
logDomainEvent({
|
|
event: "files.storage.list.succeeded",
|
|
fctx,
|
|
durationMs: Date.now() - startedAt,
|
|
meta: { prefix: prefix || null, count: keys.length },
|
|
});
|
|
return okAsync(keys);
|
|
});
|
|
}
|
|
}
|