Fixing images
This commit is contained in:
+117
-5
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user