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 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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user