File Storage
All file storage goes through IStorageService — an interface that can be swapped out without changing consumers.
Interface (src/attachments/storage/storage.interface.ts)
interface IStorageService {
save(
file: { buffer: Buffer; originalname: string; mimetype: string },
prefix?: string,
): Promise<StoredFile>;
delete(key: string): Promise<void>;
resolvePath(key: string): Promise<string>; // returns a presigned URL
}
type StoredFile = {
key: string; // storage key, e.g. "orders/attachments/uuid.jpg"
mimetype: string;
size: number;
};
const STORAGE_SERVICE = Symbol("IStorageService");
STORAGE_SERVICE symbol is used as the NestJS injection token. Consumers inject @Inject(STORAGE_SERVICE) — they never reference the concrete implementation.
Hetzner Object Storage (HetznerStorageService)
The sole production implementation. Uses @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner with Hetzner's S3-compatible API.
Configuration
const s3 = new S3Client({
endpoint: STORAGE_ENDPOINT, // e.g. https://nbg1.your-objectstorage.com
region: STORAGE_REGION, // e.g. nbg1 or hel1
credentials: {
accessKeyId: STORAGE_ACCESS_KEY,
secretAccessKey: STORAGE_SECRET_KEY,
},
forcePathStyle: true, // REQUIRED for Hetzner — do not remove
});
forcePathStyle: true is mandatory for Hetzner Object Storage. Without it, the AWS SDK constructs virtual-hosted-style URLs (bucket.endpoint) which Hetzner does not support.
Environments
| Environment | Bucket | Region | Endpoint |
|---|---|---|---|
| Production | mneme | hel1 | https://hel1.your-objectstorage.com |
| Staging | mneme-staging | nbg1 | https://nbg1.your-objectstorage.com |
All buckets are private — no public access. Files are served via presigned URLs only.
Key Format
<prefix>/<uuid><ext>
Example: orders/attachments/a3f2b1c4-8d9e-4f5a-b6c7-d8e9f0a1b2c3.jpg
The uuid is crypto.randomUUID(). Extension is derived from the original filename.
save(file, prefix?)
- Generates
key = ${prefix ?? 'uploads'}/${randomUUID()}${ext} PutObjectCommandwithContentType,ContentLength,Body: buffer- No ACL (bucket is private by default)
- Returns
{ key, mimetype, size }
delete(key)
DeleteObjectCommand. No-ops silently if the key doesn't exist (Hetzner returns 204 for non-existent keys).
resolvePath(key)
Returns a presigned GetObject URL via getSignedUrl():
- Expiry:
STORAGE_PRESIGN_EXPIRYseconds (default3600= 1 hour) - URL is valid for 1 hour; after that the client must request a fresh URL
Attachments Model (Polymorphic)
Attachment is a polymorphic model — it can belong to any entity without a foreign key constraint:
model Attachment {
id String @id @default(cuid())
entityType AttachmentEntityType // order | build | timeline_step | event_log | test_run
entityId String // ID of the entity (no FK!)
storageKey String
originalName String
mimeType String
size Int
label String? // e.g. "invoice", "booklet_quick_start", "cover_photo"
uploadedById String?
createdAt DateTime @default(now())
@@index([entityType, entityId])
}
No FK on entityId — this is intentional. A FK would require one of: a union FK (not supported by Postgres/Prisma natively), polymorphic emulation, or a join table. The current design trades referential integrity for simplicity. A cleanup job to remove orphaned attachments is a known post-launch task.
AttachmentEntityType enum: order, build, timeline_step, event_log, test_run
File Upload Flow
- Multipart file received by NestJS controller via
FileInterceptor(Multer) AttachmentsService.upload(): a. Validates MIME type against whitelist:image/jpeg,image/png,image/webp,image/gif,application/pdfb. Usesfile-typelibrary to read magic bytes — verifies actual file type, not just declaredContent-Typec. CallsstorageService.save(file, prefix)d. CreatesAttachmentrecord in DB- Returns
Attachmentwith presigned URL resolved viaresolvePath
MIME Validation Details
import { fileTypeFromBuffer } from "file-type";
const detected = await fileTypeFromBuffer(file.buffer);
if (!ALLOWED_MIME_TYPES.includes(detected?.mime)) {
throw new BadRequestException("Unsupported file type");
}
The file-type library reads the file's magic bytes (first few bytes), not the Content-Type header. This prevents clients from uploading a .exe disguised as a .jpg.
Presigned URL Expiry
All file URLs expire after 1 hour (configurable via STORAGE_PRESIGN_EXPIRY). This means:
- List endpoints that include resolved URLs will have URLs valid for 1 hour
- The frontend should not cache these URLs indefinitely
- Staff portal build photo views may show broken images after 1 hour if the page is not refreshed
Required Environment Variables
| Variable | Notes |
|---|---|
STORAGE_ENDPOINT | Hetzner S3 endpoint URL |
STORAGE_BUCKET | Bucket name |
STORAGE_ACCESS_KEY | Access key ID |
STORAGE_SECRET_KEY | Secret access key |
STORAGE_REGION | Region code (hel1 / nbg1) |
STORAGE_PRESIGN_EXPIRY | URL expiry in seconds (default 3600) |