Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions supabase/functions/_backend/triggers/on_app_update.ts
Original file line number Diff line number Diff line change
@@ -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<MiddlewareKeyVariables>()

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)
})
24 changes: 24 additions & 0 deletions supabase/functions/_backend/triggers/on_org_update.ts
Original file line number Diff line number Diff line change
@@ -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<MiddlewareKeyVariables>()

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)
})
8 changes: 8 additions & 0 deletions supabase/functions/_backend/triggers/on_user_update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
})
205 changes: 205 additions & 0 deletions supabase/functions/_backend/utils/image.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 })
}
4 changes: 4 additions & 0 deletions supabase/functions/triggers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;