Skip to content

Commit c2dc75d

Browse files
author
MasterSelects
committed
feat: add visit notifier edge tracking and tray app
1 parent 888fe3d commit c2dc75d

13 files changed

Lines changed: 1090 additions & 0 deletions

File tree

.dev.vars.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ OPENAI_API_KEY=replace-me
1313
ANTHROPIC_API_KEY=replace-me
1414
PIAPI_API_KEY=replace-me
1515
KIEAI_API_KEY=replace-me
16+
VISITOR_NOTIFY_SECRET=replace-me-with-a-random-string

functions/_middleware.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { loadUserFromSession } from './lib/auth';
22
import { buildRequestId } from './lib/db';
33
import type { AppContext, AppRouteHandler } from './lib/env';
44

5+
const ASSET_EXTENSIONS = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|wasm|webp|avif|mp4|webm)$/i;
6+
57
function withHeaders(response: Response, request: Request): Response {
68
const headers = new Headers(response.headers);
79
const { pathname } = new URL(request.url);
@@ -19,6 +21,57 @@ function withHeaders(response: Response, request: Request): Response {
1921
});
2022
}
2123

24+
function shouldTrackVisit(request: Request): boolean {
25+
const url = new URL(request.url);
26+
if (request.method !== 'GET') return false;
27+
if (url.pathname.startsWith('/api/')) return false;
28+
if (ASSET_EXTENSIONS.test(url.pathname)) return false;
29+
// Skip known bots
30+
const ua = request.headers.get('user-agent') ?? '';
31+
if (/bot|crawl|spider|slurp|facebookexternalhit|preview/i.test(ua)) return false;
32+
return true;
33+
}
34+
35+
interface VisitEntry {
36+
ts: number;
37+
path: string;
38+
country?: string;
39+
city?: string;
40+
ua?: string;
41+
referer?: string;
42+
}
43+
44+
function buildVisitKey(ts: number): string {
45+
const newestFirst = String(9_999_999_999_999 - ts).padStart(13, '0');
46+
return `visit2:${newestFirst}:${ts}:${crypto.randomUUID().slice(0, 8)}`;
47+
}
48+
49+
async function trackVisit(context: AppContext): Promise<void> {
50+
try {
51+
const request = context.request;
52+
const url = new URL(request.url);
53+
const cfData = (request as unknown as { cf?: Record<string, string> }).cf;
54+
55+
const entry: VisitEntry = {
56+
ts: Date.now(),
57+
path: url.pathname,
58+
country: cfData?.country,
59+
city: cfData?.city,
60+
ua: (request.headers.get('user-agent') ?? '').slice(0, 200),
61+
referer: request.headers.get('referer') ?? undefined,
62+
};
63+
64+
// Store newest-first keys so polling clients can read the latest visits efficiently.
65+
const key = buildVisitKey(entry.ts);
66+
await context.env.KV.put(key, '', {
67+
expirationTtl: 3600,
68+
metadata: entry,
69+
});
70+
} catch {
71+
// Never let tracking break the request
72+
}
73+
}
74+
2275
export const onRequest: AppRouteHandler = async (context: AppContext): Promise<Response> => {
2376
context.data.requestId = buildRequestId(context.request);
2477
context.data.user = null;
@@ -39,6 +92,11 @@ export const onRequest: AppRouteHandler = async (context: AppContext): Promise<R
3992
});
4093
}
4194

95+
// Track page visits in background (non-blocking)
96+
if (shouldTrackVisit(context.request)) {
97+
context.waitUntil(trackVisit(context));
98+
}
99+
42100
const response = await context.next();
43101
return withHeaders(response, context.request);
44102
};

