/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see .
*/
import fs from 'node:fs';
import path from 'node:path';
import {PassThrough, pipeline, type Readable} from 'node:stream';
import {promisify} from 'node:util';
import {Config} from '@fluxer/api/src/Config';
import {DirectS3ExpirationManager} from '@fluxer/api/src/infrastructure/DirectS3ExpirationManager';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import {processAndUploadJpeg, streamToBuffer} from '@fluxer/api/src/infrastructure/StorageObjectHelpers';
import {Logger} from '@fluxer/api/src/Logger';
import {S3Errors} from '@fluxer/s3/src/errors/S3Error';
import {generatePresignedUrl} from '@fluxer/s3/src/s3/PresignedUrlGenerator';
import type {S3Service} from '@fluxer/s3/src/s3/S3Service';
import {seconds} from 'itty-time';
const pipelinePromise = promisify(pipeline);
const expirationManagers = new WeakMap();
function getExpirationManager(s3Service: S3Service): DirectS3ExpirationManager {
const existing = expirationManagers.get(s3Service);
if (existing) {
return existing;
}
const manager = new DirectS3ExpirationManager(s3Service);
expirationManagers.set(s3Service, manager);
return manager;
}
export class DirectS3StorageService implements IStorageService {
private readonly s3Service: S3Service;
private readonly presignedUrlBase: string;
private readonly expirationManager: DirectS3ExpirationManager;
constructor(s3Service: S3Service) {
this.s3Service = s3Service;
this.presignedUrlBase = Config.s3.presignedUrlBase ?? Config.s3.endpoint;
this.expirationManager = getExpirationManager(s3Service);
}
async uploadObject({
bucket,
key,
body,
contentType,
expiresAt,
}: {
bucket: string;
key: string;
body: Uint8Array;
contentType?: string;
expiresAt?: Date;
}): Promise {
const buffer = Buffer.from(body);
if (expiresAt) {
Logger.debug({bucket, key, expiresAt}, 'Uploading S3 object with expiration');
}
await this.s3Service.putObject(bucket, key, buffer, {contentType});
if (expiresAt) {
try {
await this.expirationManager.trackExpiration({bucket, key, expiresAt});
} catch (error) {
await this.s3Service.deleteObject(bucket, key);
throw error;
}
} else {
this.expirationManager.clearExpiration(bucket, key);
}
}
async getPresignedDownloadURL({
bucket,
key,
expiresIn = seconds('5 minutes'),
}: {
bucket: string;
key: string;
expiresIn?: number;
}): Promise {
const expired = await this.expirationManager.expireIfNeeded(bucket, key);
if (expired) {
throw S3Errors.noSuchKey(key);
}
const {accessKeyId, secretAccessKey} = Config.s3;
if (!accessKeyId || !secretAccessKey) {
throw new Error('S3 credentials not configured');
}
return generatePresignedUrl({
method: 'GET',
bucket,
key,
expiresIn: Math.floor(expiresIn),
accessKey: accessKeyId,
secretKey: secretAccessKey,
endpoint: this.presignedUrlBase,
region: Config.s3.region,
});
}
async deleteObject(bucket: string, key: string): Promise {
await this.s3Service.deleteObject(bucket, key);
this.expirationManager.clearExpiration(bucket, key);
}
async getObjectMetadata(bucket: string, key: string): Promise<{contentLength: number; contentType: string} | null> {
const expired = await this.expirationManager.expireIfNeeded(bucket, key);
if (expired) {
return null;
}
try {
const metadata = await this.s3Service.headObject(bucket, key);
return {
contentLength: metadata.size,
contentType: metadata.contentType ?? 'application/octet-stream',
};
} catch (error) {
const errorCode =
error && typeof error === 'object' && 'code' in error ? (error as {code: string}).code : undefined;
if (errorCode === 'NoSuchKey' || errorCode === 'NotFound') {
return null;
}
throw error;
}
}
async readObject(bucket: string, key: string): Promise {
const expired = await this.expirationManager.expireIfNeeded(bucket, key);
if (expired) {
throw S3Errors.noSuchKey(key);
}
const {stream} = await this.s3Service.getObject(bucket, key);
const passThrough = new PassThrough();
stream.pipe(passThrough);
return streamToBuffer(passThrough);
}
async streamObject(params: {bucket: string; key: string; range?: string}): Promise<{
body: Readable;
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
cacheControl?: string | null;
contentDisposition?: string | null;
expires?: Date | null;
etag?: string | null;
lastModified?: Date | null;
} | null> {
const expired = await this.expirationManager.expireIfNeeded(params.bucket, params.key);
if (expired) {
return null;
}
const rangeOption = params.range
? (() => {
const [start, end] = params.range!.split('=')[1].split('-').map(Number);
return {start, end};
})()
: undefined;
const result = await this.s3Service.getObject(
params.bucket,
params.key,
rangeOption ? {range: rangeOption} : undefined,
);
return {
body: result.stream as Readable,
contentLength: result.metadata.size,
contentRange: result.contentRange ?? null,
contentType: result.metadata.contentType ?? null,
cacheControl: null,
contentDisposition: null,
expires: null,
etag: result.metadata.etag,
lastModified: result.metadata.lastModified,
};
}
private async ensureDirectoryExists(dirPath: string): Promise {
await fs.promises.mkdir(dirPath, {recursive: true});
}
async writeObjectToDisk(bucket: string, key: string, filePath: string): Promise {
const expired = await this.expirationManager.expireIfNeeded(bucket, key);
if (expired) {
throw S3Errors.noSuchKey(key);
}
await this.ensureDirectoryExists(path.dirname(filePath));
const {stream} = await this.s3Service.getObject(bucket, key);
const writeStream = fs.createWriteStream(filePath);
try {
await pipelinePromise(stream, writeStream);
} catch (error) {
writeStream.destroy();
throw error;
}
}
async copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType,
}: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
}): Promise {
const expired = await this.expirationManager.expireIfNeeded(sourceBucket, sourceKey);
if (expired) {
throw S3Errors.noSuchKey(sourceKey);
}
await this.s3Service.copyObject(
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType
? {
metadataDirective: 'REPLACE',
contentType: newContentType,
}
: undefined,
);
const sourceExpiresAt = this.expirationManager.getExpiration(sourceBucket, sourceKey);
if (sourceExpiresAt) {
try {
await this.expirationManager.trackExpiration({
bucket: destinationBucket,
key: destinationKey,
expiresAt: sourceExpiresAt,
});
} catch (error) {
await this.s3Service.deleteObject(destinationBucket, destinationKey);
throw error;
}
} else {
this.expirationManager.clearExpiration(destinationBucket, destinationKey);
}
}
async copyObjectWithJpegProcessing({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
contentType,
}: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
contentType: string;
}): Promise<{width: number; height: number} | null> {
const isJpeg = contentType.toLowerCase().includes('jpeg') || contentType.toLowerCase().includes('jpg');
const sourceExpiresAt = this.expirationManager.getExpiration(sourceBucket, sourceKey);
if (!isJpeg) {
await this.copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType: contentType,
});
return null;
}
try {
const sourceData = await this.readObject(sourceBucket, sourceKey);
const result = await processAndUploadJpeg({
sourceData,
contentType,
destination: {bucket: destinationBucket, key: destinationKey},
uploadObject: async (params) => this.uploadObject(params),
});
if (sourceExpiresAt) {
try {
await this.expirationManager.trackExpiration({
bucket: destinationBucket,
key: destinationKey,
expiresAt: sourceExpiresAt,
});
} catch (error) {
await this.s3Service.deleteObject(destinationBucket, destinationKey);
throw error;
}
} else {
this.expirationManager.clearExpiration(destinationBucket, destinationKey);
}
return result;
} catch (error) {
Logger.error({error}, 'Failed to process JPEG, falling back to simple copy');
await this.copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType: contentType,
});
return null;
}
}
async moveObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType,
}: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
}): Promise {
await this.copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType,
});
await this.deleteObject(sourceBucket, sourceKey);
}
async purgeBucket(bucket: string): Promise {
const result = await this.s3Service.listObjects(bucket);
const keys = result.contents.map((obj) => obj.key);
if (keys.length > 0) {
const deleteResult = await this.s3Service.deleteObjects(bucket, keys);
for (const key of deleteResult.deleted) {
this.expirationManager.clearExpiration(bucket, key);
}
}
this.expirationManager.clearBucket(bucket);
Logger.debug({bucket}, 'Purged bucket');
}
async uploadAvatar(params: {prefix: string; key: string; body: Uint8Array}): Promise {
const {prefix, key, body} = params;
await this.uploadObject({
bucket: Config.s3.buckets.cdn,
key: `${prefix}/${key}`,
body,
});
}
async deleteAvatar(params: {prefix: string; key: string}): Promise {
const {prefix, key} = params;
await this.deleteObject(Config.s3.buckets.cdn, `${prefix}/${key}`);
}
async listObjects(params: {bucket: string; prefix: string}): Promise<
ReadonlyArray<{
key: string;
lastModified?: Date;
}>
> {
const result = await this.s3Service.listObjects(params.bucket, {prefix: params.prefix});
return result.contents.map((obj) => ({
key: obj.key,
lastModified: obj.lastModified,
}));
}
async deleteObjects(params: {bucket: string; objects: ReadonlyArray<{Key: string}>}): Promise {
const keys = params.objects.map((obj) => obj.Key);
if (keys.length === 0) {
return;
}
const result = await this.s3Service.deleteObjects(params.bucket, keys);
for (const key of result.deleted) {
this.expirationManager.clearExpiration(params.bucket, key);
}
}
async createMultipartUpload(params: {
bucket: string;
key: string;
contentType?: string;
}): Promise<{uploadId: string}> {
const result = await this.s3Service.createMultipartUpload(params.bucket, params.key, {
contentType: params.contentType,
});
return {uploadId: result.uploadId};
}
async uploadPart(params: {
bucket: string;
key: string;
uploadId: string;
partNumber: number;
body: Uint8Array;
}): Promise<{etag: string}> {
const result = await this.s3Service.uploadPart(
params.bucket,
params.key,
params.uploadId,
params.partNumber,
Buffer.from(params.body),
);
return {etag: result.etag};
}
async completeMultipartUpload(params: {
bucket: string;
key: string;
uploadId: string;
parts: Array<{partNumber: number; etag: string}>;
}): Promise {
await this.s3Service.completeMultipartUpload(params.bucket, params.key, params.uploadId, params.parts);
}
async abortMultipartUpload(params: {bucket: string; key: string; uploadId: string}): Promise {
await this.s3Service.abortMultipartUpload(params.bucket, params.key, params.uploadId);
}
}