mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01: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
|
|
@ -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<boolean> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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