auth-branding-logo.ts247 lines · main
| 1 | import { ValidationError } from '@briven/shared'; |
| 2 | |
| 3 | import { env } from '../env.js'; |
| 4 | import { presignS3Url } from '../lib/s3-presign.js'; |
| 5 | import { 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 | |
| 20 | export const LOGO_MAX_BYTES = 1024 * 1024; // 1 MiB |
| 21 | |
| 22 | export const ALLOWED_LOGO_TYPES = [ |
| 23 | 'image/png', |
| 24 | 'image/jpeg', |
| 25 | 'image/webp', |
| 26 | 'image/svg+xml', |
| 27 | ] as const; |
| 28 | |
| 29 | export type AllowedLogoType = (typeof ALLOWED_LOGO_TYPES)[number]; |
| 30 | |
| 31 | interface StorageEnv { |
| 32 | endpoint: string; |
| 33 | region: string; |
| 34 | bucket: string; |
| 35 | accessKey: string; |
| 36 | secretKey: string; |
| 37 | } |
| 38 | |
| 39 | function 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 | |
| 57 | export { isStorageConfigured }; |
| 58 | |
| 59 | function 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 | */ |
| 68 | export 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 | */ |
| 116 | export 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 | */ |
| 140 | export 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 | */ |
| 149 | export 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 | |
| 187 | export interface BrandingLogoObject { |
| 188 | bytes: Uint8Array; |
| 189 | contentType: string; |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * Fetch the stored logo. Returns null when missing. |
| 194 | */ |
| 195 | export 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 | */ |
| 228 | export 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 | } |