auth-branding-logo.ts202 lines · main
1import { ValidationError } from '@briven/shared';
2
3import { env } from '../env.js';
4import { presignS3Url } from '../lib/s3-presign.js';
5import { isStorageConfigured } from './storage.js';
6
7/**
8 * Storage + serving for auth → branding logos.
9 *
10 * Logos must render on PUBLIC hosted login pages, so neither presigned
11 * (expiring) URLs nor bucket-policy changes are suitable. Instead we keep
12 * the object PRIVATE in MinIO at a STABLE key and serve it back through an
13 * UNAUTHENTICATED api route (`GET /v1/projects/:id/auth/branding/logo`)
14 * that acts like a tiny CDN. The branding config's `logoUrl` then points
15 * at that route (cache-busted), so the value is a permanent public URL.
16 *
17 * Object key is stable + overwritten on every upload:
18 * auth-branding/<projectId>/logo
19 *
20 * We reuse the same SigV4 signing path as `services/storage.ts`
21 * (`lib/s3-presign.ts` → Bun's native S3 client) rather than constructing
22 * a second S3 client. The content-type set on PUT round-trips through
23 * MinIO natively and is read back off the GET response, so we don't need a
24 * separate metadata sidecar.
25 */
26
27export const LOGO_MAX_BYTES = 1024 * 1024; // 1 MiB
28
29export const ALLOWED_LOGO_TYPES = [
30 'image/png',
31 'image/jpeg',
32 'image/webp',
33 'image/svg+xml',
34] as const;
35
36export type AllowedLogoType = (typeof ALLOWED_LOGO_TYPES)[number];
37
38interface StorageEnv {
39 endpoint: string;
40 region: string;
41 bucket: string;
42 accessKey: string;
43 secretKey: string;
44}
45
46/**
47 * Read MinIO config from env. Mirrors the private `requireStorageEnv` in
48 * `services/storage.ts` (not exported there); the env vars are the shared
49 * source of truth. Server-side ops use the INTERNAL endpoint — these
50 * fetches never leave the api host, so they don't bounce through traefik.
51 */
52function requireStorageEnv(): StorageEnv {
53 const endpoint = env.BRIVEN_MINIO_ENDPOINT;
54 const accessKey = env.BRIVEN_MINIO_ACCESS_KEY;
55 const secretKey = env.BRIVEN_MINIO_SECRET_KEY;
56 if (!endpoint || !accessKey || !secretKey) {
57 throw new ValidationError(
58 'object storage is not configured on this api (BRIVEN_MINIO_* env vars missing)',
59 );
60 }
61 return {
62 endpoint,
63 region: env.BRIVEN_MINIO_REGION ?? 'us-east-1',
64 bucket: env.BRIVEN_MINIO_BUCKET ?? 'briven',
65 accessKey,
66 secretKey,
67 };
68}
69
70/** Re-export so the route can return a `503 not_configured` cleanly. */
71export { isStorageConfigured };
72
73function objectKey(projectId: string): string {
74 return `auth-branding/${projectId}/logo`;
75}
76
77/**
78 * Pure validator — unit-tested without any network/postgres. Throws a
79 * `ValidationError` (400 at the route) on a disallowed content-type or an
80 * over-cap / non-positive size.
81 */
82export function validateLogoUpload(input: { contentType: string; size: number }): void {
83 // Normalise: a browser may append `; charset=...` for svg. Compare the
84 // bare media type so `image/svg+xml; charset=utf-8` still validates.
85 const bare = input.contentType.split(';', 1)[0]!.trim().toLowerCase();
86 if (!(ALLOWED_LOGO_TYPES as readonly string[]).includes(bare)) {
87 throw new ValidationError(
88 `logo must be one of ${ALLOWED_LOGO_TYPES.join(', ')} (got: ${bare || 'none'})`,
89 );
90 }
91 if (!Number.isFinite(input.size) || input.size <= 0) {
92 throw new ValidationError('logo file is empty');
93 }
94 if (input.size > LOGO_MAX_BYTES) {
95 throw new ValidationError(`logo exceeds the ${LOGO_MAX_BYTES} byte (1 MiB) cap`);
96 }
97}
98
99/**
100 * Build the STABLE public URL for a project's logo, cache-busted with the
101 * current unix-seconds so a re-upload busts browser + edge caches. This is
102 * what gets stored in `branding.logoUrl`. Points at the public serve route
103 * on the api origin (world-readable; no auth).
104 */
105export function brandingLogoPublicUrl(projectId: string): string {
106 const v = Math.floor(Date.now() / 1000);
107 return `${env.BRIVEN_API_ORIGIN}/v1/projects/${projectId}/auth/branding/logo?v=${v}`;
108}
109
110/**
111 * Store (overwrite) the logo object in MinIO. The content-type is tied
112 * into the signed PUT and persisted on the object, so the serve route can
113 * hand it straight back.
114 */
115export async function putBrandingLogo(input: {
116 projectId: string;
117 bytes: Uint8Array;
118 contentType: string;
119}): Promise<void> {
120 const bare = input.contentType.split(';', 1)[0]!.trim().toLowerCase();
121 const cfg = requireStorageEnv();
122 const url = presignS3Url({
123 endpoint: cfg.endpoint,
124 region: cfg.region,
125 bucket: cfg.bucket,
126 key: objectKey(input.projectId),
127 method: 'PUT',
128 accessKey: cfg.accessKey,
129 secretKey: cfg.secretKey,
130 expiresIn: 60,
131 contentType: bare,
132 });
133 const res = await fetch(url, {
134 method: 'PUT',
135 body: input.bytes,
136 headers: { 'content-type': bare },
137 });
138 if (!res.ok) {
139 const body = await res.text().catch(() => '');
140 throw new Error(`minio logo put failed: ${res.status} ${body.slice(0, 200)}`);
141 }
142}
143
144export interface BrandingLogoObject {
145 bytes: Uint8Array;
146 contentType: string;
147}
148
149/**
150 * Fetch the stored logo. Returns `null` when the object does not exist
151 * (so the route can 404). The content-type comes back off MinIO's GET
152 * response — the same value set on PUT.
153 */
154export async function getBrandingLogo(projectId: string): Promise<BrandingLogoObject | null> {
155 const cfg = requireStorageEnv();
156 const url = presignS3Url({
157 endpoint: cfg.endpoint,
158 region: cfg.region,
159 bucket: cfg.bucket,
160 key: objectKey(projectId),
161 method: 'GET',
162 accessKey: cfg.accessKey,
163 secretKey: cfg.secretKey,
164 expiresIn: 60,
165 });
166 const res = await fetch(url, { method: 'GET' });
167 if (res.status === 404 || res.status === 403) {
168 // MinIO returns 404 for a missing key; some configs 403 on a missing
169 // object. Either way: treat as "no logo".
170 return null;
171 }
172 if (!res.ok) {
173 const body = await res.text().catch(() => '');
174 throw new Error(`minio logo get failed: ${res.status} ${body.slice(0, 200)}`);
175 }
176 const contentType = res.headers.get('content-type') ?? 'application/octet-stream';
177 const bytes = new Uint8Array(await res.arrayBuffer());
178 return { bytes, contentType };
179}
180
181/**
182 * Delete the stored logo. Idempotent — a missing object (404) is success,
183 * mirroring `services/storage.ts:deleteFile`.
184 */
185export async function deleteBrandingLogo(projectId: string): Promise<void> {
186 const cfg = requireStorageEnv();
187 const url = presignS3Url({
188 endpoint: cfg.endpoint,
189 region: cfg.region,
190 bucket: cfg.bucket,
191 key: objectKey(projectId),
192 method: 'DELETE',
193 accessKey: cfg.accessKey,
194 secretKey: cfg.secretKey,
195 expiresIn: 60,
196 });
197 const res = await fetch(url, { method: 'DELETE' });
198 if (!res.ok && res.status !== 404) {
199 const body = await res.text().catch(() => '');
200 throw new Error(`minio logo delete failed: ${res.status} ${body.slice(0, 200)}`);
201 }
202}