mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
3bd717bc93
commit
51da1f8a59
4 changed files with 107 additions and 57 deletions
|
|
@ -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 }[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue