Skip to main content

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

EnvironmentBucketRegionEndpoint
Productionmnemehel1https://hel1.your-objectstorage.com
Stagingmneme-stagingnbg1https://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?)

  1. Generates key = ${prefix ?? 'uploads'}/${randomUUID()}${ext}
  2. PutObjectCommand with ContentType, ContentLength, Body: buffer
  3. No ACL (bucket is private by default)
  4. 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_EXPIRY seconds (default 3600 = 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

  1. Multipart file received by NestJS controller via FileInterceptor (Multer)
  2. AttachmentsService.upload(): a. Validates MIME type against whitelist: image/jpeg, image/png, image/webp, image/gif, application/pdf b. Uses file-type library to read magic bytes — verifies actual file type, not just declared Content-Type c. Calls storageService.save(file, prefix) d. Creates Attachment record in DB
  3. Returns Attachment with presigned URL resolved via resolvePath

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

VariableNotes
STORAGE_ENDPOINTHetzner S3 endpoint URL
STORAGE_BUCKETBucket name
STORAGE_ACCESS_KEYAccess key ID
STORAGE_SECRET_KEYSecret access key
STORAGE_REGIONRegion code (hel1 / nbg1)
STORAGE_PRESIGN_EXPIRYURL expiry in seconds (default 3600)