auth-branding-logo.ts247 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 (briven-engine).
9 *
10 * Upload path (dashboard): POST /v1/auth-core/projects/:projectId/branding/logo
11 * Public serve: GET /v1/projects/:id/auth/branding/logo (brandingPublicRouter)
12 *
13 * Object key: auth-branding/<projectId>/logo
14 *
15 * Uses the same SigV4 path as `services/storage.ts` (presign + fetch).
16 * That path is proven against live MinIO; Bun S3File.write has been flaky
17 * with SignatureDoesNotMatch when env/signing context drifts.
18 */
19
20export const LOGO_MAX_BYTES = 1024 * 1024; // 1 MiB
21
22export const ALLOWED_LOGO_TYPES = [
23 'image/png',
24 'image/jpeg',
25 'image/webp',
26 'image/svg+xml',
27] as const;
28
29export type AllowedLogoType = (typeof ALLOWED_LOGO_TYPES)[number];
30
31interface StorageEnv {
32 endpoint: string;
33 region: string;
34 bucket: string;
35 accessKey: string;
36 secretKey: string;
37}
38
39function requireStorageEnv(): StorageEnv {
40 const endpoint = env.BRIVEN_MINIO_ENDPOINT;
41 const accessKey = env.BRIVEN_MINIO_ACCESS_KEY;
42 const secretKey = env.BRIVEN_MINIO_SECRET_KEY;
43 if (!endpoint || !accessKey || !secretKey) {
44 throw new ValidationError(
45 'object storage is not configured on this api (BRIVEN_MINIO_* env vars missing)',
46 );
47 }
48 return {
49 endpoint,
50 region: env.BRIVEN_MINIO_REGION ?? 'us-east-1',
51 bucket: env.BRIVEN_MINIO_BUCKET ?? 'briven',
52 accessKey,
53 secretKey,
54 };
55}
56
57export { isStorageConfigured };
58
59function objectKey(projectId: string): string {
60 return `auth-branding/${projectId}/logo`;
61}
62
63/**
64 * When MinIO / Bun omits Content-Type (or returns application/octet-stream),
65 * sniff from magic bytes so `<img>` can render. Browsers refuse images with
66 * `nosniff` + `application/octet-stream`.
67 */
68export function sniffLogoContentType(
69 bytes: Uint8Array,
70 headerType?: string | null,
71): string {
72 const bare = (headerType ?? '').split(';', 1)[0]!.trim().toLowerCase();
73 if ((ALLOWED_LOGO_TYPES as readonly string[]).includes(bare)) {
74 return bare;
75 }
76 if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
77 return 'image/jpeg';
78 }
79 if (
80 bytes.length >= 8 &&
81 bytes[0] === 0x89 &&
82 bytes[1] === 0x50 &&
83 bytes[2] === 0x4e &&
84 bytes[3] === 0x47
85 ) {
86 return 'image/png';
87 }
88 if (
89 bytes.length >= 12 &&
90 bytes[0] === 0x52 &&
91 bytes[1] === 0x49 &&
92 bytes[2] === 0x46 &&
93 bytes[3] === 0x46 &&
94 bytes[8] === 0x57 &&
95 bytes[9] === 0x45 &&
96 bytes[10] === 0x42 &&
97 bytes[11] === 0x50
98 ) {
99 return 'image/webp';
100 }
101 // SVG is text — look for <svg or <?xml…svg in the first 256 bytes.
102 const head = new TextDecoder('utf-8', { fatal: false })
103 .decode(bytes.subarray(0, Math.min(bytes.length, 256)))
104 .toLowerCase();
105 if (head.includes('<svg') || (head.includes('<?xml') && head.includes('svg'))) {
106 return 'image/svg+xml';
107 }
108 return bare || 'application/octet-stream';
109}
110
111/**
112 * Pure validator — unit-tested without any network/postgres. Throws a
113 * `ValidationError` (400 at the route) on a disallowed content-type or an
114 * over-cap / non-positive size.
115 */
116export function validateLogoUpload(input: {
117 contentType: string;
118 size: number;
119}): void {
120 const bare = input.contentType.split(';', 1)[0]!.trim().toLowerCase();
121 if (!(ALLOWED_LOGO_TYPES as readonly string[]).includes(bare)) {
122 throw new ValidationError(
123 `logo must be one of ${ALLOWED_LOGO_TYPES.join(', ')} (got: ${bare || 'none'})`,
124 );
125 }
126 if (!Number.isFinite(input.size) || input.size <= 0) {
127 throw new ValidationError('logo file is empty');
128 }
129 if (input.size > LOGO_MAX_BYTES) {
130 throw new ValidationError(
131 `logo exceeds the ${LOGO_MAX_BYTES} byte (1 MiB) cap`,
132 );
133 }
134}
135
136/**
137 * STABLE public URL for a project's logo (served by brandingPublicRouter).
138 * Cache-busted with unix seconds.
139 */
140export function brandingLogoPublicUrl(projectId: string): string {
141 const v = Math.floor(Date.now() / 1000);
142 return `${env.BRIVEN_API_ORIGIN}/v1/projects/${projectId}/auth/branding/logo?v=${v}`;
143}
144
145/**
146 * Store (overwrite) the logo object in MinIO via signed PUT (same path as
147 * project storage uploads).
148 */
149export async function putBrandingLogo(input: {
150 projectId: string;
151 bytes: Uint8Array;
152 contentType: string;
153}): Promise<void> {
154 const bare = input.contentType.split(';', 1)[0]!.trim().toLowerCase();
155 const cfg = requireStorageEnv();
156 const key = objectKey(input.projectId);
157 const url = presignS3Url({
158 endpoint: cfg.endpoint,
159 region: cfg.region,
160 bucket: cfg.bucket,
161 key,
162 method: 'PUT',
163 accessKey: cfg.accessKey,
164 secretKey: cfg.secretKey,
165 expiresIn: 60,
166 contentType: bare,
167 });
168 // Body must be a plain ArrayBuffer / Buffer — some runtimes mishandle
169 // Uint8Array views when signing Content-Length / payload hash.
170 const body = input.bytes.buffer.slice(
171 input.bytes.byteOffset,
172 input.bytes.byteOffset + input.bytes.byteLength,
173 ) as ArrayBuffer;
174 const res = await fetch(url, {
175 method: 'PUT',
176 body,
177 headers: { 'content-type': bare },
178 });
179 if (!res.ok) {
180 const text = await res.text().catch(() => '');
181 throw new Error(
182 `minio logo put failed: ${res.status} ${text.slice(0, 200)}`,
183 );
184 }
185}
186
187export interface BrandingLogoObject {
188 bytes: Uint8Array;
189 contentType: string;
190}
191
192/**
193 * Fetch the stored logo. Returns null when missing.
194 */
195export async function getBrandingLogo(
196 projectId: string,
197): Promise<BrandingLogoObject | null> {
198 const cfg = requireStorageEnv();
199 const url = presignS3Url({
200 endpoint: cfg.endpoint,
201 region: cfg.region,
202 bucket: cfg.bucket,
203 key: objectKey(projectId),
204 method: 'GET',
205 accessKey: cfg.accessKey,
206 secretKey: cfg.secretKey,
207 expiresIn: 60,
208 });
209 const res = await fetch(url, { method: 'GET' });
210 if (res.status === 404 || res.status === 403) return null;
211 if (!res.ok) {
212 const text = await res.text().catch(() => '');
213 throw new Error(
214 `minio logo get failed: ${res.status} ${text.slice(0, 200)}`,
215 );
216 }
217 const headerType = res.headers.get('content-type');
218 const bytes = new Uint8Array(await res.arrayBuffer());
219 return {
220 bytes,
221 contentType: sniffLogoContentType(bytes, headerType),
222 };
223}
224
225/**
226 * Delete the stored logo. Idempotent.
227 */
228export async function deleteBrandingLogo(projectId: string): Promise<void> {
229 const cfg = requireStorageEnv();
230 const url = presignS3Url({
231 endpoint: cfg.endpoint,
232 region: cfg.region,
233 bucket: cfg.bucket,
234 key: objectKey(projectId),
235 method: 'DELETE',
236 accessKey: cfg.accessKey,
237 secretKey: cfg.secretKey,
238 expiresIn: 60,
239 });
240 const res = await fetch(url, { method: 'DELETE' });
241 if (!res.ok && res.status !== 404) {
242 const text = await res.text().catch(() => '');
243 throw new Error(
244 `minio logo delete failed: ${res.status} ${text.slice(0, 200)}`,
245 );
246 }
247}