Fixing images

This commit is contained in:
blaisadmin
2026-05-02 11:50:31 -04:00
parent fc6d7c2762
commit 01541c5f5c
+117 -5
View File
@@ -319,6 +319,7 @@ const normalizeBandId = (value?: string | null) => {
const normalizeEmail = (value: string) => value.trim().toLowerCase(); const normalizeEmail = (value: string) => value.trim().toLowerCase();
const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex'); const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
const createSessionToken = () => crypto.randomBytes(32).toString('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 createIntegrationToken = () => `flpt_${crypto.randomBytes(24).toString('hex')}`;
const createRandomId = () => crypto.randomUUID(); const createRandomId = () => crypto.randomUUID();
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url'); const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
@@ -479,6 +480,66 @@ const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({
createdAt: row.created_at, 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) => { const getBirdPhotoUrl = (row: BirdRow) => {
if (!row.photo_object_key) { if (!row.photo_object_key) {
return row.photo_data_url; return row.photo_data_url;
@@ -490,11 +551,9 @@ const getBirdPhotoUrl = (row: BirdRow) => {
return row.photo_data_url; return row.photo_data_url;
} }
return getSignedS3ObjectUrl({ const photoUrl = new URL(`${backendBaseUrl}/api/birds/${row.id}/photo`);
config: s3Config, photoUrl.searchParams.set('token', signBirdPhotoAccessToken(row));
objectKey: row.photo_object_key, return photoUrl.toString();
expiresInSeconds: 15 * 60,
});
}; };
const normalizeBird = (row: BirdRow) => ({ 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) => { app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdSchema.safeParse(req.body); const parsed = birdSchema.safeParse(req.body);