functions/api/visits.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { json, methodNotAllowed } from '../lib/db';
2+
import type { AppContext, AppRouteHandler } from '../lib/env';
3+
4+
interface VisitEntry {
5+
ts: number;
6+
path: string;
7+
country?: string;
8+
city?: string;
9+
ua?: string;
10+
referer?: string;
11+
}
12+
13+
interface ListedVisitKey {
14+
metadata?: unknown;
15+
name: string;
16+
}
17+
18+
function parseVisitTimestamp(name: string): number | null {
19+
const parts = name.split(':');
20+
21+
if (parts[0] === 'visit2' && parts.length >= 4) {
22+
const ts = parseInt(parts[2], 10);
23+
return Number.isFinite(ts) ? ts : null;
24+
}
25+
26+
if (parts[0] === 'visit' && parts.length >= 3) {
27+
const ts = parseInt(parts[1], 10);
28+
return Number.isFinite(ts) ? ts : null;
29+
}
30+
31+
return null;
32+
}
33+
34+
async function loadVisitEntry(context: AppContext, key: ListedVisitKey): Promise<VisitEntry | null> {
35+
if (key.metadata && typeof key.metadata === 'object') {
36+
const metadata = key.metadata as Partial<VisitEntry>;
37+
if (typeof metadata.ts === 'number' && typeof metadata.path === 'string') {
38+
return {
39+
city: metadata.city,
40+
country: metadata.country,
41+
path: metadata.path,
42+
referer: metadata.referer,
43+
ts: metadata.ts,
44+
ua: metadata.ua,
45+
};
46+
}
47+
}
48+
49+
return context.env.KV.get<VisitEntry>(key.name, { type: 'json' });
50+
}
51+
52+
export const onRequest: AppRouteHandler = async (context: AppContext): Promise<Response> => {
53+
if (context.request.method !== 'GET') {
54+
return methodNotAllowed(['GET']);
55+
}
56+
57+
// Auth: require VISITOR_NOTIFY_SECRET as query param or header
58+
const url = new URL(context.request.url);
59+
const secret = url.searchParams.get('secret') ?? context.request.headers.get('x-visitor-secret');
60+
const expected = context.env.VISITOR_NOTIFY_SECRET;
61+
62+
if (!expected || !secret || secret !== expected) {
63+
return json({ error: 'unauthorized' }, { status: 401 });
64+
}
65+
66+
// Optional: only return visits after this timestamp
67+
const sinceParam = url.searchParams.get('since');
68+
const parsedSince = sinceParam ? parseInt(sinceParam, 10) : 0;
69+
const parsedLimit = parseInt(url.searchParams.get('limit') ?? '50', 10);
70+
const since = Number.isFinite(parsedSince) ? Math.max(parsedSince, 0) : 0;
71+
const limit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 200) : 50;
72+
73+
try {
74+
const batchLimit = Math.max(limit * 2, 50);
75+
const [listedNewestFirst, listedLegacy] = await Promise.all([
76+
context.env.KV.list({ prefix: 'visit2:', limit: batchLimit }),
77+
context.env.KV.list({ prefix: 'visit:', limit: batchLimit }),
78+
]);
79+
80+
const keys = [...listedNewestFirst.keys, ...listedLegacy.keys]
81+
.map((key) => ({
82+
key,
83+
ts: parseVisitTimestamp(key.name),
84+
}))
85+
.filter((entry): entry is { key: ListedVisitKey; ts: number } => Number.isFinite(entry.ts))
86+
.filter((entry) => !since || entry.ts > since)
87+
.sort((a, b) => b.ts - a.ts)
88+
.slice(0, limit);
89+
90+
const results = await Promise.all(keys.map(({ key }) => loadVisitEntry(context, key)));
91+
const visits = results.filter((entry): entry is VisitEntry => Boolean(entry));
92+
93+
return json({
94+
count: visits.length,
95+
visits,
96+
});
97+
} catch (err) {
98+
return json(
99+
{ error: 'internal_error', message: String(err) },
100+
{ status: 500 },
101+
);
102+
}
103+
};

