diff --git a/supabase/functions/_backend/triggers/on_app_update.ts b/supabase/functions/_backend/triggers/on_app_update.ts new file mode 100644 index 0000000000..44b8a06b54 --- /dev/null +++ b/supabase/functions/_backend/triggers/on_app_update.ts @@ -0,0 +1,24 @@ +import type { MiddlewareKeyVariables } from '../utils/hono.ts' +import type { Database } from '../utils/supabase.types.ts' +import { Hono } from 'hono/tiny' +import { BRES, middlewareAPISecret, simpleError, triggerValidator } from '../utils/hono.ts' +import { cleanStoredImageMetadata } from '../utils/image.ts' +import { cloudlog } from '../utils/logging.ts' + +export const app = new Hono() + +app.post('/', middlewareAPISecret, triggerValidator('apps', 'UPDATE'), async (c) => { + const record = c.get('webhookBody') as Database['public']['Tables']['apps']['Row'] + cloudlog({ requestId: c.get('requestId'), message: 'record', record }) + + if (!record.id) { + cloudlog({ requestId: c.get('requestId'), message: 'No app id' }) + throw simpleError('no_id', 'No id', { record }) + } + + if (record.icon_url) { + await cleanStoredImageMetadata(c, record.icon_url) + } + + return c.json(BRES) +}) diff --git a/supabase/functions/_backend/triggers/on_org_update.ts b/supabase/functions/_backend/triggers/on_org_update.ts new file mode 100644 index 0000000000..c32cac2645 --- /dev/null +++ b/supabase/functions/_backend/triggers/on_org_update.ts @@ -0,0 +1,24 @@ +import type { MiddlewareKeyVariables } from '../utils/hono.ts' +import type { Database } from '../utils/supabase.types.ts' +import { Hono } from 'hono/tiny' +import { BRES, middlewareAPISecret, simpleError, triggerValidator } from '../utils/hono.ts' +import { cleanStoredImageMetadata } from '../utils/image.ts' +import { cloudlog } from '../utils/logging.ts' + +export const app = new Hono() + +app.post('/', middlewareAPISecret, triggerValidator('orgs', 'UPDATE'), async (c) => { + const record = c.get('webhookBody') as Database['public']['Tables']['orgs']['Row'] + cloudlog({ requestId: c.get('requestId'), message: 'record', record }) + + if (!record.id) { + cloudlog({ requestId: c.get('requestId'), message: 'No org id' }) + throw simpleError('no_id', 'No id', { record }) + } + + if (record.logo) { + await cleanStoredImageMetadata(c, record.logo) + } + + return c.json(BRES) +}) diff --git a/supabase/functions/_backend/triggers/on_user_update.ts b/supabase/functions/_backend/triggers/on_user_update.ts index 7e423172f0..897c9947a1 100644 --- a/supabase/functions/_backend/triggers/on_user_update.ts +++ b/supabase/functions/_backend/triggers/on_user_update.ts @@ -2,6 +2,7 @@ import type { MiddlewareKeyVariables } from '../utils/hono.ts' import type { Database } from '../utils/supabase.types.ts' import { Hono } from 'hono/tiny' import { BRES, middlewareAPISecret, simpleError, triggerValidator } from '../utils/hono.ts' +import { cleanStoredImageMetadata } from '../utils/image.ts' import { cloudlog } from '../utils/logging.ts' import { createApiKey } from '../utils/supabase.ts' import { syncUserPreferenceTags } from '../utils/user_preferences.ts' @@ -22,5 +23,12 @@ app.post('/', middlewareAPISecret, triggerValidator('users', 'UPDATE'), async (c } await createApiKey(c, record.id) await syncUserPreferenceTags(c, record.email, record, oldRecord, oldRecord?.email) + + const newImagePath = record.image_url + const oldImagePath = oldRecord?.image_url + if (newImagePath && newImagePath !== oldImagePath) { + await cleanStoredImageMetadata(c, newImagePath) + } + return c.json(BRES) }) diff --git a/supabase/functions/_backend/utils/image.ts b/supabase/functions/_backend/utils/image.ts new file mode 100644 index 0000000000..927b6b14a3 --- /dev/null +++ b/supabase/functions/_backend/utils/image.ts @@ -0,0 +1,205 @@ +import type { Context } from 'hono' +import { cloudlog, cloudlogErr } from './logging.ts' +import { normalizeImagePath } from './storage.ts' +import { supabaseAdmin } from './supabase.ts' + +const JPEG_SIGNATURE = [0xFF, 0xD8] +const PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] + +function mimeFromFileBytes(bytes: Uint8Array): string | null { + if (bytes.length >= 2 && bytes[0] === JPEG_SIGNATURE[0] && bytes[1] === JPEG_SIGNATURE[1]) { + return 'image/jpeg' + } + + if (bytes.length >= PNG_SIGNATURE.length) { + const isPng = PNG_SIGNATURE.every((signatureByte, index) => bytes[index] === signatureByte) + if (isPng) + return 'image/png' + } + + return null +} + +function readUint32(bytes: Uint8Array, offset: number): number { + return ((bytes[offset] << 24) + | (bytes[offset + 1] << 16) + | (bytes[offset + 2] << 8) + | bytes[offset + 3]) >>> 0 +} + +function concatUint8Arrays(parts: Uint8Array[]): Uint8Array { + const totalLength = parts.reduce((sum, part) => sum + part.length, 0) + const out = new Uint8Array(totalLength) + let position = 0 + for (const part of parts) { + out.set(part, position) + position += part.length + } + return out +} + +function shouldStripJpegSegment(marker: number): boolean { + // APP0 (JFIF) is kept, APP1..APP15 are removed (EXIF, ICC, etc.) + if (marker === 0xE0) + return false + if (marker >= 0xE1 && marker <= 0xEF) + return true + + // Strip comments too + return marker === 0xFE +} + +function isSameBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) + return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) + return false + } + return true +} + +function stripJpegMetadata(input: Uint8Array): Uint8Array | null { + if (input.length < 2 || input[0] !== JPEG_SIGNATURE[0] || input[1] !== JPEG_SIGNATURE[1]) + return null + + const out: Uint8Array[] = [input.slice(0, 2)] + let offset = 2 + + while (offset < input.length) { + if (input[offset] !== 0xFF) + return null + + const marker = input[offset + 1] + + if (marker === 0xD9) { + out.push(input.slice(offset, offset + 2)) + break + } + + // Standalone markers (RST, SOI, EOI) have no payload length + if (marker === 0xD8 || marker === 0x01 || (marker >= 0xD0 && marker <= 0xD7)) { + out.push(input.slice(offset, offset + 2)) + offset += 2 + continue + } + + if (offset + 4 > input.length) + return null + + const segmentLength = (input[offset + 2] << 8) | input[offset + 3] + if (segmentLength < 2) + return null + + const segmentEnd = offset + 2 + segmentLength + if (segmentEnd > input.length) + return null + + // Start of scan: keep the marker and payload and stop parsing there + if (marker === 0xDA) { + out.push(input.slice(offset)) + break + } + + if (!shouldStripJpegSegment(marker)) { + out.push(input.slice(offset, segmentEnd)) + } + + offset = segmentEnd + } + + return concatUint8Arrays(out) +} + +function stripPngMetadata(input: Uint8Array): Uint8Array | null { + for (let i = 0; i < PNG_SIGNATURE.length; i++) { + if (input[i] !== PNG_SIGNATURE[i]) + return null + } + + const out: Uint8Array[] = [input.slice(0, PNG_SIGNATURE.length)] + let offset = PNG_SIGNATURE.length + + while (offset < input.length) { + if (offset + 8 > input.length) + return null + + const length = readUint32(input, offset) + const type = new TextDecoder().decode(input.slice(offset + 4, offset + 8)) + const chunkStart = offset + const chunkDataStart = offset + 8 + const chunkEnd = chunkDataStart + length + 4 + + if (chunkEnd > input.length) + return null + + const shouldKeep = !['eXIf', 'iTXt', 'tEXt', 'zTXt', 'iCCP'].includes(type) + if (shouldKeep) + out.push(input.slice(chunkStart, chunkEnd)) + + offset = chunkEnd + + if (type === 'IEND') + break + } + + return concatUint8Arrays(out) +} + +function stripMetadataBytes(input: Uint8Array): Uint8Array | null { + return stripJpegMetadata(input) ?? stripPngMetadata(input) +} + +function mimeFromFilePath(path: string): string | null { + const normalized = path.toLowerCase() + + if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) + return 'image/jpeg' + if (normalized.endsWith('.png')) + return 'image/png' + return null +} + +export async function cleanStoredImageMetadata(c: Context, rawImagePath: string): Promise { + const requestId = c.get('requestId') + const normalizedPath = normalizeImagePath(rawImagePath) + + if (!normalizedPath) { + cloudlog({ requestId, message: 'Cannot normalize image path', rawImagePath }) + return + } + + const supabase = supabaseAdmin(c) + const { data: fileBlob, error: downloadError } = await supabase.storage.from('images').download(normalizedPath) + + if (downloadError || !fileBlob) { + if (downloadError) + cloudlogErr({ requestId, message: 'Failed to download image for metadata cleanup', path: normalizedPath, error: downloadError }) + return + } + + const original = new Uint8Array(await fileBlob.arrayBuffer()) + const sanitized = stripMetadataBytes(original) + + if (!sanitized) { + cloudlog({ requestId, message: 'Unsupported image format for metadata cleanup', path: normalizedPath }) + return + } + + if (isSameBytes(sanitized, original)) { + cloudlog({ requestId, message: 'No metadata found to remove', path: normalizedPath }) + return + } + + const contentType = fileBlob.type || mimeFromFileBytes(original) || mimeFromFilePath(normalizedPath) || undefined + const { error: uploadError } = await supabase.storage.from('images').upload(normalizedPath, sanitized, { + upsert: true, + ...(contentType ? { contentType } : {}), + }) + + if (uploadError) { + throw uploadError + } + + cloudlog({ requestId, message: 'Stripped image metadata', path: normalizedPath }) +} diff --git a/supabase/functions/triggers/index.ts b/supabase/functions/triggers/index.ts index 602d01077c..12a78e5066 100644 --- a/supabase/functions/triggers/index.ts +++ b/supabase/functions/triggers/index.ts @@ -9,9 +9,11 @@ import { app as cron_sync_sub } from '../_backend/triggers/cron_sync_sub.ts' import { app as logsnag_insights } from '../_backend/triggers/logsnag_insights.ts' import { app as on_app_create } from '../_backend/triggers/on_app_create.ts' import { app as on_app_delete } from '../_backend/triggers/on_app_delete.ts' +import { app as on_app_update } from '../_backend/triggers/on_app_update.ts' import { app as on_channel_update } from '../_backend/triggers/on_channel_update.ts' import { app as on_deploy_history_create } from '../_backend/triggers/on_deploy_history_create.ts' import { app as on_manifest_create } from '../_backend/triggers/on_manifest_create.ts' +import { app as on_org_update } from '../_backend/triggers/on_org_update.ts' import { app as on_organization_create } from '../_backend/triggers/on_organization_create.ts' import { app as on_organization_delete } from '../_backend/triggers/on_organization_delete.ts' import { app as on_user_create } from '../_backend/triggers/on_user_create.ts' @@ -38,12 +40,14 @@ appGlobal.route('/on_user_update', on_user_update) appGlobal.route('/on_user_delete', on_user_delete) appGlobal.route('/on_app_create', on_app_create) appGlobal.route('/on_app_delete', on_app_delete) +appGlobal.route('/on_app_update', on_app_update) appGlobal.route('/on_version_create', on_version_create) appGlobal.route('/on_version_update', on_version_update) appGlobal.route('/on_version_delete', on_version_delete) appGlobal.route('/on_manifest_create', on_manifest_create) appGlobal.route('/stripe_event', stripe_event) appGlobal.route('/on_organization_create', on_organization_create) +appGlobal.route('/on_org_update', on_org_update) appGlobal.route('/cron_stat_app', cron_stat_app) appGlobal.route('/cron_stat_org', cron_stat_org) appGlobal.route('/cron_sync_sub', cron_sync_sub) diff --git a/supabase/migrations/20260225000000_image_metadata_cleanup_triggers.sql b/supabase/migrations/20260225000000_image_metadata_cleanup_triggers.sql new file mode 100644 index 0000000000..add7f3ca8a --- /dev/null +++ b/supabase/migrations/20260225000000_image_metadata_cleanup_triggers.sql @@ -0,0 +1,44 @@ +-- Add queue-backed image metadata cleanup triggers for user-uploaded images. + +-- Create queues used by the backend trigger worker. +SELECT + pgmq.create ('on_app_update'); + +SELECT + pgmq.create ('on_org_update'); + +-- Run image metadata cleanup on app icon updates. +DROP TRIGGER IF EXISTS "on_app_update" ON "public"."apps"; +CREATE TRIGGER "on_app_update" +AFTER +UPDATE OF "icon_url" ON "public"."apps" FOR EACH ROW +EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function" ('on_app_update'); + +-- Run image metadata cleanup on org logo updates. +DROP TRIGGER IF EXISTS "on_org_update" ON "public"."orgs"; +CREATE TRIGGER "on_org_update" +AFTER +UPDATE OF "logo" ON "public"."orgs" FOR EACH ROW +EXECUTE FUNCTION "public"."trigger_http_queue_post_to_function" ('on_org_update'); + +-- Keep high-frequency queue processing up-to-date with new image cleanup triggers. +WITH updated_target AS ( + SELECT + ct.name, + ( + SELECT COALESCE(jsonb_agg(value ORDER BY value), '["on_app_update","on_org_update"]'::jsonb)::text + FROM ( + SELECT jsonb_array_elements_text(ct.target::jsonb) AS value + UNION + SELECT 'on_app_update' + UNION + SELECT 'on_org_update' + ) AS items + ) AS normalized_target + FROM public.cron_tasks ct + WHERE ct.name = 'high_frequency_queues' +) +UPDATE public.cron_tasks ct +SET target = updated_target.normalized_target +FROM updated_target +WHERE ct.name = updated_target.name;