auth-user-avatar.ts121 lines · main
1/**
2 * User avatar upload — Phase 7.2.
3 *
4 * Generates presigned S3 URLs so the browser can upload directly to MinIO.
5 * After upload the client calls PATCH /v1/auth-tenant/user/avatar to store
6 * the public serve URL on the user row.
7 *
8 * Object key: auth-avatars/<projectId>/<userId>/<uuid>
9 */
10
11import { env } from '../env.js';
12import { presignS3Url } from '../lib/s3-presign.js';
13import { isStorageConfigured } from './storage.js';
14import { runInProjectDatabase } from '../db/data-plane.js';
15
16export const AVATAR_MAX_BYTES = 2 * 1024 * 1024; // 2 MiB
17export const ALLOWED_AVATAR_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const;
18
19interface StorageEnv {
20 endpoint: string;
21 region: string;
22 bucket: string;
23 accessKey: string;
24 secretKey: string;
25}
26
27function requireStorageEnv(): StorageEnv {
28 const endpoint = env.BRIVEN_MINIO_ENDPOINT;
29 const accessKey = env.BRIVEN_MINIO_ACCESS_KEY;
30 const secretKey = env.BRIVEN_MINIO_SECRET_KEY;
31 if (!endpoint || !accessKey || !secretKey) {
32 throw new Error('object storage is not configured on this api (BRIVEN_MINIO_* env vars missing)');
33 }
34 return {
35 endpoint,
36 region: env.BRIVEN_MINIO_REGION ?? 'us-east-1',
37 bucket: env.BRIVEN_MINIO_BUCKET ?? 'briven',
38 accessKey,
39 secretKey,
40 };
41}
42
43export { isStorageConfigured };
44
45function objectKey(projectId: string, userId: string, fileId: string): string {
46 return `auth-avatars/${projectId}/${userId}/${fileId}`;
47}
48
49export function avatarPublicUrl(projectId: string, userId: string, fileId: string): string {
50 return `${env.BRIVEN_API_ORIGIN}/v1/auth-tenant/user/avatar/serve?p=${projectId}&u=${userId}&f=${fileId}`;
51}
52
53export interface PresignResult {
54 uploadUrl: string;
55 publicUrl: string;
56}
57
58export function generateAvatarPresign(
59 projectId: string,
60 userId: string,
61 contentType: string,
62): PresignResult {
63 const bare = contentType.split(';', 1)[0]!.trim().toLowerCase();
64 if (!(ALLOWED_AVATAR_TYPES as readonly string[]).includes(bare)) {
65 throw new Error(`avatar must be one of ${ALLOWED_AVATAR_TYPES.join(', ')}`);
66 }
67
68 const cfg = requireStorageEnv();
69 const fileId = crypto.randomUUID();
70 const key = objectKey(projectId, userId, fileId);
71
72 const uploadUrl = presignS3Url({
73 endpoint: cfg.endpoint,
74 region: cfg.region,
75 bucket: cfg.bucket,
76 key,
77 method: 'PUT',
78 accessKey: cfg.accessKey,
79 secretKey: cfg.secretKey,
80 expiresIn: 300, // 5 minutes
81 contentType: bare,
82 });
83
84 return { uploadUrl, publicUrl: avatarPublicUrl(projectId, userId, fileId) };
85}
86
87export async function updateUserAvatar(projectId: string, userId: string, imageUrl: string | null): Promise<void> {
88 await runInProjectDatabase(projectId, async (tx) => {
89 await tx.unsafe(
90 `UPDATE "_briven_auth_users" SET image = $1, updated_at = now() WHERE id = $2`,
91 [imageUrl, userId] as never,
92 );
93 });
94}
95
96export async function getAvatarImage(
97 projectId: string,
98 userId: string,
99 fileId: string,
100): Promise<{ bytes: Uint8Array; contentType: string } | null> {
101 const cfg = requireStorageEnv();
102 const url = presignS3Url({
103 endpoint: cfg.endpoint,
104 region: cfg.region,
105 bucket: cfg.bucket,
106 key: objectKey(projectId, userId, fileId),
107 method: 'GET',
108 accessKey: cfg.accessKey,
109 secretKey: cfg.secretKey,
110 expiresIn: 60,
111 });
112 const res = await fetch(url, { method: 'GET' });
113 if (res.status === 404 || res.status === 403) return null;
114 if (!res.ok) {
115 const body = await res.text().catch(() => '');
116 throw new Error(`minio avatar get failed: ${res.status} ${body.slice(0, 200)}`);
117 }
118 const contentType = res.headers.get('content-type') ?? 'application/octet-stream';
119 const bytes = new Uint8Array(await res.arrayBuffer());
120 return { bytes, contentType };
121}