.188625fb7dd457ed-00000000.bun-build
.188625fb7dd457ed-00000000.bun-build
This is a binary file and will not be displayed.
.188625fbf9fffb7a-00000000.bun-build
.188625fbf9fffb7a-00000000.bun-build
This is a binary file and will not be displayed.
+7
-1
.env.example
+7
-1
.env.example
···
1
1
# Slack API Configuration
2
2
# Get these from https://api.slack.com/apps
3
-
SLACK_TOKEN=xoxb-123456789012-123456789012-abcdefghijklmnopqrstuvwx
3
+
# Either SLACK_BOT_TOKEN or SLACK_TOKEN will work (SLACK_BOT_TOKEN takes precedence)
4
+
SLACK_BOT_TOKEN=xoxb-your-bot-token-here
4
5
SLACK_SIGNING_SECRET=1234567890abcdef1234567890abcdef
6
+
7
+
# Optional: Slack Rate Limiting
8
+
# Adjust these if you're hitting rate limits or need more throughput
9
+
# SLACK_MAX_CONCURRENT=3 # Max concurrent requests (default: 3)
10
+
# SLACK_MIN_TIME_MS=200 # Min time between requests in ms (default: 200, ~5 req/s)
5
11
6
12
# Environment
7
13
NODE_ENV=production
+37
-32
src/cache.ts
+37
-32
src/cache.ts
···
11
11
*/
12
12
interface SlackUserProvider {
13
13
getUserInfo(userId: string): Promise<SlackUser>;
14
+
testAuth(): Promise<boolean>;
14
15
}
15
16
16
17
/**
···
36
37
37
38
interface UserAgentMetrics {
38
39
userAgent: string;
39
-
count: number;
40
+
hits: number;
40
41
}
41
42
42
43
interface LatencyPercentiles {
···
649
650
heapUsed: number;
650
651
heapTotal: number;
651
652
percentage: number;
653
+
details?: {
654
+
heapUsedMiB: number;
655
+
heapTotalMiB: number;
656
+
heapPercent: number;
657
+
rssMiB: number;
658
+
externalMiB: number;
659
+
arrayBuffersMiB: number;
660
+
};
652
661
};
653
662
};
654
663
uptime: number;
655
664
}> {
656
-
const checks = {
665
+
const checks: {
666
+
database: { status: boolean; latency?: number };
667
+
slackApi: { status: boolean; error?: string };
668
+
queueDepth: number;
669
+
memoryUsage: {
670
+
heapUsed: number;
671
+
heapTotal: number;
672
+
percentage: number;
673
+
details?: {
674
+
heapUsedMiB: number;
675
+
heapTotalMiB: number;
676
+
heapPercent: number;
677
+
rssMiB: number;
678
+
externalMiB: number;
679
+
arrayBuffersMiB: number;
680
+
};
681
+
};
682
+
} = {
657
683
database: { status: false, latency: 0 },
658
684
slackApi: { status: false },
659
685
queueDepth: this.userUpdateQueue.size,
···
676
702
// Check Slack API if wrapper is available
677
703
if (this.slackWrapper) {
678
704
try {
679
-
await this.slackWrapper.getUserInfo("U062UG485EE"); // Use a known test user
705
+
await this.slackWrapper.testAuth();
680
706
checks.slackApi = { status: true };
681
707
} catch (error) {
682
708
checks.slackApi = {
···
933
959
}
934
960
935
961
/**
936
-
* Lists all emoji in the cache
937
-
* @returns Array of Emoji objects that haven't expired
938
-
*/
939
-
async listEmojis(): Promise<Emoji[]> {
940
-
const results = this.db
941
-
.query("SELECT * FROM emojis WHERE expiration > ?")
942
-
.all(Date.now()) as Emoji[];
943
-
944
-
return results.map((result) => ({
945
-
type: "emoji",
946
-
id: result.id,
947
-
name: result.name,
948
-
alias: result.alias || null,
949
-
imageUrl: result.imageUrl,
950
-
expiration: new Date(result.expiration),
951
-
}));
952
-
}
953
-
954
-
/**
955
962
* Retrieves a user from the cache
956
963
* @param userId Unique identifier of the user
957
964
* @returns User object if found and not expired, null otherwise
958
965
*/
959
966
async getUser(userId: string): Promise<User | null> {
967
+
const normalizedId = userId.toUpperCase();
960
968
const result = this.db
961
969
.query("SELECT * FROM users WHERE userId = ?")
962
-
.get(userId.toUpperCase()) as User;
970
+
.get(normalizedId) as User;
963
971
964
972
if (!result) {
965
973
return null;
···
970
978
971
979
// If user is expired, remove and return null
972
980
if (expiration < now) {
973
-
this.db.run("DELETE FROM users WHERE userId = ?", [userId]);
981
+
this.db.run("DELETE FROM users WHERE userId = ?", [normalizedId]);
974
982
return null;
975
983
}
976
984
···
983
991
const newExpiration = now + 7 * 24 * 60 * 60 * 1000;
984
992
this.db.run("UPDATE users SET expiration = ? WHERE userId = ?", [
985
993
newExpiration,
986
-
userId.toUpperCase(),
994
+
normalizedId,
987
995
]);
988
996
989
997
// Queue for background update to get fresh data
990
-
this.queueUserUpdate(userId);
998
+
this.queueUserUpdate(normalizedId);
991
999
992
1000
console.log(
993
-
`Touch-refresh: Extended TTL for user ${userId} and queued for update`,
1001
+
`Touch-refresh: Extended TTL for user ${normalizedId} and queued for update`,
994
1002
);
995
1003
}
996
1004
···
1799
1807
1800
1808
/**
1801
1809
* Gets user agents data from cumulative stats table
1802
-
* @param _days Unused - user_agent_stats is cumulative
1803
-
* @returns User agents data
1810
+
* @returns User agents data (cumulative, not time-filtered)
1804
1811
*/
1805
-
async getUserAgents(
1806
-
_days: number = 7,
1807
-
): Promise<Array<{ userAgent: string; hits: number }>> {
1812
+
async getUserAgents(): Promise<Array<{ userAgent: string; hits: number }>> {
1808
1813
const cacheKey = "useragents_all";
1809
1814
const cached = this.typedAnalyticsCache.getUserAgentData(cacheKey);
1810
1815
+38
-13
src/handlers/index.ts
+38
-13
src/handlers/index.ts
···
12
12
let cache!: SlackCache;
13
13
let slackApp!: SlackWrapper;
14
14
15
+
/**
16
+
* Parse a string to a positive integer, returning a fallback if invalid
17
+
*/
18
+
function parsePositiveInt(value: string | null, fallback: number): number {
19
+
if (!value) return fallback;
20
+
const n = Number.parseInt(value, 10);
21
+
return Number.isFinite(n) && n > 0 ? n : fallback;
22
+
}
23
+
15
24
export function injectDependencies(
16
25
cacheInstance: SlackCache,
17
26
slackInstance: SlackWrapper,
···
177
186
request,
178
187
recordAnalytics,
179
188
) => {
189
+
const configuredToken = process.env.BEARER_TOKEN;
190
+
if (!configuredToken) {
191
+
console.error("BEARER_TOKEN is not configured");
192
+
await recordAnalytics(500);
193
+
return new Response("Server misconfigured", { status: 500 });
194
+
}
195
+
180
196
const authHeader = request.headers.get("authorization") || "";
181
-
if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) {
197
+
if (authHeader !== `Bearer ${configuredToken}`) {
182
198
await recordAnalytics(401);
183
199
return new Response("Unauthorized", { status: 401 });
184
200
}
···
246
262
request,
247
263
recordAnalytics,
248
264
) => {
265
+
const configuredToken = process.env.BEARER_TOKEN;
266
+
if (!configuredToken) {
267
+
console.error("BEARER_TOKEN is not configured");
268
+
await recordAnalytics(500);
269
+
return new Response("Server misconfigured", { status: 500 });
270
+
}
271
+
249
272
const authHeader = request.headers.get("authorization") || "";
250
-
if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) {
273
+
if (authHeader !== `Bearer ${configuredToken}`) {
251
274
await recordAnalytics(401);
252
275
return new Response("Unauthorized", { status: 401 });
253
276
}
···
262
285
) => {
263
286
const url = new URL(request.url);
264
287
const params = new URLSearchParams(url.search);
265
-
const daysParam = params.get("days");
266
-
const days = daysParam ? parseInt(daysParam, 10) : 7;
288
+
const days = parsePositiveInt(params.get("days"), 7);
267
289
268
290
const stats = await cache.getEssentialStats(days);
269
291
await recordAnalytics(200);
···
276
298
) => {
277
299
const url = new URL(request.url);
278
300
const params = new URLSearchParams(url.search);
279
-
const daysParam = params.get("days");
280
-
const days = daysParam ? parseInt(daysParam, 10) : 7;
301
+
const days = parsePositiveInt(params.get("days"), 7);
281
302
282
303
const chartData = await cache.getChartData(days);
283
304
await recordAnalytics(200);
···
311
332
312
333
const startParam = params.get("start");
313
334
const endParam = params.get("end");
314
-
const daysParam = params.get("days");
315
335
316
336
let options: { days?: number; startTime?: number; endTime?: number } = {};
317
337
318
338
if (startParam && endParam) {
319
-
options.startTime = parseInt(startParam, 10);
320
-
options.endTime = parseInt(endParam, 10);
339
+
const start = parsePositiveInt(startParam, 0);
340
+
const end = parsePositiveInt(endParam, 0);
341
+
if (start > 0 && end > 0) {
342
+
options.startTime = start;
343
+
options.endTime = end;
344
+
} else {
345
+
options.days = 7;
346
+
}
321
347
} else {
322
-
options.days = daysParam ? parseInt(daysParam, 10) : 7;
348
+
options.days = parsePositiveInt(params.get("days"), 7);
323
349
}
324
350
325
351
const traffic = cache.getTraffic(options);
···
333
359
) => {
334
360
const url = new URL(request.url);
335
361
const params = new URLSearchParams(url.search);
336
-
const daysParam = params.get("days");
337
-
const days = daysParam ? parseInt(daysParam, 10) : 7;
362
+
const days = parsePositiveInt(params.get("days"), 7);
338
363
339
364
const [essentialStats, chartData, userAgents] = await Promise.all([
340
365
cache.getEssentialStats(days),
341
366
cache.getChartData(days),
342
-
cache.getUserAgents(days),
367
+
cache.getUserAgents(),
343
368
]);
344
369
345
370
await recordAnalytics(200);
+3
-2
src/lib/analytics-wrapper.ts
+3
-2
src/lib/analytics-wrapper.ts
···
32
32
"unknown";
33
33
const referer = request.headers.get("referer") || undefined;
34
34
35
-
// Use the actual request URL for dynamic paths, fallback to provided path
36
-
const analyticsPath = path.includes(":") ? request.url : path;
35
+
// Use the pathname for dynamic paths to ensure proper endpoint grouping
36
+
const requestUrl = new URL(request.url);
37
+
const analyticsPath = path.includes(":") ? requestUrl.pathname : path;
37
38
38
39
await cache.recordRequest(
39
40
analyticsPath,
+35
-9
src/routes/api-routes.ts
+35
-9
src/routes/api-routes.ts
···
341
341
type: "object",
342
342
properties: {
343
343
totalRequests: { type: "number", example: 12345 },
344
-
averageResponseTime: { type: "number", example: 23.5 },
344
+
averageResponseTime: {
345
+
type: "number",
346
+
nullable: true,
347
+
example: 23.5,
348
+
},
345
349
uptime: { type: "number", example: 99.9 },
346
-
period: { type: "string", example: "7 days" },
347
350
},
348
351
}),
349
352
]),
···
371
374
},
372
375
responses: Object.fromEntries([
373
376
apiResponse(200, "Chart data retrieved successfully", {
374
-
type: "array",
375
-
items: {
376
-
type: "object",
377
-
properties: {
378
-
time: { type: "string", example: "2024-01-01T12:00:00Z" },
379
-
count: { type: "number", example: 42 },
380
-
averageResponseTime: { type: "number", example: 25.3 },
377
+
type: "object",
378
+
properties: {
379
+
requestsByDay: {
380
+
type: "array",
381
+
items: {
382
+
type: "object",
383
+
properties: {
384
+
date: { type: "string", example: "2024-01-01 12:00:00" },
385
+
count: { type: "number", example: 42 },
386
+
averageResponseTime: { type: "number", example: 25.3 },
387
+
},
388
+
},
389
+
},
390
+
latencyOverTime: {
391
+
type: "array",
392
+
items: {
393
+
type: "object",
394
+
properties: {
395
+
time: { type: "string", example: "2024-01-01 12:00:00" },
396
+
averageResponseTime: { type: "number", example: 25.3 },
397
+
p95: { type: "number", nullable: true, example: null },
398
+
count: { type: "number", example: 42 },
399
+
},
400
+
},
381
401
},
382
402
},
383
403
}),
···
457
477
description: "Unix timestamp of bucket start",
458
478
},
459
479
hits: { type: "number", example: 42 },
480
+
avgLatency: {
481
+
type: "number",
482
+
nullable: true,
483
+
example: 25.3,
484
+
description: "Average response time in ms",
485
+
},
460
486
},
461
487
},
462
488
}),
+49
-13
src/slackWrapper.ts
+49
-13
src/slackWrapper.ts
···
22
22
class SlackWrapper {
23
23
private signingSecret: string;
24
24
private botToken: string;
25
-
private limiter = new Bottleneck({
26
-
maxConcurrent: 10,
27
-
minTime: 10, // 100 requests per second
28
-
});
25
+
private limiter: Bottleneck;
29
26
30
27
/**
31
28
* Creates a new SlackWrapper instance
···
35
32
constructor(config?: SlackConfig) {
36
33
this.signingSecret =
37
34
config?.signingSecret || process.env.SLACK_SIGNING_SECRET || "";
38
-
this.botToken = config?.botToken || process.env.SLACK_BOT_TOKEN || "";
35
+
this.botToken =
36
+
config?.botToken ||
37
+
process.env.SLACK_BOT_TOKEN ||
38
+
process.env.SLACK_TOKEN ||
39
+
"";
40
+
41
+
// Configure rate limiting - defaults are conservative to respect Slack API limits
42
+
const maxConcurrent = Number(process.env.SLACK_MAX_CONCURRENT ?? 3);
43
+
const minTime = Number(process.env.SLACK_MIN_TIME_MS ?? 200); // ~5 requests per second
44
+
this.limiter = new Bottleneck({
45
+
maxConcurrent: Number.isFinite(maxConcurrent) && maxConcurrent > 0 ? maxConcurrent : 3,
46
+
minTime: Number.isFinite(minTime) && minTime > 0 ? minTime : 200,
47
+
});
39
48
40
49
const missingFields = [];
41
50
if (!this.signingSecret) missingFields.push("signing secret");
···
43
52
44
53
if (missingFields.length > 0) {
45
54
throw new Error(
46
-
`Missing required Slack credentials: ${missingFields.join(" and ")} either pass them to the class or set them as environment variables`,
55
+
`Missing required Slack credentials: ${missingFields.join(" and ")} either pass them to the class or set them as environment variables (SLACK_BOT_TOKEN or SLACK_TOKEN)`,
47
56
);
48
57
}
49
58
}
···
124
133
}
125
134
126
135
/**
127
-
* Verifies a Slack request signature
128
-
* @param signature The signature from the request header
129
-
* @param timestamp The timestamp from the request header
136
+
* Verifies a Slack request signature with timestamp freshness check
137
+
* @param signature The signature from the request header (x-slack-signature)
138
+
* @param timestamp The timestamp from the request header (x-slack-request-timestamp)
130
139
* @param body The raw request body
140
+
* @param maxAgeSeconds Maximum age of timestamp in seconds (default: 5 minutes)
131
141
* @returns boolean indicating if the signature is valid
132
142
*/
133
-
verifySignature(signature: string, timestamp: string, body: string): boolean {
143
+
verifySignature(
144
+
signature: string,
145
+
timestamp: string,
146
+
body: string,
147
+
maxAgeSeconds: number = 300,
148
+
): boolean {
149
+
if (!signature || !timestamp) {
150
+
return false;
151
+
}
152
+
153
+
// Reject old timestamps to prevent replay attacks
154
+
const ts = Number(timestamp);
155
+
if (!Number.isFinite(ts)) {
156
+
return false;
157
+
}
158
+
159
+
const now = Math.floor(Date.now() / 1000);
160
+
if (Math.abs(now - ts) > maxAgeSeconds) {
161
+
return false;
162
+
}
163
+
134
164
const baseString = `v0:${timestamp}:${body}`;
135
165
const hmac = createHmac("sha256", this.signingSecret);
136
-
const computedSignature = `v0=${hmac.update(baseString).digest("hex")}`;
166
+
const expected = `v0=${hmac.update(baseString).digest("hex")}`;
167
+
168
+
// Ensure equal length before timingSafeEqual to avoid exception
169
+
if (expected.length !== signature.length) {
170
+
return false;
171
+
}
172
+
137
173
return timingSafeEqual(
138
-
new Uint8Array(Buffer.from(signature)),
139
-
new Uint8Array(Buffer.from(computedSignature)),
174
+
Buffer.from(signature, "utf8"),
175
+
Buffer.from(expected, "utf8"),
140
176
);
141
177
}
142
178
}