Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ OPENAI_API_KEY=replace-me
ANTHROPIC_API_KEY=replace-me
PIAPI_API_KEY=replace-me
KIEAI_API_KEY=replace-me
VISITOR_NOTIFY_SECRET=replace-me-with-a-random-string
58 changes: 58 additions & 0 deletions functions/_middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { loadUserFromSession } from './lib/auth';
import { buildRequestId } from './lib/db';
import type { AppContext, AppRouteHandler } from './lib/env';

const ASSET_EXTENSIONS = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|wasm|webp|avif|mp4|webm)$/i;

function withHeaders(response: Response, request: Request): Response {
const headers = new Headers(response.headers);
const { pathname } = new URL(request.url);
Expand All @@ -19,6 +21,57 @@ function withHeaders(response: Response, request: Request): Response {
});
}

function shouldTrackVisit(request: Request): boolean {
const url = new URL(request.url);
if (request.method !== 'GET') return false;
if (url.pathname.startsWith('/api/')) return false;
if (ASSET_EXTENSIONS.test(url.pathname)) return false;
// Skip known bots
const ua = request.headers.get('user-agent') ?? '';
if (/bot|crawl|spider|slurp|facebookexternalhit|preview/i.test(ua)) return false;
return true;
}

interface VisitEntry {
ts: number;
path: string;
country?: string;
city?: string;
ua?: string;
referer?: string;
}

function buildVisitKey(ts: number): string {
const newestFirst = String(9_999_999_999_999 - ts).padStart(13, '0');
return `visit2:${newestFirst}:${ts}:${crypto.randomUUID().slice(0, 8)}`;
}

async function trackVisit(context: AppContext): Promise<void> {
try {
const request = context.request;
const url = new URL(request.url);
const cfData = (request as unknown as { cf?: Record<string, string> }).cf;

const entry: VisitEntry = {
ts: Date.now(),
path: url.pathname,
country: cfData?.country,
city: cfData?.city,
ua: (request.headers.get('user-agent') ?? '').slice(0, 200),
referer: request.headers.get('referer') ?? undefined,
};

// Store newest-first keys so polling clients can read the latest visits efficiently.
const key = buildVisitKey(entry.ts);
await context.env.KV.put(key, '', {
expirationTtl: 3600,
metadata: entry,
});
} catch {
// Never let tracking break the request
}
}

export const onRequest: AppRouteHandler = async (context: AppContext): Promise<Response> => {
context.data.requestId = buildRequestId(context.request);
context.data.user = null;
Expand All @@ -39,6 +92,11 @@ export const onRequest: AppRouteHandler = async (context: AppContext): Promise<R
});
}

// Track page visits in background (non-blocking)
if (shouldTrackVisit(context.request)) {
context.waitUntil(trackVisit(context));
}

const response = await context.next();
return withHeaders(response, context.request);
};
103 changes: 103 additions & 0 deletions functions/api/visits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { json, methodNotAllowed } from '../lib/db';
import type { AppContext, AppRouteHandler } from '../lib/env';

interface VisitEntry {
ts: number;
path: string;
country?: string;
city?: string;
ua?: string;
referer?: string;
}

interface ListedVisitKey {
metadata?: unknown;
name: string;
}

function parseVisitTimestamp(name: string): number | null {
const parts = name.split(':');

if (parts[0] === 'visit2' && parts.length >= 4) {
const ts = parseInt(parts[2], 10);
return Number.isFinite(ts) ? ts : null;
}

if (parts[0] === 'visit' && parts.length >= 3) {
const ts = parseInt(parts[1], 10);
return Number.isFinite(ts) ? ts : null;
}

return null;
}

async function loadVisitEntry(context: AppContext, key: ListedVisitKey): Promise<VisitEntry | null> {
if (key.metadata && typeof key.metadata === 'object') {
const metadata = key.metadata as Partial<VisitEntry>;
if (typeof metadata.ts === 'number' && typeof metadata.path === 'string') {
return {
city: metadata.city,
country: metadata.country,
path: metadata.path,
referer: metadata.referer,
ts: metadata.ts,
ua: metadata.ua,
};
}
}

return context.env.KV.get<VisitEntry>(key.name, { type: 'json' });
}

