Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145import { redis } from "@openstatus/upstash";import { z } from "zod";
import { getClientIP, ratelimit } from "../../../../lib/ratelimit";import { hashIP } from "../../../../lib/utils";
export const runtime = "edge";
const RATE_LIMIT_WINDOW = 60; // secondsconst MAX_REQUESTS_PER_WINDOW = 5;
// a docs pathname (from usePathname); constrained so it can't craft arbitrary Redis keysconst path = z .string() .min(1) .max(512) .regex(/^\/[a-zA-Z0-9/_-]*$/);
// rating-only (thumbs) and message-only (feedback) are separate actionsconst schema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("rating"), path, rating: z.enum(["up", "down"]), }), z.object({ kind: z.literal("message"), path, message: z.string().trim().min(1).max(2000), }),]);
type Rating = "up" | "down";
const countsKey = (path: string) => `docs-feedback:counts:${path}`;const voterKey = (path: string, id: string) => `docs-feedback:voter:${path}:${id}`;// bound voter-key storage; after expiry a re-vote may double count (acceptable)const VOTER_TTL = 60 * 60 * 24 * 180; // 180 days, seconds
// Slack mrkdwn treats & < > as control chars; escape user text before interpolationconst escapeSlack = (text: string) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
export async function POST(request: Request) { let data: z.infer<typeof schema>; try { data = schema.parse(await request.json()); } catch { return Response.json({ error: "Invalid request" }, { status: 400 }); }
const clientIP = getClientIP(request.headers); if (!clientIP) { return Response.json( { error: "Unable to determine client IP" }, { status: 400 }, ); } const voterId = await hashIP(clientIP);
const limit = await ratelimit(`docs-feedback:${voterId}`, { window: RATE_LIMIT_WINDOW, limit: MAX_REQUESTS_PER_WINDOW, }); if (!limit.success) { return Response.json( { error: "Too many requests" }, { status: 429, headers: { "Retry-After": Math.ceil( (limit.reset - Date.now()) / 1000, ).toString(), }, }, ); }
// dev: skip external writes (Redis tally + Slack) so local runs don't pollute prod if (process.env.NODE_ENV === "development") { console.log("docs feedback", data); return Response.json({ success: true }); }
// historical tally of up/down votes per path; best-effort, never blocks Slack if (data.kind === "rating") { try { // one vote per (path, voter); SET ... GET atomically claims the new vote and // returns the prior one in a single round-trip, so concurrent requests can't // both read a stale value and double-count. The delta derives from that prior // value server-side, so a tampered count needs both a real prior vote and the IP. const previous = (await redis.set( voterKey(data.path, voterId), data.rating, { ex: VOTER_TTL, get: true }, )) as Rating | null; if (previous !== data.rating) { await redis.hincrby(countsKey(data.path), data.rating, 1); if (previous) { await redis.hincrby(countsKey(data.path), previous, -1); } } } catch (err) { console.error("Docs feedback: failed to record vote", err); } }
const webhook = process.env.SLACK_FEEDBACK_WEBHOOK_URL; if (!webhook) { console.error("Docs feedback: SLACK_FEEDBACK_WEBHOOK_URL not configured."); return Response.json({ success: true }); }
const lines = [ data.kind === "rating" ? `*Docs feedback:* ${data.rating === "up" ? "๐ helpful" : "๐ not helpful"}` : "*Docs feedback:* ๐ฌ comment", `*Path:* ${escapeSlack(data.path)}`, ]; if (data.kind === "message") { lines.push( "--------------------------------", `*Message:* ${escapeSlack(data.message)}`, ); }
try { const response = await fetch(webhook, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: lines.join("\n") }), }); if (!response.ok) { console.error( `Docs feedback: Slack webhook responded ${response.status}`, ); } } catch (err) { console.error("Docs feedback: failed to post to Slack", err); }
return Response.json({ success: true });}