Barazo AppView backend barazo.forum
at main 177 lines 5.7 kB view raw
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}