export const onRequest: AppRouteHandler = async (context: AppContext): Promise<Response> => {
if (context.request.method !== 'GET') {
return methodNotAllowed(['GET']);
}

// Auth: require VISITOR_NOTIFY_SECRET as query param or header
const url = new URL(context.request.url);
const secret = url.searchParams.get('secret') ?? context.request.headers.get('x-visitor-secret');
const expected = context.env.VISITOR_NOTIFY_SECRET;

if (!expected || !secret || secret !== expected) {
return json({ error: 'unauthorized' }, { status: 401 });
}

// Optional: only return visits after this timestamp
const sinceParam = url.searchParams.get('since');
const parsedSince = sinceParam ? parseInt(sinceParam, 10) : 0;
const parsedLimit = parseInt(url.searchParams.get('limit') ?? '50', 10);
const since = Number.isFinite(parsedSince) ? Math.max(parsedSince, 0) : 0;
const limit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 200) : 50;

try {
const batchLimit = Math.max(limit * 2, 50);
const [listedNewestFirst, listedLegacy] = await Promise.all([
context.env.KV.list({ prefix: 'visit2:', limit: batchLimit }),
context.env.KV.list({ prefix: 'visit:', limit: batchLimit }),
]);

const keys = [...listedNewestFirst.keys, ...listedLegacy.keys]
.map((key) => ({
key,
ts: parseVisitTimestamp(key.name),
}))
.filter((entry): entry is { key: ListedVisitKey; ts: number } => Number.isFinite(entry.ts))
.filter((entry) => !since || entry.ts > since)
.sort((a, b) => b.ts - a.ts)
.slice(0, limit);

const results = await Promise.all(keys.map(({ key }) => loadVisitEntry(context, key)));
const visits = results.filter((entry): entry is VisitEntry => Boolean(entry));

return json({
count: visits.length,
visits,
});
} catch (err) {
return json(
{ error: 'internal_error', message: String(err) },
{ status: 500 },
);
}
};
6 changes: 6 additions & 0 deletions functions/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export interface AppD1Database {
export interface AppKVNamespace {
delete(key: string): Promise<void>;
get<T = string>(key: string, options?: { type?: 'text' | 'json' | 'arrayBuffer' | 'stream' }): Promise<T | null>;
list(options?: {
prefix?: string;
limit?: number;
cursor?: string;
}): Promise<{ keys: { name: string; expiration?: number; metadata?: unknown }[]; list_complete: boolean; cursor?: string }>;
put(
key: string,
value: string | ArrayBuffer | ArrayBufferView | ReadableStream,
Expand Down Expand Up @@ -52,6 +57,7 @@ export interface Env {
STRIPE_PRICE_STUDIO?: string;
STRIPE_SECRET_KEY?: string;
STRIPE_WEBHOOK_SECRET?: string;
VISITOR_NOTIFY_SECRET?: string;
}

export interface AppUser {
Expand Down
152 changes: 152 additions & 0 deletions scripts/visit-notifier.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env node
/**
* Visit Notifier — Polls Cloudflare for new page visits and plays an audio notification.
*
* Usage:
* node scripts/visit-notifier.mjs
*
* Environment variables (or .env in project root):
* SITE_URL — Your deployed site URL (default: https://masterselects.pages.dev)
* VISITOR_NOTIFY_SECRET — The secret matching your Cloudflare env
* POLL_INTERVAL_MS — Polling interval in ms (default: 5000)
* BEEP_FREQUENCY — Beep frequency in Hz (default: 800)
* BEEP_DURATION — Beep duration in ms (default: 400)
*/

import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');

// ── Load .env if present ──────────────────────────────────────────
function loadEnv() {
try {
const envPath = resolve(ROOT, '.dev.vars');
const content = readFileSync(envPath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
let value = trimmed.slice(eqIdx + 1).trim();
// Strip quotes
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (!process.env[key]) process.env[key] = value;
}
} catch {
// .dev.vars not found — rely on process.env
}
}

loadEnv();

// ── Config ────────────────────────────────────────────────────────
const SITE_URL = (process.env.SITE_URL || 'https://masterselects.pages.dev').replace(/\/$/, '');
const SECRET = process.env.VISITOR_NOTIFY_SECRET;
const POLL_MS = parseInt(process.env.POLL_INTERVAL_MS || '5000', 10);
const BEEP_FREQ = parseInt(process.env.BEEP_FREQUENCY || '800', 10);
const BEEP_DUR = parseInt(process.env.BEEP_DURATION || '400', 10);

if (!SECRET) {
console.error('ERROR: VISITOR_NOTIFY_SECRET is not set.');
console.error('Set it in .dev.vars or as an environment variable.');
process.exit(1);
}

// ── Audio notification ────────────────────────────────────────────
function playBeep() {
try {
if (process.platform === 'win32') {
execSync(
`powershell -NoProfile -Command "[console]::beep(${BEEP_FREQ},${BEEP_DUR})"`,
{ stdio: 'ignore' },
);
} else if (process.platform === 'darwin') {
execSync('afplay /System/Library/Sounds/Glass.aiff', { stdio: 'ignore' });
} else {
// Linux: try paplay, then beep, then printf BEL
try {
execSync('paplay /usr/share/sounds/freedesktop/stereo/message-new-instant.oga', { stdio: 'ignore' });
} catch {
try {
execSync(`beep -f ${BEEP_FREQ} -l ${BEEP_DUR}`, { stdio: 'ignore' });
} catch {
process.stdout.write('\x07'); // terminal bell
}
}
}
} catch {
process.stdout.write('\x07'); // fallback: terminal bell
}
}

// ── State ─────────────────────────────────────────────────────────
let lastSeenTs = Date.now();
let totalVisits = 0;

// ── Formatting ────────────────────────────────────────────────────
function formatVisit(v) {
const time = new Date(v.ts).toLocaleTimeString('de-DE');
const location = [v.city, v.country].filter(Boolean).join(', ') || 'unknown';
return ` ${time} ${v.path.padEnd(30)} ${location}`;
}

// ── Polling ───────────────────────────────────────────────────────
async function poll() {
try {
const url = `${SITE_URL}/api/visits?secret=${encodeURIComponent(SECRET)}&since=${lastSeenTs}&limit=50`;
const res = await fetch(url, {
headers: { 'User-Agent': 'MasterSelects-VisitNotifier/1.0' },
});

if (!res.ok) {
const text = await res.text();
console.error(`[${new Date().toLocaleTimeString()}] API error ${res.status}: ${text.slice(0, 200)}`);
return;
}

const data = await res.json();

if (data.visits && data.visits.length > 0) {
// Sort oldest first for display
const sorted = [...data.visits].sort((a, b) => a.ts - b.ts);

for (const visit of sorted) {
totalVisits++;
console.log(`\n>>> NEW VISITOR (#${totalVisits}) <<<`);
console.log(formatVisit(visit));
playBeep();
}

// Update watermark to newest visit
const newest = Math.max(...data.visits.map((v) => v.ts));
lastSeenTs = newest;
}
} catch (err) {
console.error(`[${new Date().toLocaleTimeString()}] Poll error: ${err.message}`);
}
}

// ── Main ──────────────────────────────────────────────────────────
console.log('===========================================');
console.log(' MasterSelects Visit Notifier');
console.log('===========================================');
console.log(`Site: ${SITE_URL}`);
console.log(`Polling: every ${POLL_MS / 1000}s`);
console.log(`Audio: ${BEEP_FREQ}Hz, ${BEEP_DUR}ms`);
console.log('-------------------------------------------');
console.log('Waiting for visitors...\n');

// Initial beep to confirm audio works
playBeep();

// Poll loop
setInterval(poll, POLL_MS);
// Also poll immediately
poll();
8 changes: 8 additions & 0 deletions tools/visitor-tray/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
SITE_URL=https://www.masterselects.com
VISITOR_NOTIFY_SECRET=replace-me
POLL_INTERVAL_MS=5000
MAX_VISITS_PER_POLL=25
ALERT_SECONDS=10
ENABLE_SOUND=true
ENABLE_BALLOON=true
OPEN_SITE_ON_BALLOON_CLICK=true
19 changes: 19 additions & 0 deletions tools/visitor-tray/Install-Startup.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$shortcutName = 'MasterSelects Visitor Tray.lnk'
$startupDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Startup'
$shortcutPath = Join-Path $startupDir $shortcutName
$toolRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$launcherPath = (Resolve-Path (Join-Path $toolRoot 'start.cmd')).Path
$iconPath = (Resolve-Path (Join-Path $toolRoot '..\..\masterselects.ico')).Path

$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($shortcutPath)
$shortcut.TargetPath = $launcherPath
$shortcut.WorkingDirectory = $toolRoot
$shortcut.IconLocation = $iconPath
$shortcut.Description = 'MasterSelects visitor tray notifier'
$shortcut.Save()

Write-Host "Startup shortcut created: $shortcutPath"
Loading
Loading