Skip to content

Commit 2139296

Browse files
committed
refactor(clicks): write the click row and the counter in one transaction
There were two tracking implementations. clicks.ts was the one in use, while clickTracking.ts had no importer anywhere in the repo, and the dead one was the better of the two: it wrote the row and the counter together instead of leaving links.click_count and COUNT(*) over clicks free to drift apart, and it dropped an unparsable referrer instead of keeping it raw, query string included. clicks.ts now does both inside one transaction and keeps what the active version did better: the bot filter up front, no re-throw so tracking can never break a redirect, and a failing UA parser that leaves the columns empty rather than skipping the row. clickTracking.ts is gone. Because the counter moved into trackClick, the separate incrementClickCount calls had to go, otherwise every click would count twice. That function has no callers left and is removed with them. One behaviour change follows from this: in the password flow the counter used to be incremented even for bots while the clicks row was already filtered, now the filter covers both.
1 parent c9158f3 commit 2139296

5 files changed

Lines changed: 71 additions & 167 deletions

File tree

app/[code]/page.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { redirect, notFound } from 'next/navigation'
22
import { headers } from 'next/headers'
33
import Link from 'next/link'
44
import { getTranslations } from 'next-intl/server'
5-
import { getLinkByCode, incrementClickCount, isLinkExpired } from '@/lib/links'
5+
import { getLinkByCode, isLinkExpired } from '@/lib/links'
66
import { trackClick } from '@/lib/clicks'
77
import { getClientIp } from '@/lib/rateLimit'
88
import { isBot } from '@/lib/botDetection'
@@ -51,18 +51,14 @@ export default async function RedirectPage({ params }: PageProps) {
5151
const userAgent = headersList.get('user-agent')
5252
const referrer = headersList.get('referer')
5353

54+
// trackClick schreibt Zeile und Zähler in einer Transaktion und filtert Bots
55+
// selbst. Bewusst ohne await, damit die Weiterleitung nicht auf die DB wartet.
5456
if (!isBot(userAgent)) {
55-
const clientIp = getClientIp(headersList)
56-
57-
incrementClickCount(link.id).catch((err) => {
58-
console.error('[Redirect] Counter-Error:', err)
59-
})
60-
6157
trackClick({
6258
linkId: link.id,
6359
userAgent,
6460
referrer,
65-
clientIp,
61+
clientIp: getClientIp(headersList),
6662
}).catch(() => { /* bereits intern geloggt */ })
6763
}
6864

app/api/verify/route.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NextRequest, NextResponse } from 'next/server'
22
import { verifyPasswordSchema } from '@/lib/validation'
3-
import { verifyLinkPassword, incrementClickCount } from '@/lib/links'
3+
import { verifyLinkPassword } from '@/lib/links'
44
import { trackClick } from '@/lib/clicks'
55
import { getClientIp, hashIp, checkRateLimit } from '@/lib/rateLimit'
66
import type { ApiError } from '@/types'
@@ -57,17 +57,11 @@ export async function POST(request: NextRequest) {
5757
}
5858

5959
// ─── Click tracken (fire-and-forget) ───────────────────────────
60-
const userAgent = request.headers.get('user-agent')
61-
const referrer = request.headers.get('referer')
62-
63-
incrementClickCount(link.id).catch((err) => {
64-
console.error('[Verify] Counter-Error:', err)
65-
})
66-
60+
// Zeile und Zähler schreibt trackClick in einer Transaktion.
6761
trackClick({
6862
linkId: link.id,
69-
userAgent,
70-
referrer,
63+
userAgent: request.headers.get('user-agent'),
64+
referrer: request.headers.get('referer'),
7165
clientIp,
7266
}).catch(() => { /* bereits intern geloggt */ })
7367

src/lib/clickTracking.ts

Lines changed: 0 additions & 108 deletions
This file was deleted.

src/lib/clicks.ts

Lines changed: 63 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { UAParser } from 'ua-parser-js'
2-
import { execute } from './db'
2+
import { getPool } from './db'
33
import { hashIp } from './rateLimit'
44
import { isBot } from './botDetection'
55
import type { DeviceType } from '@/types'
@@ -12,70 +12,102 @@ interface TrackClickOptions {
1212
}
1313

