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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 16:30:17 +02:00
parent 3bd717bc93
commit 51da1f8a59
4 changed files with 107 additions and 57 deletions

View file

@ -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 }[];
}

View file

@ -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}
)
`);
}
}