functions/lib/env.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export interface AppD1Database {
1515
export interface AppKVNamespace {
1616
delete(key: string): Promise<void>;
1717
get<T = string>(key: string, options?: { type?: 'text' | 'json' | 'arrayBuffer' | 'stream' }): Promise<T | null>;
18+
list(options?: {
19+
prefix?: string;
20+
limit?: number;
21+
cursor?: string;
22+
}): Promise<{ keys: { name: string; expiration?: number; metadata?: unknown }[]; list_complete: boolean; cursor?: string }>;
1823
put(
1924
key: string,
2025
value: string | ArrayBuffer | ArrayBufferView | ReadableStream,
@@ -52,6 +57,7 @@ export interface Env {
5257
STRIPE_PRICE_STUDIO?: string;
5358
STRIPE_SECRET_KEY?: string;
5459
STRIPE_WEBHOOK_SECRET?: string;
60+
VISITOR_NOTIFY_SECRET?: string;
5561
}
5662

5763
export interface AppUser {

scripts/visit-notifier.mjs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Visit Notifier — Polls Cloudflare for new page visits and plays an audio notification.
4+
*
5+
* Usage:
6+
* node scripts/visit-notifier.mjs
7+
*
8+
* Environment variables (or .env in project root):
9+
* SITE_URL — Your deployed site URL (default: https://masterselects.pages.dev)
10+
* VISITOR_NOTIFY_SECRET — The secret matching your Cloudflare env
11+
* POLL_INTERVAL_MS — Polling interval in ms (default: 5000)
12+
* BEEP_FREQUENCY — Beep frequency in Hz (default: 800)
13+
* BEEP_DURATION — Beep duration in ms (default: 400)
14+
*/
15+
16+
import { execSync } from 'node:child_process';
17+
import { readFileSync } from 'node:fs';
18+
import { resolve, dirname } from 'node:path';
19+
import { fileURLToPath } from 'node:url';
20+
21+
const __dirname = dirname(fileURLToPath(import.meta.url));
22+
const ROOT = resolve(__dirname, '..');
23+
24+
// ── Load .env if present ──────────────────────────────────────────
25+
function loadEnv() {
26+
try {
27+
const envPath = resolve(ROOT, '.dev.vars');
28+
const content = readFileSync(envPath, 'utf-8');
29+
for (const line of content.split('\n')) {
30+
const trimmed = line.trim();
31+
if (!trimmed || trimmed.startsWith('#')) continue;
32+
const eqIdx = trimmed.indexOf('=');
33+
if (eqIdx === -1) continue;
34+
const key = trimmed.slice(0, eqIdx).trim();
35+
let value = trimmed.slice(eqIdx + 1).trim();
36+
// Strip quotes
37+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
38+
value = value.slice(1, -1);
39+
}
40+
if (!process.env[key]) process.env[key] = value;
41+
}
42+
} catch {
43+
// .dev.vars not found — rely on process.env
44+
}
45+
}
46+
47+
loadEnv();
48+
49+
// ── Config ────────────────────────────────────────────────────────
50+
const SITE_URL = (process.env.SITE_URL || 'https://masterselects.pages.dev').replace(/\/$/, '');
51+
const SECRET = process.env.VISITOR_NOTIFY_SECRET;
52+
const POLL_MS = parseInt(process.env.POLL_INTERVAL_MS || '5000', 10);
53+
const BEEP_FREQ = parseInt(process.env.BEEP_FREQUENCY || '800', 10);
54+
const BEEP_DUR = parseInt(process.env.BEEP_DURATION || '400', 10);
55+
56+
if (!SECRET) {
57+
console.error('ERROR: VISITOR_NOTIFY_SECRET is not set.');
58+
console.error('Set it in .dev.vars or as an environment variable.');
59+
process.exit(1);
60+
}
61+
62+
// ── Audio notification ────────────────────────────────────────────
63+
function playBeep() {
64+
try {
65+
if (process.platform === 'win32') {
66+
execSync(
67+
`powershell -NoProfile -Command "[console]::beep(${BEEP_FREQ},${BEEP_DUR})"`,
68+
{ stdio: 'ignore' },
69+
);
70+
} else if (process.platform === 'darwin') {
71+
execSync('afplay /System/Library/Sounds/Glass.aiff', { stdio: 'ignore' });
72+
} else {
73+
// Linux: try paplay, then beep, then printf BEL
74+
try {
75+
execSync('paplay /usr/share/sounds/freedesktop/stereo/message-new-instant.oga', { stdio: 'ignore' });
76+
} catch {
77+
try {
78+
execSync(`beep -f ${BEEP_FREQ} -l ${BEEP_DUR}`, { stdio: 'ignore' });
79+
} catch {
80+
process.stdout.write('\x07'); // terminal bell
81+
}
82+
}
83+
}
84+
} catch {
85+
process.stdout.write('\x07'); // fallback: terminal bell
86+
}
87+
}
88+
89+
// ── State ─────────────────────────────────────────────────────────
90+
let lastSeenTs = Date.now();
91+
let totalVisits = 0;
92+
93+
// ── Formatting ────────────────────────────────────────────────────
94+
function formatVisit(v) {
95+
const time = new Date(v.ts).toLocaleTimeString('de-DE');
96+
const location = [v.city, v.country].filter(Boolean).join(', ') || 'unknown';
97+
return ` ${time} ${v.path.padEnd(30)} ${location}`;
98+
}
99+
100+
// ── Polling ───────────────────────────────────────────────────────
101+
async function poll() {
102+
try {
103+
const url = `${SITE_URL}/api/visits?secret=${encodeURIComponent(SECRET)}&since=${lastSeenTs}&limit=50`;
104+
const res = await fetch(url, {
105+
headers: { 'User-Agent': 'MasterSelects-VisitNotifier/1.0' },
106+
});
107+
108+
if (!res.ok) {
109+
const text = await res.text();
110+
console.error(`[${new Date().toLocaleTimeString()}] API error ${res.status}: ${text.slice(0, 200)}`);
111+
return;
112+
}
113+
114+
const data = await res.json();
115+
116+
if (data.visits && data.visits.length > 0) {
117+
// Sort oldest first for display
118+
const sorted = [...data.visits].sort((a, b) => a.ts - b.ts);
119+
120+
for (const visit of sorted) {
121+
totalVisits++;
122+
console.log(`\n>>> NEW VISITOR (#${totalVisits}) <<<`);
123+
console.log(formatVisit(visit));
124+
playBeep();
125+
}
126+
127+
// Update watermark to newest visit
128+
const newest = Math.max(...data.visits.map((v) => v.ts));
129+
lastSeenTs = newest;
130+
}
131+
} catch (err) {
132+
console.error(`[${new Date().toLocaleTimeString()}] Poll error: ${err.message}`);
133+
}
134+
}
135+
136+
// ── Main ──────────────────────────────────────────────────────────
137+
console.log('===========================================');
138+
console.log(' MasterSelects Visit Notifier');
139+
console.log('===========================================');
140+
console.log(`Site: ${SITE_URL}`);
141+
console.log(`Polling: every ${POLL_MS / 1000}s`);
142+
console.log(`Audio: ${BEEP_FREQ}Hz, ${BEEP_DUR}ms`);
143+
console.log('-------------------------------------------');
144+
console.log('Waiting for visitors...\n');
145+
146+
// Initial beep to confirm audio works
147+
playBeep();
148+
149+
// Poll loop
150+
setInterval(poll, POLL_MS);
151+
// Also poll immediately
152+
poll();

tools/visitor-tray/.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
SITE_URL=https://www.masterselects.com
2+
VISITOR_NOTIFY_SECRET=replace-me
3+
POLL_INTERVAL_MS=5000
4+
MAX_VISITS_PER_POLL=25
5+
ALERT_SECONDS=10
6+
ENABLE_SOUND=true
7+
ENABLE_BALLOON=true
8+
OPEN_SITE_ON_BALLOON_CLICK=true
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Set-StrictMode -Version Latest
2+
$ErrorActionPreference = 'Stop'
3+
4+
$shortcutName = 'MasterSelects Visitor Tray.lnk'
5+
$startupDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Startup'
6+
$shortcutPath = Join-Path $startupDir $shortcutName
7+
$toolRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
8+
$launcherPath = (Resolve-Path (Join-Path $toolRoot 'start.cmd')).Path
9+
$iconPath = (Resolve-Path (Join-Path $toolRoot '..\..\masterselects.ico')).Path
10+
11+
$shell = New-Object -ComObject WScript.Shell
12+
$shortcut = $shell.CreateShortcut($shortcutPath)
13+
$shortcut.TargetPath = $launcherPath
14+
$shortcut.WorkingDirectory = $toolRoot
15+
$shortcut.IconLocation = $iconPath
16+
$shortcut.Description = 'MasterSelects visitor tray notifier'
17+
$shortcut.Save()
18+
19+
Write-Host "Startup shortcut created: $shortcutPath"

0 commit comments

Comments
 (0)