diff --git a/backend/src/app.ts b/backend/src/app.ts index bf6b1a8..3e74e13 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -319,6 +319,7 @@ const normalizeBandId = (value?: string | null) => { const normalizeEmail = (value: string) => value.trim().toLowerCase(); const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex'); const createSessionToken = () => crypto.randomBytes(32).toString('hex'); +const photoAccessSecret = process.env.PHOTO_ACCESS_SECRET?.trim() || process.env.S3_SECRET_ACCESS_KEY?.trim() || createSessionToken(); const createIntegrationToken = () => `flpt_${crypto.randomBytes(24).toString('hex')}`; const createRandomId = () => crypto.randomUUID(); const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url'); @@ -479,6 +480,66 @@ const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({ createdAt: row.created_at, }); +const signBirdPhotoAccessToken = (row: BirdRow) => { + if (!row.photo_object_key) { + return ''; + } + + const expiresAt = Math.floor(Date.now() / 1000) + 15 * 60; + const payload = Buffer.from( + JSON.stringify({ + birdId: row.id, + workspaceId: row.workspace_id, + objectKey: row.photo_object_key, + expiresAt, + }), + ).toString('base64url'); + const signature = crypto.createHmac('sha256', photoAccessSecret).update(payload).digest('base64url'); + + return `${payload}.${signature}`; +}; + +const verifyBirdPhotoAccessToken = (token: string) => { + const [payload, signature] = token.split('.'); + + if (!payload || !signature) { + return null; + } + + const expectedSignature = crypto.createHmac('sha256', photoAccessSecret).update(payload).digest('base64url'); + + const signatureBuffer = Buffer.from(signature); + const expectedSignatureBuffer = Buffer.from(expectedSignature); + + if (signatureBuffer.length !== expectedSignatureBuffer.length || !crypto.timingSafeEqual(signatureBuffer, expectedSignatureBuffer)) { + return null; + } + + const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { + birdId?: unknown; + workspaceId?: unknown; + objectKey?: unknown; + expiresAt?: unknown; + }; + + if ( + typeof parsed.birdId !== 'string' || + typeof parsed.workspaceId !== 'number' || + typeof parsed.objectKey !== 'string' || + typeof parsed.expiresAt !== 'number' || + parsed.expiresAt < Math.floor(Date.now() / 1000) + ) { + return null; + } + + return parsed as { + birdId: string; + workspaceId: number; + objectKey: string; + expiresAt: number; + }; +}; + const getBirdPhotoUrl = (row: BirdRow) => { if (!row.photo_object_key) { return row.photo_data_url; @@ -490,11 +551,9 @@ const getBirdPhotoUrl = (row: BirdRow) => { return row.photo_data_url; } - return getSignedS3ObjectUrl({ - config: s3Config, - objectKey: row.photo_object_key, - expiresInSeconds: 15 * 60, - }); + const photoUrl = new URL(`${backendBaseUrl}/api/birds/${row.id}/photo`); + photoUrl.searchParams.set('token', signBirdPhotoAccessToken(row)); + return photoUrl.toString(); }; const normalizeBird = (row: BirdRow) => ({ @@ -2677,6 +2736,59 @@ app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: Nex } }); +app.get('/api/birds/:birdId/photo', async (req: Request, res: Response, next: NextFunction) => { + try { + const token = typeof req.query.token === 'string' ? req.query.token : ''; + const photoAccess = verifyBirdPhotoAccessToken(token); + + if (!photoAccess || photoAccess.birdId !== req.params.birdId) { + res.status(403).json({ error: 'Photo link expired or invalid.' }); + return; + } + + const bird = await getBirdById(photoAccess.birdId, photoAccess.workspaceId); + + if (!bird || bird.photo_object_key !== photoAccess.objectKey) { + res.status(404).json({ error: 'Photo not found.' }); + return; + } + + const s3Config = getS3ImageStorageConfig(); + + if (!s3Config) { + res.status(503).json({ error: 'Image storage is not configured.' }); + return; + } + + const signedUrl = getSignedS3ObjectUrl({ + config: s3Config, + objectKey: bird.photo_object_key, + expiresInSeconds: 60, + }); + const imageResponse = await fetch(signedUrl); + + if (!imageResponse.ok) { + res.status(imageResponse.status).json({ error: 'Unable to load bird photo.' }); + return; + } + + const contentType = imageResponse.headers.get('content-type') || bird.photo_content_type || 'application/octet-stream'; + const contentLength = imageResponse.headers.get('content-length'); + const imageBuffer = Buffer.from(await imageResponse.arrayBuffer()); + + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'private, max-age=300'); + + if (contentLength) { + res.setHeader('Content-Length', contentLength); + } + + res.send(imageBuffer); + } catch (error) { + next(error); + } +}); + app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { const parsed = birdSchema.safeParse(req.body);