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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "capgo-app",
"type": "module",
"version": "12.116.1",
"version": "12.116.3",
"private": true,
"license": "GPL-3.0",
"scripts": {
Expand Down
19 changes: 18 additions & 1 deletion src/pages/admin/dashboard/replication.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useRouter } from 'vue-router'
import AdminStatsCard from '~/components/admin/AdminStatsCard.vue'
import Spinner from '~/components/Spinner.vue'
import { formatLocalDateTime } from '~/services/date'
import { defaultApiHost } from '~/services/supabase'
import { defaultApiHost, useSupabase } from '~/services/supabase'
import { useDisplayStore } from '~/stores/display'
import { useMainStore } from '~/stores/main'

Expand Down Expand Up @@ -97,13 +97,30 @@ const checkedAt = computed(() => {
return formatLocalDateTime(data.value.checked_at)
})

const internalReplicationSecret = import.meta.env.VITE_REPLICATION_API_SECRET as string | undefined

async function loadReplicationStatus() {
isLoading.value = true
errorMessage.value = null

try {
const headers: Record<string, string> = {}
if (internalReplicationSecret) {
headers.apisecret = internalReplicationSecret
}
else {
const supabase = useSupabase()
const { data: { session } } = await supabase.auth.getSession()

if (!session?.access_token)
throw new Error('No session available and replication secret is not configured')

headers.Authorization = `Bearer ${session.access_token}`
}

const response = await fetch(`${defaultApiHost}/replication`, {
method: 'GET',
headers,
})

const payload = await response.json().catch(() => null) as ReplicationStatusResponse | null
Expand Down
40 changes: 40 additions & 0 deletions src/services/photos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ const supabase = useSupabase()
const main = useMainStore()
const organizationStore = useOrganizationStore()

function normalizeImageStoragePath(path?: string | null) {
if (!path)
return ''

const pathWithoutQuery = path.split('?')[0]
const signedUrlRegex = /\/storage\/v1\/object\/(?:public\/|sign\/)?images\/(.+)$/
const signedUrlMatch = signedUrlRegex.exec(pathWithoutQuery)
if (signedUrlMatch?.[1])
return signedUrlMatch[1].replace(/^\/+/, '')

return pathWithoutQuery.replace(/^images\//, '').replace(/^\/+/, '')
}

async function uploadPhotoShared(
data: string,
storagePath: string,
Expand Down Expand Up @@ -51,6 +64,17 @@ async function uploadPhotoUser(formId: string, data: string, fileName: string, c
return
}

let previousImagePath = ''
const { data: currentUser, error: currentUserError } = await supabase
.from('users')
.select('image_url')
.eq('id', safeUserId)
.maybeSingle()
if (currentUserError)
console.error('cannot fetch current user image before update', currentUserError)
else
previousImagePath = normalizeImageStoragePath(currentUser?.image_url)

const { data: usr, error: dbError } = await supabase
.from('users')
.update({ image_url: storagePath })
Expand All @@ -61,8 +85,24 @@ async function uploadPhotoUser(formId: string, data: string, fileName: string, c
if (!usr || dbError) {
setErrors(formId, [wentWrong], {})
console.error('upload error', dbError)
const { error: cleanupUploadError } = await supabase
.storage
.from('images')
.remove([storagePath])
if (cleanupUploadError)
console.error('cannot cleanup newly uploaded user image after db error', cleanupUploadError)
return
}

if (previousImagePath && previousImagePath !== storagePath) {
const { error: deletePreviousImageError } = await supabase
.storage
.from('images')
.remove([previousImagePath])
if (deletePreviousImageError)
console.error('cannot delete previous user image', deletePreviousImageError)
}

usr.image_url = signedUrl
main.user = usr
}
Expand Down
31 changes: 27 additions & 4 deletions supabase/functions/_backend/files/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,12 +180,35 @@ export async function handlePreviewRequest(c: Context<MiddlewareKeyVariables>):
// Use admin client - preview is public when allow_preview is enabled
const supabase = supabaseAdmin(c)

// Get app settings to check if preview is enabled (case-insensitive since frontend lowercases)
const { data: appData, error: appError } = await supabase
// Get app settings to check if preview is enabled.
// Try exact match first (prevents wildcard collisions), then fallback to
// case-insensitive match for preview URLs that were lowercased.
const exactLookup = await supabase
.from('apps')
.select('app_id, allow_preview')
.ilike('app_id', appId)
.single()
.eq('app_id', appId)
.maybeSingle()

let appData = exactLookup.data
let appError = exactLookup.error

if (!appData && !appError) {
const escapedAppId = appId
.toLowerCase()
.replace(/\\/g, '\\\\')
.replace(/%/g, '\\%')
.replace(/_/g, '\\_')

const fallbackLookup = await supabase
.from('apps')
.select('app_id, allow_preview')
.ilike('app_id', escapedAppId)
.limit(1)
.maybeSingle()

appData = fallbackLookup.data
appError = fallbackLookup.error
}

if (appError || !appData) {
throw simpleError('app_not_found', 'App not found', { appId })
Expand Down
22 changes: 17 additions & 5 deletions supabase/functions/_backend/public/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { getBuildStatus } from './status.ts'
import { tusProxy } from './upload.ts'

export const app = honoFactory.createApp()
const uploadWriteMiddleware = middlewareKey(['all', 'write'])

// POST /build/request - Request a new native build
app.post('/request', middlewareKey(['all', 'write']), async (c) => {
Expand Down Expand Up @@ -89,11 +90,22 @@ app.post('/upload/:jobId', middlewareKey(['all', 'write']), async (c) => {
})

// HEAD /build/upload/:jobId/* - Check TUS upload progress (proxied to builder)
app.on('HEAD', '/upload/:jobId/*', middlewareKey(['all', 'write']), async (c) => {
const jobId = c.req.param('jobId')
const apikey = c.get('apikey') as Database['public']['Tables']['apikeys']['Row']
return tusProxy(c, jobId, apikey)
})
// Hono resolves HEAD via GET route matching, so we gate by request method here.
app.get(
'/upload/:jobId/*',
async (c, next) => {
if (c.req.method !== 'HEAD') {
return c.notFound()
}
return next()
},
uploadWriteMiddleware,
async (c) => {
const jobId = c.req.param('jobId')
const apikey = c.get('apikey') as Database['public']['Tables']['apikeys']['Row']
return tusProxy(c, jobId, apikey)
},
)

// PATCH /build/upload/:jobId/* - Upload TUS chunk (proxied to builder)
app.patch('/upload/:jobId/*', middlewareKey(['all', 'write']), async (c) => {
Expand Down
76 changes: 49 additions & 27 deletions supabase/functions/_backend/public/replication.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { Context } from 'hono'
import type { MiddlewareKeyVariables } from '../utils/hono.ts'
import { sql } from 'drizzle-orm'
import { honoFactory, useCors } from '../utils/hono.ts'
import { getClaimsFromJWT, honoFactory, middlewareAPISecret, quickError, useCors } from '../utils/hono.ts'
import { cloudlogErr } from '../utils/logging.ts'
import { closeClient, getDrizzleClient, getPgClient, logPgError } from '../utils/pg.ts'
import { supabaseClient } from '../utils/supabase.ts'

const DEFAULT_THRESHOLD_SECONDS = 180
const DEFAULT_THRESHOLD_BYTES = 16 * 1024 * 1024
Expand All @@ -24,13 +27,6 @@ interface ReplicationSlotLag {
reasons: string[]
}

interface ReplicationErrorInfo {
message: string
code?: string
detail?: string
hint?: string
}

function toNumber(value: unknown): number | null {
if (value === null || value === undefined)
return null
Expand All @@ -40,19 +36,6 @@ function toNumber(value: unknown): number | null {
return num
}

function getErrorInfo(error: unknown): ReplicationErrorInfo {
if (error instanceof Error) {
const err = error as Error & { code?: string, detail?: string, hint?: string }
return {
message: err.message,
code: err.code,
detail: err.detail,
hint: err.hint,
}
}
return { message: String(error) }
}

function buildReplicationQuery(mode: ReplicationQueryMode) {
const slotsCte = sql`
WITH slots AS (
Expand Down Expand Up @@ -162,7 +145,51 @@ export const app = honoFactory.createApp()

app.use('*', useCors)

type ReplicationContext = Context<MiddlewareKeyVariables, any, any>

async function validateReplicationAccess(c: ReplicationContext) {
const apiSecret = c.req.header('apisecret')

if (apiSecret) {
await middlewareAPISecret(c, async () => {})
return
}

const authorization = c.req.header('authorization')
if (!authorization) {
throw quickError(401, 'no_authorization', 'Authorization header or apisecret is required')
}

const claims = getClaimsFromJWT(authorization)
if (!claims?.sub) {
cloudlogErr({ requestId: c.get('requestId'), message: 'replication_invalid_jwt' })
throw quickError(401, 'invalid_jwt', 'Invalid JWT')
}

c.set('authorization', authorization)
c.set('auth', {
userId: claims.sub,
authType: 'jwt',
apikey: null,
jwt: authorization,
})

const userClient = supabaseClient(c, authorization)
const { data: isAdmin, error: adminError } = await userClient.rpc('is_admin')
if (adminError) {
cloudlogErr({ requestId: c.get('requestId'), message: 'replication_is_admin_error', error: adminError })
throw quickError(500, 'is_admin_error', 'Unable to verify admin rights')
}

if (!isAdmin) {
cloudlogErr({ requestId: c.get('requestId'), message: 'replication_not_admin', userId: claims.sub })
throw quickError(403, 'not_admin', 'Not admin - only admin users can access replication status')
}
}

app.get('/', async (c) => {
await validateReplicationAccess(c)

const thresholdSeconds = DEFAULT_THRESHOLD_SECONDS
const thresholdBytes = DEFAULT_THRESHOLD_BYTES

Expand Down Expand Up @@ -248,16 +275,11 @@ app.get('/', async (c) => {
}
catch (error) {
logPgError(c, 'replication_lag', error)
const errorInfo = getErrorInfo(error)
cloudlogErr({ requestId: c.get('requestId'), message: 'replication_lag_error', error })
return c.json({
status: 'ko',
error: 'replication_lag_error',
message: 'Failed to fetch replication slot lag',
error_message: errorInfo.message,
error_code: errorInfo.code,
error_detail: errorInfo.detail,
error_hint: errorInfo.hint,
message: 'Failed to fetch replication lag',
threshold_seconds: thresholdSeconds,
threshold_minutes: Number((thresholdSeconds / 60).toFixed(2)),
threshold_bytes: thresholdBytes,
Expand Down
Loading