managarten/packages/shared-storage/src/client.ts
Till-JS d6303e4998 feat(storage): add public endpoint support for presigned URLs
When services run in Docker with internal endpoints (e.g., http://minio:9000),
presigned URLs are inaccessible from browsers. This adds S3_PUBLIC_ENDPOINT
support to generate presigned URLs using a publicly accessible endpoint
(e.g., https://minio.mana.how) while keeping internal operations on the
Docker network.

Changes:
- Add publicEndpoint to StorageConfig type
- Create separate S3Client for presigned URL generation
- Add S3_PUBLIC_ENDPOINT to factory configuration
- Configure lightwrite-backend with public MinIO endpoint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-16 15:35:18 +01:00

212 lines
4.8 KiB
TypeScript

import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
HeadObjectCommand,
type PutObjectCommandInput,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import type {
StorageConfig,
BucketConfig,
UploadOptions,
PresignedUrlOptions,
UploadResult,
FileInfo,
} from './types';
/**
* S3-compatible storage client for MinIO (local) and Hetzner Object Storage (production)
*/
export class StorageClient {
private client: S3Client;
private presignClient: S3Client;
private bucket: BucketConfig;
constructor(config: StorageConfig, bucket: BucketConfig) {
// Main client for internal operations (upload, download, delete, etc.)
this.client = new S3Client({
endpoint: config.endpoint,
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
forcePathStyle: config.forcePathStyle ?? true,
});
// Separate client for presigned URLs (uses public endpoint if available)
// This allows internal operations to use Docker network addresses
// while presigned URLs use publicly accessible endpoints
this.presignClient = new S3Client({
endpoint: config.publicEndpoint ?? config.endpoint,
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
forcePathStyle: config.forcePathStyle ?? true,
});
this.bucket = bucket;
}
/**
* Upload a file to the bucket
*/
async upload(
key: string,
body: Buffer | Uint8Array | string | ReadableStream,
options: UploadOptions = {}
): Promise<UploadResult> {
const input: PutObjectCommandInput = {
Bucket: this.bucket.name,
Key: key,
Body: body,
ContentType: options.contentType,
CacheControl: options.cacheControl,
Metadata: options.metadata,
};
if (options.public) {
input.ACL = 'public-read';
}
const command = new PutObjectCommand(input);
const result = await this.client.send(command);
return {
key,
url: this.getPublicUrl(key),
etag: result.ETag,
};
}
/**
* Download a file from the bucket
*/
async download(key: string): Promise<Buffer> {
const command = new GetObjectCommand({
Bucket: this.bucket.name,
Key: key,
});
const response = await this.client.send(command);
if (!response.Body) {
throw new Error(`File not found: ${key}`);
}
// Convert stream to buffer
const chunks: Uint8Array[] = [];
const stream = response.Body as AsyncIterable<Uint8Array>;
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
/**
* Delete a file from the bucket
*/
async delete(key: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: this.bucket.name,
Key: key,
});
await this.client.send(command);
}
/**
* Check if a file exists
*/
async exists(key: string): Promise<boolean> {
try {
const command = new HeadObjectCommand({
Bucket: this.bucket.name,
Key: key,
});
await this.client.send(command);
return true;
} catch {
return false;
}
}
/**
* List files in the bucket with optional prefix
*/
async list(prefix?: string, maxKeys = 1000): Promise<FileInfo[]> {
const command = new ListObjectsV2Command({
Bucket: this.bucket.name,
Prefix: prefix,
MaxKeys: maxKeys,
});
const response = await this.client.send(command);
return (response.Contents ?? []).map((item) => ({
key: item.Key!,
size: item.Size ?? 0,
lastModified: item.LastModified ?? new Date(),
etag: item.ETag,
}));
}
/**
* Generate a presigned URL for uploading (PUT)
* Uses the public endpoint if configured, allowing browser access
*/
async getUploadUrl(key: string, options: PresignedUrlOptions = {}): Promise<string> {
const command = new PutObjectCommand({
Bucket: this.bucket.name,
Key: key,
});
return getSignedUrl(this.presignClient, command, {
expiresIn: options.expiresIn ?? 3600,
});
}
/**
* Generate a presigned URL for downloading (GET)
* Uses the public endpoint if configured, allowing browser access
*/
async getDownloadUrl(key: string, options: PresignedUrlOptions = {}): Promise<string> {
const command = new GetObjectCommand({
Bucket: this.bucket.name,
Key: key,
});
return getSignedUrl(this.presignClient, command, {
expiresIn: options.expiresIn ?? 3600,
});
}
/**
* Get the public URL for a file (if bucket is public)
*/
getPublicUrl(key: string): string | undefined {
if (!this.bucket.publicUrl) {
return undefined;
}
return `${this.bucket.publicUrl}/${key}`;
}
/**
* Get the underlying S3 client for advanced operations
*/
getS3Client(): S3Client {
return this.client;
}
/**
* Get the bucket name
*/
getBucketName(): string {
return this.bucket.name;
}
}