Barazo AppView backend
barazo.forum
1import type { FastifyPluginCallback } from 'fastify'
2import sharp from 'sharp'
3import { badRequest, errorResponseSchema } from '../lib/api-errors.js'
4import { communityProfiles } from '../db/schema/community-profiles.js'
5
6const ALLOWED_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif'])
7
8const AVATAR_SIZE = { width: 400, height: 400 }
9const BANNER_SIZE = { width: 1500, height: 500 }
10
11// ---------------------------------------------------------------------------
12// OpenAPI JSON Schema definitions
13// ---------------------------------------------------------------------------
14
15const uploadResponseJsonSchema = {
16 type: 'object' as const,
17 properties: {
18 url: { type: 'string' as const },
19 },
20}
21
22const paramsJsonSchema = {
23 type: 'object' as const,
24 required: ['communityDid'],
25 properties: {
26 communityDid: { type: 'string' as const },
27 },
28}
29
30// ---------------------------------------------------------------------------
31// Upload routes plugin
32// ---------------------------------------------------------------------------
33
34/**
35 * Avatar and banner upload endpoints for community profiles.
36 *
37 * - POST /api/communities/:communityDid/profile/avatar
38 * - POST /api/communities/:communityDid/profile/banner
39 */
40export function uploadRoutes(): FastifyPluginCallback {
41 return (app, _opts, done) => {
42 const { db, authMiddleware, storage, env } = app
43 const maxSize = env.UPLOAD_MAX_SIZE_BYTES
44
45 // -----------------------------------------------------------------
46 // POST /api/communities/:communityDid/profile/avatar
47 // -----------------------------------------------------------------
48
49 app.post(
50 '/api/communities/:communityDid/profile/avatar',
51 {
52 preHandler: [authMiddleware.requireAuth],
53 schema: {
54 tags: ['Uploads'],
55 summary: 'Upload community profile avatar',
56 security: [{ bearerAuth: [] }],
57 consumes: ['multipart/form-data'],
58 params: paramsJsonSchema,
59 response: {
60 200: uploadResponseJsonSchema,
61 400: errorResponseSchema,
62 401: errorResponseSchema,
63 },
64 },
65 },
66 async (request, reply) => {
67 const requestUser = request.user
68 if (!requestUser) {
69 return reply.status(401).send({ error: 'Authentication required' })
70 }
71
72 const { communityDid } = request.params as { communityDid: string }
73
74 const file = await request.file()
75 if (!file) throw badRequest('No file uploaded')
76 if (!ALLOWED_MIMES.has(file.mimetype)) {
77 throw badRequest('File must be JPEG, PNG, WebP, or GIF')
78 }
79
80 const buffer = await file.toBuffer()
81 if (buffer.length > maxSize) {
82 throw badRequest(`File too large (max ${String(Math.round(maxSize / 1024 / 1024))}MB)`)
83 }
84
85 const processed = await sharp(buffer)
86 .resize(AVATAR_SIZE.width, AVATAR_SIZE.height, { fit: 'cover' })
87 .webp({ quality: 85 })
88 .toBuffer()
89
90 const url = await storage.store(processed, 'image/webp', 'avatars')
91
92 const now = new Date()
93 await db
94 .insert(communityProfiles)
95 .values({
96 did: requestUser.did,
97 communityDid,
98 avatarUrl: url,
99 updatedAt: now,
100 })
101 .onConflictDoUpdate({
102 target: [communityProfiles.did, communityProfiles.communityDid],
103 set: { avatarUrl: url, updatedAt: now },
104 })
105
106 return reply.status(200).send({ url })
107 }
108 )
109
110 // -----------------------------------------------------------------
111 // POST /api/communities/:communityDid/profile/banner
112 // -----------------------------------------------------------------
113
114 app.post(
115 '/api/communities/:communityDid/profile/banner',
116 {
117 preHandler: [authMiddleware.requireAuth],
118 schema: {
119 tags: ['Uploads'],
120 summary: 'Upload community profile banner',
121 security: [{ bearerAuth: [] }],
122 consumes: ['multipart/form-data'],
123 params: paramsJsonSchema,
124 response: {
125 200: uploadResponseJsonSchema,
126 400: errorResponseSchema,
127 401: errorResponseSchema,
128 },
129 },
130 },
131 async (request, reply) => {
132 const requestUser = request.user
133 if (!requestUser) {
134 return reply.status(401).send({ error: 'Authentication required' })
135 }
136
137 const { communityDid } = request.params as { communityDid: string }
138
139 const file = await request.file()
140 if (!file) throw badRequest('No file uploaded')
141 if (!ALLOWED_MIMES.has(file.mimetype)) {
142 throw badRequest('File must be JPEG, PNG, WebP, or GIF')
143 }
144
145 const buffer = await file.toBuffer()
146 if (buffer.length > maxSize) {
147 throw badRequest(`File too large (max ${String(Math.round(maxSize / 1024 / 1024))}MB)`)
148 }
149
150 const processed = await sharp(buffer)
151 .resize(BANNER_SIZE.width, BANNER_SIZE.height, { fit: 'cover' })
152 .webp({ quality: 85 })
153 .toBuffer()
154
155 const url = await storage.store(processed, 'image/webp', 'banners')
156
157 const now = new Date()
158 await db
159 .insert(communityProfiles)
160 .values({
161 did: requestUser.did,
162 communityDid,
163 bannerUrl: url,
164 updatedAt: now,
165 })
166 .onConflictDoUpdate({
167 target: [communityProfiles.did, communityProfiles.communityDid],
168 set: { bannerUrl: url, updatedAt: now },
169 })
170
171 return reply.status(200).send({ url })
172 }
173 )
174
175 done()
176 }
177}