1414
/**
15-
* Trackt einen Click in der `clicks`-Tabelle.
16-
* Bots werden ignoriert. Fire-and-forget – wirft keinen Fehler.
15+
* Trackt einen Click: Insert in `clicks` und Increment von `links.click_count`
16+
* in EINER Transaktion. Damit können Zähler und Tabelle nicht auseinanderlaufen.
1717
*
18-
* Der Aufrufer sollte das Promise nicht awaiten,
19-
* damit Redirects nicht durch DB-Latenz verzögert werden.
18+
* Bots werden ignoriert, dann passiert gar nichts – weder Zeile noch Zähler.
19+
*
20+
* Fire-and-forget: Fehler werden geloggt, aber nicht geworfen. Der Aufrufer
21+
* soll das Promise nicht awaiten, damit Redirects nicht durch DB-Latenz
22+
* verzögert werden.
2023
*/
2124
export async function trackClick(opts: TrackClickOptions): Promise<void> {
2225
const { linkId, userAgent, referrer, clientIp } = opts
2326

2427
// Bots überspringen
2528
if (isBot(userAgent)) return
2629

27-
try {
28-
// ─── User-Agent parsen ─────────────────────────────────────────
29-
const parser = new UAParser(userAgent ?? '')
30-
const result = parser.getResult()
30+
// ─── User-Agent parsen ───────────────────────────────────────────
31+
// Ein kaputter UA darf das Tracking nicht verhindern, die Felder bleiben
32+
// dann einfach leer.
33+
let browser: string | null = null
34+
let os: string | null = null
35+
let device: DeviceType = 'desktop'
36+
37+
if (userAgent) {
38+
try {
39+
const result = new UAParser(userAgent).getResult()
40+
browser = result.browser.name ?? null
41+
os = result.os.name ?? null
42+
device = mapDeviceType(result.device.type)
43+
} catch {
44+
// Stiller Fehlschlag – wir tracken trotzdem
45+
}
46+
}
47+
48+
const cleanRef = sanitizeReferrer(referrer)
3149

32-
const browser = result.browser.name ?? null
33-
const os = result.os.name ?? null
34-
const device = mapDeviceType(result.device.type)
50+
// ─── Transaktion: Insert Click + Update Zähler ───────────────────
51+
let conn
52+
try {
53+
conn = await getPool().getConnection()
54+
} catch (err) {
55+
console.error('[trackClick] Keine DB-Verbindung:', err)
56+
return
57+
}
3558

36-
// ─── Referrer normalisieren (max. 500 Zeichen, ohne Querystring-Spam) ───
37-
const cleanRef = referrer ? sanitizeReferrer(referrer) : null
59+
try {
60+
await conn.beginTransaction()
3861

39-
// ─── In DB schreiben ──────────────────────────────────────────
40-
await execute(
62+
await conn.execute(
4163
`INSERT INTO clicks
4264
(link_id, ip_hash, referrer, browser, os, device_type)
4365
VALUES (?, ?, ?, ?, ?, ?)`,
44-
[
45-
linkId,
46-
hashIp(clientIp),
47-
cleanRef,
48-
browser,
49-
os,
50-
device,
51-
]
66+
[linkId, hashIp(clientIp), cleanRef, browser, os, device]
5267
)
68+
69+
await conn.execute(
70+
'UPDATE links SET click_count = click_count + 1 WHERE id = ?',
71+
[linkId]
72+
)
73+
74+
await conn.commit()
5375
} catch (err) {
5476
console.error('[trackClick] Fehler beim Tracking:', err)
5577
// Bewusst kein Re-throw – Tracking darf den Redirect nicht stören
78+
try {
79+
await conn.rollback()
80+
} catch (rollbackErr) {
81+
console.error('[trackClick] Rollback fehlgeschlagen:', rollbackErr)
82+
}
83+
} finally {
84+
conn.release()
5685
}
5786
}
5887

5988
/**
6089
* Mappt ua-parser-js Device-Types auf unsere DeviceType-Werte.
90+
* ua-parser-js v2 kennt zusätzlich 'xr' (VR/AR-Headsets), das zählt als mobile.
6191
*/
6292
function mapDeviceType(type: string | undefined): DeviceType {
63-
if (!type) return 'desktop'
6493
if (type === 'mobile') return 'mobile'
6594
if (type === 'tablet') return 'tablet'
95+
if (type === 'xr') return 'mobile'
6696
return 'desktop'
6797
}
6898

6999
/**
70100
* Bereinigt Referrer-URLs:
71-
* - Schneidet auf 500 Zeichen
72-
* - Entfernt Query-Strings (Privacy)
101+
* - Nur Origin + Pfad behalten, Query-Strings können Tokens enthalten
102+
* - Auf 500 Zeichen kürzen (Spaltenbreite)
103+
* - Nicht parsbare Werte verwerfen, statt sie roh zu übernehmen
73104
*/
74-
function sanitizeReferrer(ref: string): string | null {
105+
function sanitizeReferrer(referrer: string | null): string | null {
106+
if (!referrer) return null
75107
try {
76-
const url = new URL(ref)
77-
return `${url.protocol}//${url.host}${url.pathname}`.slice(0, 500)
108+
const url = new URL(referrer)
109+
return `${url.origin}${url.pathname}`.slice(0, 500)
78110
} catch {
79-
return ref.slice(0, 500)
111+
return null
80112
}
81113
}

src/lib/links.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -122,16 +122,6 @@ export async function verifyLinkPassword(
122122
return valid ? link : null
123123
}
124124

125-
/**
126-
* Erhöht den Click-Counter atomar.
127-
*/
128-
export async function incrementClickCount(linkId: number): Promise<void> {
129-
await execute(
130-
'UPDATE links SET click_count = click_count + 1 WHERE id = ?',
131-
[linkId]
132-
)
133-
}
134-
135125
/**
136126
* Löscht einen Link anhand seines Delete-Tokens.
137127
* Gibt true zurück bei Erfolg, false wenn Token ungültig.

0 commit comments

Comments
 (0)