Compare commits
2 Commits
d2d130d960
...
22f344a998
| Author | SHA1 | Date | |
|---|---|---|---|
| 22f344a998 | |||
| 1bb3002baf |
@@ -2,6 +2,14 @@ POSTGRES_DB=flockpal
|
||||
POSTGRES_USER=flockpal
|
||||
POSTGRES_PASSWORD=change_me_for_production
|
||||
REDIS_URL=redis://redis:6379
|
||||
IMAGE_STORAGE_PROVIDER=database
|
||||
S3_ENDPOINT=
|
||||
S3_REGION=
|
||||
S3_BUCKET=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_PUBLIC_BASE_URL=
|
||||
S3_KEY_PREFIX=bird-photos
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
BACKEND_URL=http://localhost:5000
|
||||
VITE_API_BASE_URL=http://localhost:5000/api
|
||||
|
||||
@@ -87,6 +87,12 @@ curl -H "Authorization: Bearer <admin-token>" https://your-host/api/metrics
|
||||
- `BACKEND_URL`
|
||||
- `VITE_API_BASE_URL`
|
||||
- `REDIS_URL`
|
||||
- `IMAGE_STORAGE_PROVIDER`
|
||||
- `S3_ENDPOINT`
|
||||
- `S3_REGION`
|
||||
- `S3_BUCKET`
|
||||
- `S3_ACCESS_KEY_ID`
|
||||
- `S3_SECRET_ACCESS_KEY`
|
||||
- `RESCUE_ONBOARDING_WEBHOOK_URL`
|
||||
2. Build and start the production stack:
|
||||
|
||||
@@ -104,6 +110,28 @@ Compose includes a Redis service at `redis://redis:6379` and passes that value t
|
||||
|
||||
Scheduled milestone reminders are enqueued through Redis with a per-date job id, then processed by the worker. This keeps scheduled work out of API containers and prevents duplicate scheduled jobs when the API is scaled horizontally. Redis can also support later shared rate-limit state and short-lived cache entries.
|
||||
|
||||
## Image storage
|
||||
|
||||
FlockPal currently keeps bird photos in Postgres as `photo_data_url`. The schema also has S3 object metadata columns so image storage can move to Wasabi/S3 without changing the bird record contract.
|
||||
|
||||
Set these when Wasabi image storage is ready:
|
||||
|
||||
- `IMAGE_STORAGE_PROVIDER=s3`
|
||||
- `S3_ENDPOINT=https://s3.<wasabi-region>.wasabisys.com`
|
||||
- `S3_REGION=<wasabi-region>`
|
||||
- `S3_BUCKET=<bucket-name>`
|
||||
- `S3_ACCESS_KEY_ID=<access-key>`
|
||||
- `S3_SECRET_ACCESS_KEY=<secret-key>`
|
||||
- `S3_PUBLIC_BASE_URL=<optional CDN or public bucket base URL; leave blank for private signed URLs>`
|
||||
- `S3_KEY_PREFIX=bird-photos`
|
||||
|
||||
Use a dedicated private bucket and access key for FlockPal images. Grant only the S3 permissions the app needs for that bucket. When `S3_PUBLIC_BASE_URL` is blank, FlockPal stores private object keys and returns short-lived signed URLs for bird photos.
|
||||
|
||||
Bucket settings recommendation:
|
||||
|
||||
- Enable bucket versioning if you want rollback protection from accidental overwrites or deletes. Add a lifecycle policy once upload volume is known because every object version contributes to stored data.
|
||||
- Do not enable Object Lock on the primary app image bucket unless there is a strict legal/compliance retention requirement. Object Lock must be enabled when creating the bucket, depends on versioning, and can make user-requested image deletion or replacement harder.
|
||||
|
||||
## Worker process
|
||||
|
||||
The API container does not run scheduled reminder loops. Background reminders run in the `worker` service so the API can be scaled horizontally without multiple API containers sending duplicate scheduled emails.
|
||||
|
||||
+168
-4
@@ -60,6 +60,13 @@ import {
|
||||
updateVetVisitForBird,
|
||||
} from './repositories/birdRepository.js';
|
||||
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
||||
import {
|
||||
buildBirdPhotoObjectKey,
|
||||
getImageExtensionFromContentType,
|
||||
getImageStorageProvider,
|
||||
getS3ImageStorageConfig,
|
||||
} from './storage/imageStorageConfig.js';
|
||||
import { deleteS3Object, getSignedS3ObjectUrl, putS3Object } from './storage/s3Client.js';
|
||||
import {
|
||||
cancelRescueVerificationRequest,
|
||||
claimWorkspaceInvites,
|
||||
@@ -160,6 +167,7 @@ const photoDataUrlSchema = z
|
||||
.string()
|
||||
.regex(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/)
|
||||
.max(1_500_000);
|
||||
const photoUrlSchema = z.string().trim().url().max(2000);
|
||||
|
||||
const magicLinkRequestSchema = z.object({
|
||||
name: z.string().trim().max(160).optional().or(z.literal('')),
|
||||
@@ -230,7 +238,7 @@ const birdSchema = z.object({
|
||||
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
||||
gotchaDay: dateStringSchema.optional().or(z.literal('')),
|
||||
chartColor: chartColorSchema.optional(),
|
||||
photoDataUrl: photoDataUrlSchema.optional().or(z.literal('')),
|
||||
photoDataUrl: z.union([photoDataUrlSchema, photoUrlSchema, z.literal('')]).optional(),
|
||||
notifyOnDob: z.boolean().optional(),
|
||||
notifyOnGotchaDay: z.boolean().optional(),
|
||||
});
|
||||
@@ -471,6 +479,24 @@ const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
|
||||
const getBirdPhotoUrl = (row: BirdRow) => {
|
||||
if (!row.photo_object_key) {
|
||||
return row.photo_data_url;
|
||||
}
|
||||
|
||||
const s3Config = getS3ImageStorageConfig();
|
||||
|
||||
if (!s3Config) {
|
||||
return row.photo_data_url;
|
||||
}
|
||||
|
||||
return getSignedS3ObjectUrl({
|
||||
config: s3Config,
|
||||
objectKey: row.photo_object_key,
|
||||
expiresInSeconds: 15 * 60,
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeBird = (row: BirdRow) => ({
|
||||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
@@ -481,7 +507,10 @@ const normalizeBird = (row: BirdRow) => ({
|
||||
dateOfBirth: row.date_of_birth,
|
||||
gotchaDay: row.gotcha_day,
|
||||
chartColor: row.chart_color,
|
||||
photoDataUrl: row.photo_data_url,
|
||||
photoDataUrl: getBirdPhotoUrl(row),
|
||||
photoObjectKey: row.photo_object_key,
|
||||
photoContentType: row.photo_content_type,
|
||||
photoUpdatedAt: row.photo_updated_at,
|
||||
notifyOnDob: row.notify_on_dob,
|
||||
notifyOnGotchaDay: row.notify_on_gotcha_day,
|
||||
memorializedAt: row.memorialized_at,
|
||||
@@ -1018,6 +1047,107 @@ const parseDataImage = (dataUrl: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
const isDataImageUrl = (value: string | null | undefined) => Boolean(value && value.startsWith('data:image/'));
|
||||
|
||||
const resolveBirdPhotoStorage = async ({
|
||||
birdId,
|
||||
workspaceId,
|
||||
photoDataUrl,
|
||||
existingBird,
|
||||
}: {
|
||||
birdId: string;
|
||||
workspaceId: number;
|
||||
photoDataUrl: string | null;
|
||||
existingBird?: BirdRow | null;
|
||||
}) => {
|
||||
if (!photoDataUrl) {
|
||||
return {
|
||||
photoDataUrl: null,
|
||||
photoObjectKey: null,
|
||||
photoContentType: null,
|
||||
photoUpdatedAt: null,
|
||||
objectKeyToDelete: existingBird?.photo_object_key ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDataImageUrl(photoDataUrl)) {
|
||||
if (existingBird?.photo_object_key) {
|
||||
return {
|
||||
photoDataUrl: null,
|
||||
photoObjectKey: existingBird.photo_object_key,
|
||||
photoContentType: existingBird.photo_content_type,
|
||||
photoUpdatedAt: existingBird.photo_updated_at,
|
||||
objectKeyToDelete: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
photoDataUrl,
|
||||
photoObjectKey: null,
|
||||
photoContentType: null,
|
||||
photoUpdatedAt: null,
|
||||
objectKeyToDelete: existingBird?.photo_object_key ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const parsedImage = parseDataImage(photoDataUrl);
|
||||
|
||||
if (!parsedImage) {
|
||||
throw new Error('Unable to process bird photo.');
|
||||
}
|
||||
|
||||
if (getImageStorageProvider() !== 's3') {
|
||||
return {
|
||||
photoDataUrl,
|
||||
photoObjectKey: null,
|
||||
photoContentType: null,
|
||||
photoUpdatedAt: null,
|
||||
objectKeyToDelete: existingBird?.photo_object_key ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const s3Config = getS3ImageStorageConfig();
|
||||
|
||||
if (!s3Config) {
|
||||
throw new Error('S3 image storage is enabled but not fully configured.');
|
||||
}
|
||||
|
||||
const extension = getImageExtensionFromContentType(parsedImage.contentType);
|
||||
const objectKey = buildBirdPhotoObjectKey({ workspaceId, birdId, extension });
|
||||
await putS3Object({
|
||||
config: s3Config,
|
||||
objectKey,
|
||||
content: parsedImage.content,
|
||||
contentType: parsedImage.contentType,
|
||||
});
|
||||
|
||||
return {
|
||||
photoDataUrl: null,
|
||||
photoObjectKey: objectKey,
|
||||
photoContentType: parsedImage.contentType,
|
||||
photoUpdatedAt: new Date().toISOString(),
|
||||
objectKeyToDelete: existingBird?.photo_object_key ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteBirdPhotoObjectIfNeeded = async (objectKey: string | null) => {
|
||||
if (!objectKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const s3Config = getS3ImageStorageConfig();
|
||||
|
||||
if (!s3Config) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteS3Object({ config: s3Config, objectKey });
|
||||
} catch (error) {
|
||||
console.warn(`Unable to delete old bird photo object ${objectKey}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultBirdPhotoAttachment = () => {
|
||||
const defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda-default.png');
|
||||
|
||||
@@ -2555,8 +2685,18 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
||||
return;
|
||||
}
|
||||
|
||||
let uploadedObjectKeyToCleanup: string | null = null;
|
||||
|
||||
try {
|
||||
const birdId = crypto.randomUUID();
|
||||
const photoStorage = await resolveBirdPhotoStorage({
|
||||
birdId,
|
||||
workspaceId: req.auth!.workspace.id,
|
||||
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
||||
});
|
||||
uploadedObjectKeyToCleanup = photoStorage.photoObjectKey;
|
||||
const bird = await createBird({
|
||||
birdId,
|
||||
workspaceId: req.auth!.workspace.id,
|
||||
name: parsed.data.name,
|
||||
tagId: normalizeBandId(parsed.data.tagId),
|
||||
@@ -2565,13 +2705,19 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
||||
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
||||
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
||||
chartColor: parsed.data.chartColor ?? '#cb3a35',
|
||||
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
||||
photoDataUrl: photoStorage.photoDataUrl,
|
||||
photoObjectKey: photoStorage.photoObjectKey,
|
||||
photoContentType: photoStorage.photoContentType,
|
||||
photoUpdatedAt: photoStorage.photoUpdatedAt,
|
||||
notifyOnDob: parsed.data.notifyOnDob ?? false,
|
||||
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
|
||||
});
|
||||
|
||||
uploadedObjectKeyToCleanup = null;
|
||||
res.status(201).json({ bird: normalizeBird(bird!) });
|
||||
} catch (error) {
|
||||
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
||||
|
||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
|
||||
return;
|
||||
@@ -2664,6 +2810,8 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
||||
return;
|
||||
}
|
||||
|
||||
let uploadedObjectKeyToCleanup: string | null = null;
|
||||
|
||||
try {
|
||||
const existingBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||
|
||||
@@ -2676,6 +2824,14 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
||||
return;
|
||||
}
|
||||
|
||||
const photoStorage = await resolveBirdPhotoStorage({
|
||||
birdId: req.params.birdId,
|
||||
workspaceId: req.auth!.workspace.id,
|
||||
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
||||
existingBird,
|
||||
});
|
||||
uploadedObjectKeyToCleanup =
|
||||
photoStorage.photoObjectKey && photoStorage.photoObjectKey !== existingBird.photo_object_key ? photoStorage.photoObjectKey : null;
|
||||
const bird = await updateBird({
|
||||
birdId: req.params.birdId,
|
||||
workspaceId: req.auth!.workspace.id,
|
||||
@@ -2686,7 +2842,10 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
||||
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
||||
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
||||
chartColor: parsed.data.chartColor ?? '#cb3a35',
|
||||
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
||||
photoDataUrl: photoStorage.photoDataUrl,
|
||||
photoObjectKey: photoStorage.photoObjectKey,
|
||||
photoContentType: photoStorage.photoContentType,
|
||||
photoUpdatedAt: photoStorage.photoUpdatedAt,
|
||||
notifyOnDob: parsed.data.notifyOnDob ?? false,
|
||||
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
|
||||
});
|
||||
@@ -2696,8 +2855,12 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
||||
return;
|
||||
}
|
||||
|
||||
uploadedObjectKeyToCleanup = null;
|
||||
await deleteBirdPhotoObjectIfNeeded(photoStorage.objectKeyToDelete);
|
||||
res.json({ bird: normalizeBird(bird) });
|
||||
} catch (error) {
|
||||
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
||||
|
||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
|
||||
return;
|
||||
@@ -2728,6 +2891,7 @@ app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspa
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
await deleteBirdPhotoObjectIfNeeded(bird.photo_object_key);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -221,6 +221,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
gotcha_day DATE,
|
||||
chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
|
||||
photo_data_url TEXT,
|
||||
photo_object_key TEXT,
|
||||
photo_content_type VARCHAR(80),
|
||||
photo_updated_at TIMESTAMPTZ,
|
||||
notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
memorialized_at TIMESTAMPTZ,
|
||||
@@ -237,6 +240,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
|
||||
ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
|
||||
ADD COLUMN IF NOT EXISTS photo_data_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS photo_object_key TEXT,
|
||||
ADD COLUMN IF NOT EXISTS photo_content_type VARCHAR(80),
|
||||
ADD COLUMN IF NOT EXISTS photo_updated_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS memorialized_at TIMESTAMPTZ,
|
||||
@@ -287,6 +293,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none')
|
||||
AND memorialized_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_birds_photo_object_key
|
||||
ON birds (photo_object_key)
|
||||
WHERE photo_object_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -41,7 +41,7 @@ export const enqueueBirdMilestoneReminderJob = (runDate: string): Promise<Job<Bi
|
||||
requestedBy: 'scheduler',
|
||||
},
|
||||
{
|
||||
jobId: `bird-milestone-reminders:${runDate}`,
|
||||
jobId: `bird-milestone-reminders-${runDate}`,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ const birdSelectFields = `
|
||||
birds.gotcha_day::text,
|
||||
birds.chart_color,
|
||||
birds.photo_data_url,
|
||||
birds.photo_object_key,
|
||||
birds.photo_content_type,
|
||||
birds.photo_updated_at,
|
||||
birds.notify_on_dob,
|
||||
birds.notify_on_gotcha_day,
|
||||
birds.memorialized_at,
|
||||
@@ -250,6 +253,7 @@ export const createBirdMilestoneReminderDelivery = async ({
|
||||
};
|
||||
|
||||
export const createBird = async ({
|
||||
birdId,
|
||||
workspaceId,
|
||||
name,
|
||||
tagId,
|
||||
@@ -259,9 +263,13 @@ export const createBird = async ({
|
||||
gotchaDay,
|
||||
chartColor,
|
||||
photoDataUrl,
|
||||
photoObjectKey = null,
|
||||
photoContentType = null,
|
||||
photoUpdatedAt = null,
|
||||
notifyOnDob,
|
||||
notifyOnGotchaDay,
|
||||
}: {
|
||||
birdId?: string;
|
||||
workspaceId: number;
|
||||
name: string;
|
||||
tagId: string | null;
|
||||
@@ -271,14 +279,33 @@ export const createBird = async ({
|
||||
gotchaDay: string | null;
|
||||
chartColor: string;
|
||||
photoDataUrl: string | null;
|
||||
photoObjectKey?: string | null;
|
||||
photoContentType?: string | null;
|
||||
photoUpdatedAt?: string | null;
|
||||
notifyOnDob: boolean;
|
||||
notifyOnGotchaDay: boolean;
|
||||
}) => {
|
||||
const result = await db.query<BirdRow>(
|
||||
`INSERT INTO birds (workspace_id, name, tag_id, species, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
||||
[workspaceId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay],
|
||||
`INSERT INTO birds (id, workspace_id, name, tag_id, species, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day)
|
||||
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
||||
[
|
||||
birdId ?? null,
|
||||
workspaceId,
|
||||
name,
|
||||
tagId,
|
||||
species,
|
||||
gender,
|
||||
dateOfBirth,
|
||||
gotchaDay,
|
||||
chartColor,
|
||||
photoDataUrl,
|
||||
photoObjectKey,
|
||||
photoContentType,
|
||||
photoUpdatedAt,
|
||||
notifyOnDob,
|
||||
notifyOnGotchaDay,
|
||||
],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
@@ -295,6 +322,9 @@ export const updateBird = async ({
|
||||
gotchaDay,
|
||||
chartColor,
|
||||
photoDataUrl,
|
||||
photoObjectKey = null,
|
||||
photoContentType = null,
|
||||
photoUpdatedAt = null,
|
||||
notifyOnDob,
|
||||
notifyOnGotchaDay,
|
||||
}: {
|
||||
@@ -308,6 +338,9 @@ export const updateBird = async ({
|
||||
gotchaDay: string | null;
|
||||
chartColor: string;
|
||||
photoDataUrl: string | null;
|
||||
photoObjectKey?: string | null;
|
||||
photoContentType?: string | null;
|
||||
photoUpdatedAt?: string | null;
|
||||
notifyOnDob: boolean;
|
||||
notifyOnGotchaDay: boolean;
|
||||
}) => {
|
||||
@@ -321,12 +354,15 @@ export const updateBird = async ({
|
||||
gotcha_day = $7,
|
||||
chart_color = $8,
|
||||
photo_data_url = $9,
|
||||
notify_on_dob = $10,
|
||||
notify_on_gotcha_day = $11
|
||||
photo_object_key = $10,
|
||||
photo_content_type = $11,
|
||||
photo_updated_at = $12,
|
||||
notify_on_dob = $13,
|
||||
notify_on_gotcha_day = $14
|
||||
WHERE id = $1
|
||||
AND workspace_id = $12
|
||||
AND workspace_id = $15
|
||||
AND memorialized_at IS NULL
|
||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
(
|
||||
SELECT weight_grams::text
|
||||
FROM weight_records
|
||||
@@ -341,7 +377,23 @@ export const updateBird = async ({
|
||||
ORDER BY recorded_on DESC
|
||||
LIMIT 1
|
||||
) AS latest_recorded_on`,
|
||||
[birdId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay, workspaceId],
|
||||
[
|
||||
birdId,
|
||||
name,
|
||||
tagId,
|
||||
species,
|
||||
gender,
|
||||
dateOfBirth,
|
||||
gotchaDay,
|
||||
chartColor,
|
||||
photoDataUrl,
|
||||
photoObjectKey,
|
||||
photoContentType,
|
||||
photoUpdatedAt,
|
||||
notifyOnDob,
|
||||
notifyOnGotchaDay,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
@@ -369,7 +421,7 @@ export const memorializeBird = async ({
|
||||
WHERE id = $1
|
||||
AND workspace_id = $2
|
||||
AND memorialized_at IS NULL
|
||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
(
|
||||
SELECT weight_grams::text
|
||||
FROM weight_records
|
||||
@@ -405,7 +457,7 @@ export const updateMemorialReminderPreference = async ({
|
||||
WHERE id = $1
|
||||
AND workspace_id = $2
|
||||
AND memorialized_at IS NOT NULL
|
||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
(
|
||||
SELECT weight_grams::text
|
||||
FROM weight_records
|
||||
@@ -445,7 +497,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
|
||||
WHERE id = $1
|
||||
AND workspace_id = $2
|
||||
AND memorialized_at IS NULL
|
||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
(
|
||||
SELECT weight_grams::text
|
||||
FROM weight_records
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
export type ImageStorageProvider = 'database' | 's3';
|
||||
|
||||
export type S3ImageStorageConfig = {
|
||||
provider: 's3';
|
||||
endpoint: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
publicBaseUrl: string | null;
|
||||
keyPrefix: string;
|
||||
};
|
||||
|
||||
const trimOptional = (value: string | undefined) => {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
};
|
||||
|
||||
export const getImageStorageProvider = (): ImageStorageProvider =>
|
||||
process.env.IMAGE_STORAGE_PROVIDER === 's3' ? 's3' : 'database';
|
||||
|
||||
export const getS3ImageStorageConfig = (): S3ImageStorageConfig | null => {
|
||||
if (getImageStorageProvider() !== 's3') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = trimOptional(process.env.S3_ENDPOINT);
|
||||
const region = trimOptional(process.env.S3_REGION);
|
||||
const bucket = trimOptional(process.env.S3_BUCKET);
|
||||
const accessKeyId = trimOptional(process.env.S3_ACCESS_KEY_ID);
|
||||
const secretAccessKey = trimOptional(process.env.S3_SECRET_ACCESS_KEY);
|
||||
|
||||
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 's3',
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
publicBaseUrl: trimOptional(process.env.S3_PUBLIC_BASE_URL),
|
||||
keyPrefix: trimOptional(process.env.S3_KEY_PREFIX) ?? 'bird-photos',
|
||||
};
|
||||
};
|
||||
|
||||
export const isS3ImageStorageConfigured = () => getS3ImageStorageConfig() !== null;
|
||||
|
||||
export const buildBirdPhotoObjectKey = ({
|
||||
workspaceId,
|
||||
birdId,
|
||||
extension,
|
||||
now = new Date(),
|
||||
}: {
|
||||
workspaceId: number;
|
||||
birdId: string;
|
||||
extension: string;
|
||||
now?: Date;
|
||||
}) => {
|
||||
const prefix = trimOptional(process.env.S3_KEY_PREFIX) ?? 'bird-photos';
|
||||
const safeExtension = extension.replace(/^\./, '').toLowerCase() || 'bin';
|
||||
const timestamp = now.toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
return `${prefix}/workspace-${workspaceId}/${birdId}/${timestamp}.${safeExtension}`;
|
||||
};
|
||||
|
||||
export const getImageExtensionFromContentType = (contentType: string) => {
|
||||
switch (contentType.toLowerCase()) {
|
||||
case 'image/jpeg':
|
||||
case 'image/jpg':
|
||||
return 'jpg';
|
||||
case 'image/png':
|
||||
return 'png';
|
||||
case 'image/webp':
|
||||
return 'webp';
|
||||
case 'image/gif':
|
||||
return 'gif';
|
||||
default:
|
||||
return 'bin';
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import type { S3ImageStorageConfig } from './imageStorageConfig.js';
|
||||
|
||||
const awsDate = (date: Date) => date.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
||||
const shortDate = (date: Date) => date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
|
||||
const hmac = (key: crypto.BinaryLike, value: string) => crypto.createHmac('sha256', key).update(value).digest();
|
||||
const sha256Hex = (value: crypto.BinaryLike) => crypto.createHash('sha256').update(value).digest('hex');
|
||||
|
||||
const encodeObjectKey = (key: string) => key.split('/').map(encodeURIComponent).join('/');
|
||||
const encodeQueryValue = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
|
||||
const getSigningKey = (secretAccessKey: string, date: string, region: string) => {
|
||||
const dateKey = hmac(`AWS4${secretAccessKey}`, date);
|
||||
const regionKey = hmac(dateKey, region);
|
||||
const serviceKey = hmac(regionKey, 's3');
|
||||
return hmac(serviceKey, 'aws4_request');
|
||||
};
|
||||
|
||||
const buildObjectUrl = (config: S3ImageStorageConfig, objectKey: string) => {
|
||||
const endpoint = config.endpoint.replace(/\/+$/, '');
|
||||
return new URL(`${endpoint}/${encodeURIComponent(config.bucket)}/${encodeObjectKey(objectKey)}`);
|
||||
};
|
||||
|
||||
const signS3Request = ({
|
||||
config,
|
||||
method,
|
||||
objectKey,
|
||||
contentHash,
|
||||
contentType,
|
||||
now = new Date(),
|
||||
}: {
|
||||
config: S3ImageStorageConfig;
|
||||
method: 'DELETE' | 'PUT';
|
||||
objectKey: string;
|
||||
contentHash: string;
|
||||
contentType?: string;
|
||||
now?: Date;
|
||||
}) => {
|
||||
const url = buildObjectUrl(config, objectKey);
|
||||
const amzDate = awsDate(now);
|
||||
const date = shortDate(now);
|
||||
const credentialScope = `${date}/${config.region}/s3/aws4_request`;
|
||||
const headers: Record<string, string> = {
|
||||
host: url.host,
|
||||
'x-amz-content-sha256': contentHash,
|
||||
'x-amz-date': amzDate,
|
||||
};
|
||||
|
||||
if (contentType) {
|
||||
headers['content-type'] = contentType;
|
||||
}
|
||||
|
||||
const signedHeaders = Object.keys(headers).sort().join(';');
|
||||
const canonicalHeaders = Object.keys(headers)
|
||||
.sort()
|
||||
.map((key) => `${key}:${headers[key]}\n`)
|
||||
.join('');
|
||||
const canonicalRequest = [method, url.pathname, url.search.slice(1), canonicalHeaders, signedHeaders, contentHash].join('\n');
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n');
|
||||
const signature = crypto.createHmac('sha256', getSigningKey(config.secretAccessKey, date, config.region)).update(stringToSign).digest('hex');
|
||||
|
||||
return {
|
||||
url,
|
||||
headers: {
|
||||
...headers,
|
||||
Authorization: `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const getPublicObjectUrl = (config: S3ImageStorageConfig, objectKey: string) => {
|
||||
if (config.publicBaseUrl) {
|
||||
return `${config.publicBaseUrl.replace(/\/+$/, '')}/${encodeObjectKey(objectKey)}`;
|
||||
}
|
||||
|
||||
return buildObjectUrl(config, objectKey).toString();
|
||||
};
|
||||
|
||||
export const getSignedS3ObjectUrl = ({
|
||||
config,
|
||||
objectKey,
|
||||
expiresInSeconds = 900,
|
||||
now = new Date(),
|
||||
}: {
|
||||
config: S3ImageStorageConfig;
|
||||
objectKey: string;
|
||||
expiresInSeconds?: number;
|
||||
now?: Date;
|
||||
}) => {
|
||||
const url = buildObjectUrl(config, objectKey);
|
||||
const amzDate = awsDate(now);
|
||||
const date = shortDate(now);
|
||||
const credentialScope = `${date}/${config.region}/s3/aws4_request`;
|
||||
const credential = `${config.accessKeyId}/${credentialScope}`;
|
||||
const queryParams: Record<string, string> = {
|
||||
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
||||
'X-Amz-Credential': credential,
|
||||
'X-Amz-Date': amzDate,
|
||||
'X-Amz-Expires': String(Math.min(Math.max(expiresInSeconds, 1), 604800)),
|
||||
'X-Amz-SignedHeaders': 'host',
|
||||
};
|
||||
const canonicalQuery = Object.keys(queryParams)
|
||||
.sort()
|
||||
.map((key) => `${encodeQueryValue(key)}=${encodeQueryValue(queryParams[key])}`)
|
||||
.join('&');
|
||||
const canonicalHeaders = `host:${url.host}\n`;
|
||||
const canonicalRequest = ['GET', url.pathname, canonicalQuery, canonicalHeaders, 'host', 'UNSIGNED-PAYLOAD'].join('\n');
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n');
|
||||
const signature = crypto.createHmac('sha256', getSigningKey(config.secretAccessKey, date, config.region)).update(stringToSign).digest('hex');
|
||||
|
||||
url.search = `${canonicalQuery}&X-Amz-Signature=${signature}`;
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export const putS3Object = async ({
|
||||
config,
|
||||
objectKey,
|
||||
content,
|
||||
contentType,
|
||||
}: {
|
||||
config: S3ImageStorageConfig;
|
||||
objectKey: string;
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
}) => {
|
||||
const contentHash = sha256Hex(content);
|
||||
const signed = signS3Request({ config, method: 'PUT', objectKey, contentHash, contentType });
|
||||
const response = await fetch(signed.url, {
|
||||
method: 'PUT',
|
||||
headers: signed.headers,
|
||||
body: content,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => '');
|
||||
throw new Error(`Wasabi upload failed with ${response.status}${errorText ? `: ${errorText.slice(0, 300)}` : ''}`);
|
||||
}
|
||||
|
||||
return getPublicObjectUrl(config, objectKey);
|
||||
};
|
||||
|
||||
export const deleteS3Object = async ({
|
||||
config,
|
||||
objectKey,
|
||||
}: {
|
||||
config: S3ImageStorageConfig;
|
||||
objectKey: string;
|
||||
}) => {
|
||||
const signed = signS3Request({ config, method: 'DELETE', objectKey, contentHash: sha256Hex('') });
|
||||
const response = await fetch(signed.url, {
|
||||
method: 'DELETE',
|
||||
headers: signed.headers,
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
const errorText = await response.text().catch(() => '');
|
||||
throw new Error(`Wasabi delete failed with ${response.status}${errorText ? `: ${errorText.slice(0, 300)}` : ''}`);
|
||||
}
|
||||
};
|
||||
@@ -103,6 +103,9 @@ export type BirdRow = {
|
||||
gotcha_day: string | null;
|
||||
chart_color: string;
|
||||
photo_data_url: string | null;
|
||||
photo_object_key: string | null;
|
||||
photo_content_type: string | null;
|
||||
photo_updated_at: string | null;
|
||||
notify_on_dob: boolean;
|
||||
notify_on_gotcha_day: boolean;
|
||||
memorialized_at: string | null;
|
||||
|
||||
@@ -43,6 +43,14 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||
S3_REGION: ${S3_REGION:-}
|
||||
S3_BUCKET: ${S3_BUCKET:-}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
|
||||
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
|
||||
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
||||
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||
@@ -111,6 +119,14 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||
S3_REGION: ${S3_REGION:-}
|
||||
S3_BUCKET: ${S3_BUCKET:-}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
|
||||
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
|
||||
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
||||
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||
|
||||
@@ -41,6 +41,14 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||
S3_REGION: ${S3_REGION:-}
|
||||
S3_BUCKET: ${S3_BUCKET:-}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
|
||||
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
|
||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
||||
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||
@@ -104,6 +112,14 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||
S3_REGION: ${S3_REGION:-}
|
||||
S3_BUCKET: ${S3_BUCKET:-}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
|
||||
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
|
||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
||||
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
if [ -z "${BASH_VERSION:-}" ]; then
|
||||
exec bash "$0" "$@"
|
||||
fi
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
compose_file="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
if [ -z "${BASH_VERSION:-}" ]; then
|
||||
exec bash "$0" "$@"
|
||||
fi
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
|
||||
Reference in New Issue
Block a user