From 51da1f8a5920a2fb818b51bc9340c4fdb237841e Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 2 Apr 2026 16:30:17 +0200 Subject: [PATCH] fix(uload): add input validation, migrate clicks to dedicated table - Add URL validation (must be valid http/https), short code uniqueness check, custom code format validation, maxClicks >= 1, expiresAt must be future - Migrate uload-server click tracking from sync_changes to uload.clicks table for performant analytics with SQL indexes on link_id, clicked_at, country - Migrate analytics queries from JSON aggregation on sync_changes to direct SQL on uload.clicks (typed columns instead of data->>'field' extraction) - Make short URL domain configurable via PUBLIC_ULOAD_DOMAIN env var Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/routes/(app)/uload/+page.svelte | 71 ++++++++++++++++++- .../src/routes/(app)/uload/links/+page.svelte | 4 +- .../apps/server/src/services/analytics.ts | 56 ++++++--------- .../apps/server/src/services/redirect.ts | 33 ++++----- 4 files changed, 107 insertions(+), 57 deletions(-) diff --git a/apps/manacore/apps/web/src/routes/(app)/uload/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/uload/+page.svelte index e8364d57d..f083aa88e 100644 --- a/apps/manacore/apps/web/src/routes/(app)/uload/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/uload/+page.svelte @@ -85,13 +85,61 @@ ) ); + const ULOAD_DOMAIN = import.meta.env.PUBLIC_ULOAD_DOMAIN || 'ulo.ad'; + function getShortUrl(code: string): string { - return `https://ulo.ad/${code}`; + return `https://${ULOAD_DOMAIN}/${code}`; + } + + function isValidUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } + } + + function isValidCustomCode(code: string): boolean { + return /^[a-zA-Z0-9_-]+$/.test(code); + } + + async function isShortCodeUnique(code: string): Promise { + const existing = await linkTable.where('shortCode').equals(code).first(); + return !existing; } async function createLink() { if (!newUrl) return; + + if (!isValidUrl(newUrl)) { + toast.error('Bitte eine gueltige URL eingeben (mit https://)'); + return; + } + const shortCode = newCustomCode || generateShortCode(); + + if (newCustomCode && !isValidCustomCode(newCustomCode)) { + toast.error('Custom Code darf nur Buchstaben, Zahlen, - und _ enthalten'); + return; + } + + if (!(await isShortCodeUnique(shortCode))) { + toast.error(`Short Code "${shortCode}" ist bereits vergeben`); + return; + } + + const maxClicks = newMaxClicks ? parseInt(newMaxClicks) : null; + if (maxClicks !== null && maxClicks < 1) { + toast.error('Max Klicks muss mindestens 1 sein'); + return; + } + + if (newExpiresAt && new Date(newExpiresAt) <= new Date()) { + toast.error('Ablaufdatum muss in der Zukunft liegen'); + return; + } + await linkTable.add({ id: crypto.randomUUID(), shortCode, @@ -101,7 +149,7 @@ description: null, isActive: true, password: newPassword || null, - maxClicks: newMaxClicks ? parseInt(newMaxClicks) : null, + maxClicks, expiresAt: newExpiresAt || null, clickCount: 0, qrCodeUrl: null, @@ -139,6 +187,23 @@ async function saveEdit() { if (!editingLink || !editUrl) return; + + if (!isValidUrl(editUrl)) { + toast.error('Bitte eine gueltige URL eingeben (mit https://)'); + return; + } + + const maxClicks = editMaxClicks ? parseInt(editMaxClicks) : null; + if (maxClicks !== null && maxClicks < 1) { + toast.error('Max Klicks muss mindestens 1 sein'); + return; + } + + if (editExpiresAt && new Date(editExpiresAt) <= new Date()) { + toast.error('Ablaufdatum muss in der Zukunft liegen'); + return; + } + await linkTable.update(editingLink.id, { originalUrl: editUrl, title: editTitle || null, @@ -147,7 +212,7 @@ utmCampaign: editUtmCampaign || null, expiresAt: editExpiresAt || null, password: editPassword || null, - maxClicks: editMaxClicks ? parseInt(editMaxClicks) : null, + maxClicks, }); toast.success('Link aktualisiert'); editingLink = null; diff --git a/apps/manacore/apps/web/src/routes/(app)/uload/links/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/uload/links/+page.svelte index cca9d1866..818de0b4d 100644 --- a/apps/manacore/apps/web/src/routes/(app)/uload/links/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/uload/links/+page.svelte @@ -59,8 +59,10 @@ ) ); + const ULOAD_DOMAIN = import.meta.env.PUBLIC_ULOAD_DOMAIN || 'ulo.ad'; + function getShortUrl(code: string): string { - return `https://ulo.ad/${code}`; + return `https://${ULOAD_DOMAIN}/${code}`; } function copyShortUrl(code: string) { diff --git a/apps/uload/apps/server/src/services/analytics.ts b/apps/uload/apps/server/src/services/analytics.ts index 462eff2f9..c954ca031 100644 --- a/apps/uload/apps/server/src/services/analytics.ts +++ b/apps/uload/apps/server/src/services/analytics.ts @@ -2,8 +2,8 @@ import { sql } from 'drizzle-orm'; import type { Database } from '../db/connection'; /** - * Analytics service that reads click data from mana-sync's sync_changes table. - * Clicks are stored with app_id='uload', table_name='clicks'. + * Analytics service that reads click data from the dedicated uload.clicks table. + * Uses SQL indexes on link_id, clicked_at, country, device_type for fast aggregation. */ export class AnalyticsService { constructor(private db: Database) {} @@ -12,11 +12,9 @@ export class AnalyticsService { const result = await this.db.execute(sql` SELECT count(*)::int as "totalClicks", - count(DISTINCT data->>'ipHash')::int as "uniqueVisitors" - FROM sync_changes - WHERE app_id = 'uload' AND table_name = 'clicks' - AND data->>'linkId' = ${linkId} - AND op = 'insert' + count(DISTINCT ip_hash)::int as "uniqueVisitors" + FROM uload.clicks + WHERE link_id = ${linkId} `); const rows = result as unknown as { totalClicks: number; uniqueVisitors: number }[]; return rows[0] ?? { totalClicks: 0, uniqueVisitors: 0 }; @@ -25,28 +23,24 @@ export class AnalyticsService { async getClicksOverTime(linkId: string, days = 30) { return this.db.execute(sql` SELECT - date_trunc('day', created_at)::date::text as date, + date_trunc('day', clicked_at)::date::text as date, count(*)::int as count - FROM sync_changes - WHERE app_id = 'uload' AND table_name = 'clicks' - AND data->>'linkId' = ${linkId} - AND op = 'insert' - AND created_at >= now() - make_interval(days => ${days}) - GROUP BY date_trunc('day', created_at) - ORDER BY date_trunc('day', created_at) + FROM uload.clicks + WHERE link_id = ${linkId} + AND clicked_at >= now() - make_interval(days => ${days}) + GROUP BY date_trunc('day', clicked_at) + ORDER BY date_trunc('day', clicked_at) `) as unknown as { date: string; count: number }[]; } async getTopReferrers(linkId: string, limit = 10) { return this.db.execute(sql` SELECT - COALESCE(data->>'referer', 'Direct') as referer, + COALESCE(referer, 'Direct') as referer, count(*)::int as count - FROM sync_changes - WHERE app_id = 'uload' AND table_name = 'clicks' - AND data->>'linkId' = ${linkId} - AND op = 'insert' - GROUP BY data->>'referer' + FROM uload.clicks + WHERE link_id = ${linkId} + GROUP BY referer ORDER BY count(*) DESC LIMIT ${limit} `) as unknown as { referer: string; count: number }[]; @@ -55,13 +49,11 @@ export class AnalyticsService { async getDeviceBreakdown(linkId: string) { return this.db.execute(sql` SELECT - COALESCE(data->>'deviceType', 'Unknown') as "deviceType", + COALESCE(device_type, 'Unknown') as "deviceType", count(*)::int as count - FROM sync_changes - WHERE app_id = 'uload' AND table_name = 'clicks' - AND data->>'linkId' = ${linkId} - AND op = 'insert' - GROUP BY data->>'deviceType' + FROM uload.clicks + WHERE link_id = ${linkId} + GROUP BY device_type ORDER BY count(*) DESC `) as unknown as { deviceType: string; count: number }[]; } @@ -69,13 +61,11 @@ export class AnalyticsService { async getCountryBreakdown(linkId: string) { return this.db.execute(sql` SELECT - COALESCE(data->>'country', 'Unknown') as country, + COALESCE(country, 'Unknown') as country, count(*)::int as count - FROM sync_changes - WHERE app_id = 'uload' AND table_name = 'clicks' - AND data->>'linkId' = ${linkId} - AND op = 'insert' - GROUP BY data->>'country' + FROM uload.clicks + WHERE link_id = ${linkId} + GROUP BY country ORDER BY count(*) DESC `) as unknown as { country: string; count: number }[]; } diff --git a/apps/uload/apps/server/src/services/redirect.ts b/apps/uload/apps/server/src/services/redirect.ts index f204e2dbc..080fecd7b 100644 --- a/apps/uload/apps/server/src/services/redirect.ts +++ b/apps/uload/apps/server/src/services/redirect.ts @@ -12,9 +12,8 @@ interface ResolvedLink { } /** - * Reads link data from mana-sync's sync_changes table. - * Data is stored as JSONB in the `data` column with app_id='uload' and table_name='links'. - * We get the latest version of each record by using DISTINCT ON. + * Reads link data from mana-sync's sync_changes table (local-first architecture). + * Writes clicks to the dedicated uload.clicks table for performant analytics. */ export class RedirectService { constructor(private db: Database) {} @@ -60,24 +59,18 @@ export class RedirectService { country?: string; } ) { - const clickId = crypto.randomUUID(); - const clickData = JSON.stringify({ - id: clickId, - linkId, - ipHash: meta.ipHash || null, - userAgent: meta.userAgent || null, - referer: meta.referer || null, - browser: meta.browser || null, - deviceType: meta.deviceType || null, - os: meta.os || null, - country: meta.country || null, - clickedAt: new Date().toISOString(), - }); - - // Insert click as a sync_changes record so it's visible to clients await this.db.execute(sql` - INSERT INTO sync_changes (app_id, table_name, record_id, user_id, op, data, client_id) - VALUES ('uload', 'clicks', ${clickId}, 'system', 'insert', ${clickData}::jsonb, 'uload-server') + INSERT INTO uload.clicks (link_id, ip_hash, user_agent, referer, browser, device_type, os, country) + VALUES ( + ${linkId}, + ${meta.ipHash || null}, + ${meta.userAgent || null}, + ${meta.referer || null}, + ${meta.browser || null}, + ${meta.deviceType || null}, + ${meta.os || null}, + ${meta.country || null} + ) `); } }