data/stats.db
data/stats.db
This is a binary file and will not be displayed.
data/stats.db-shm
data/stats.db-shm
This is a binary file and will not be displayed.
data/stats.db-wal
data/stats.db-wal
This is a binary file and will not be displayed.
+3
package.json
+3
package.json
+3
-2
src/index.ts
+3
-2
src/index.ts
···
1
1
import sharp from "sharp";
2
2
import { nanoid } from "nanoid";
3
+
import { recordHit } from "./stats";
3
4
4
5
// Configuration from env
5
6
const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL!;
···
82
83
return new Response("Not found", { status: 404 });
83
84
}
84
85
85
-
// Skip exists check - redirect directly, R2 will handle 404s
86
-
// This saves ~50-100ms per request
86
+
recordHit(imageKey);
87
+
87
88
const r2PublicUrl = `${R2_PUBLIC_URL}/${imageKey}`;
88
89
return Response.redirect(r2PublicUrl, 307);
89
90
},
+64
src/stats.ts
+64
src/stats.ts
···
1
+
import { Database } from "bun:sqlite";
2
+
import { mkdirSync, existsSync } from "node:fs";
3
+
import { dirname } from "node:path";
4
+
5
+
const DB_PATH = process.env.STATS_DB_PATH || "./data/stats.db";
6
+
7
+
const dbDir = dirname(DB_PATH);
8
+
if (!existsSync(dbDir)) {
9
+
mkdirSync(dbDir, { recursive: true });
10
+
}
11
+
12
+
const db = new Database(DB_PATH, { create: true });
13
+
14
+
db.exec("PRAGMA journal_mode = WAL");
15
+
db.exec("PRAGMA synchronous = NORMAL"); // Safe with WAL, faster than FULL
16
+
db.exec("PRAGMA busy_timeout = 5000");
17
+
18
+
db.exec(`
19
+
CREATE TABLE IF NOT EXISTS image_stats (
20
+
image_key TEXT NOT NULL,
21
+
bucket_hour INTEGER NOT NULL,
22
+
hits INTEGER NOT NULL DEFAULT 1,
23
+
PRIMARY KEY (image_key, bucket_hour)
24
+
) WITHOUT ROWID
25
+
`);
26
+
27
+
db.exec(`
28
+
CREATE INDEX IF NOT EXISTS idx_stats_time
29
+
ON image_stats(bucket_hour, image_key)
30
+
`);
31
+
32
+
const incrementStmt = db.prepare(`
33
+
INSERT INTO image_stats (image_key, bucket_hour, hits)
34
+
VALUES (?1, ?2, 1)
35
+
ON CONFLICT(image_key, bucket_hour) DO UPDATE SET hits = hits + 1
36
+
`);
37
+
38
+
export function recordHit(imageKey: string): void {
39
+
const now = Math.floor(Date.now() / 1000);
40
+
const bucketHour = now - (now % 3600);
41
+
incrementStmt.run(imageKey, bucketHour);
42
+
}
43
+
44
+
export function getStats(imageKey: string, sinceDays: number = 30) {
45
+
const since = Math.floor(Date.now() / 1000) - sinceDays * 86400;
46
+
return db
47
+
.prepare(
48
+
`SELECT bucket_hour, hits FROM image_stats
49
+
WHERE image_key = ? AND bucket_hour >= ?
50
+
ORDER BY bucket_hour`
51
+
)
52
+
.all(imageKey, since);
53
+
}
54
+
55
+
export function getTopImages(sinceDays: number = 7, limit: number = 10) {
56
+
const since = Math.floor(Date.now() / 1000) - sinceDays * 86400;
57
+
return db
58
+
.prepare(
59
+
`SELECT image_key, SUM(hits) as total
60
+
FROM image_stats WHERE bucket_hour >= ?
61
+
GROUP BY image_key ORDER BY total DESC LIMIT ?`
62
+
)
63
+
.all(since, limit);
64
+
}