From 416bebe8e357fd8458d6e06cef34a225a7a4201f Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Fri, 6 Feb 2026 11:28:36 +0000 Subject: [PATCH] wakatime serverless setup --- .env.example | 10 ++ .gitignore | 2 + api/wakatime/_shared.ts | 291 +++++++++++++++++++++++++++++++++ api/wakatime/oauth/callback.ts | 97 +++++++++++ api/wakatime/oauth/start.ts | 64 ++++++++ api/wakatime/stats.ts | 133 +++++++++++++++ package.json | 1 + 7 files changed, 598 insertions(+) create mode 100644 .env.example create mode 100644 api/wakatime/_shared.ts create mode 100644 api/wakatime/oauth/callback.ts create mode 100644 api/wakatime/oauth/start.ts create mode 100644 api/wakatime/stats.ts diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..ce8afde8 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +WAKATIME_CLIENT_ID=wakatime_oauth_client_id_here +WAKATIME_CLIENT_SECRET=wakatime_oauth_client_secret_here +WAKATIME_REFRESH_TOKEN=wakatime_oauth_refresh_token_here +WAKATIME_IDLE_MINUTES=10 + +WAKATIME_SETUP_ENABLED=0 +WAKATIME_SETUP_SECRET=change_me +WAKATIME_OAUTH_REDIRECT_URI= + +VITE_WAKATIME_REFRESH_MS=60000 diff --git a/.gitignore b/.gitignore index a547bf36..782334b8 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? +.vercel +.env \ No newline at end of file diff --git a/api/wakatime/_shared.ts b/api/wakatime/_shared.ts new file mode 100644 index 00000000..6d67bdf0 --- /dev/null +++ b/api/wakatime/_shared.ts @@ -0,0 +1,291 @@ +import { createHash, randomUUID } from "node:crypto"; + +const WAKATIME_TOKEN_URL = "https://wakatime.com/oauth/token"; + +type JsonRecord = Record; + +const minuteMs = 60_000; +const hourMs = 60 * minuteMs; + +const rateLimitStore = new Map(); + +export function json( + res: { status: (code: number) => { json: (payload: unknown) => void } }, + statusCode: number, + payload: unknown, +) { + res.status(statusCode).json(payload); +} + +export function getRequiredEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing environment variable: ${name}`); + return value; +} + +export function getOptionalIntEnv(name: string, fallback: number): number { + const value = process.env[name]; + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) return fallback; + return parsed; +} + +export function getBooleanEnv(name: string, fallback: boolean): boolean { + const value = process.env[name]; + if (!value) return fallback; + return value === "1" || value.toLowerCase() === "true"; +} + +export function isSetupEnabled(): boolean { + return getBooleanEnv("WAKATIME_SETUP_ENABLED", false); +} + +export function assertSetupSecret(provided: string | undefined): boolean { + const expected = process.env.WAKATIME_SETUP_SECRET; + if (!expected || !provided) return false; + return provided === expected; +} + +export function buildRedirectUri(req: { + headers: Record; +}): string { + const configured = process.env.WAKATIME_OAUTH_REDIRECT_URI; + if (configured) return configured; + + const hostHeader = req.headers["x-forwarded-host"] ?? req.headers.host; + const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader; + if (!host) throw new Error("Cannot determine host for OAuth redirect URI"); + + const protocolHeader = req.headers["x-forwarded-proto"]; + const protocol = Array.isArray(protocolHeader) + ? protocolHeader[0] + : (protocolHeader ?? "https"); + + return `${protocol}://${host}/api/wakatime/oauth/callback`; +} + +export function makeState(secret: string): string { + const payload = { + ts: Date.now(), + nonce: randomUUID(), + }; + const encodedPayload = toBase64Url(JSON.stringify(payload)); + const signature = sha256(`${encodedPayload}.${secret}`); + return `${encodedPayload}.${signature}`; +} + +export function verifyState( + state: string, + secret: string, + maxAgeMs = 15 * minuteMs, +): boolean { + const parts = state.split("."); + if (parts.length !== 2) return false; + + const [encodedPayload, signature] = parts; + const expectedSignature = sha256(`${encodedPayload}.${secret}`); + if (expectedSignature !== signature) return false; + + let payload: { ts?: unknown }; + try { + payload = JSON.parse(fromBase64Url(encodedPayload)) as { ts?: unknown }; + } catch { + return false; + } + + if (typeof payload.ts !== "number") return false; + const age = Date.now() - payload.ts; + if (age < 0 || age > maxAgeMs) return false; + + return true; +} + +export async function exchangeCodeForTokens(input: { + code: string; + clientId: string; + clientSecret: string; + redirectUri: string; +}): Promise<{ + accessToken?: string; + refreshToken?: string; + raw: JsonRecord | string; +}> { + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: input.clientId, + client_secret: input.clientSecret, + code: input.code, + redirect_uri: input.redirectUri, + }); + + const response = await fetch(WAKATIME_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json, application/x-www-form-urlencoded", + }, + body, + }); + + const parsed = await parseFlexibleResponse(response); + if (!response.ok) { + throw new Error(`Token exchange failed (${response.status})`); + } + + const accessToken = extractString(parsed, ["access_token", "token"]); + const refreshToken = extractString(parsed, ["refresh_token"]); + + return { + accessToken, + refreshToken, + raw: parsed, + }; +} + +export async function refreshAccessToken(input: { + refreshToken: string; + clientId: string; + clientSecret: string; +}): Promise { + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: input.refreshToken, + client_id: input.clientId, + client_secret: input.clientSecret, + }); + + const response = await fetch(WAKATIME_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json, application/x-www-form-urlencoded", + }, + body, + }); + + const parsed = await parseFlexibleResponse(response); + if (!response.ok) { + throw new Error(`Token refresh failed (${response.status})`); + } + + const accessToken = extractString(parsed, ["access_token", "token"]); + if (!accessToken) + throw new Error("Missing access_token in token refresh response"); + + return accessToken; +} + +export async function wakatimeGet( + path: string, + accessToken: string, +): Promise { + const response = await fetch(`https://api.wakatime.com/api/v1${path}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`WakaTime request failed (${response.status}) on ${path}`); + } + + return (await response.json()) as T; +} + +export function inferCodingStatus( + lastHeartbeatAt: string | undefined, + idleMinutes: number, +): boolean { + if (!lastHeartbeatAt) return false; + const heartbeatMs = Date.parse(lastHeartbeatAt); + if (Number.isNaN(heartbeatMs)) return false; + return Date.now() - heartbeatMs <= idleMinutes * minuteMs; +} + +export function getClientIp(req: { + headers: Record; +}): string { + const forwardedFor = req.headers["x-forwarded-for"]; + const value = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; + if (!value) return "unknown"; + return value.split(",")[0]?.trim() ?? "unknown"; +} + +export function isRateLimited( + key: string, + limit: number, + windowMs = hourMs, +): boolean { + const now = Date.now(); + const item = rateLimitStore.get(key); + if (!item || now > item.resetAt) { + rateLimitStore.set(key, { count: 1, resetAt: now + windowMs }); + return false; + } + + item.count += 1; + if (item.count > limit) return true; + return false; +} + +export function setStatsCacheHeaders(res: { + setHeader: (name: string, value: string) => void; +}) { + res.setHeader( + "Cache-Control", + "public, s-maxage=60, stale-while-revalidate=300", + ); +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function toBase64Url(value: string): string { + return Buffer.from(value, "utf8").toString("base64url"); +} + +function fromBase64Url(value: string): string { + return Buffer.from(value, "base64url").toString("utf8"); +} + +function extractString( + source: JsonRecord | string, + keys: string[], +): string | undefined { + if (typeof source === "string") { + const params = new URLSearchParams(source); + for (const key of keys) { + const value = params.get(key); + if (value) return value; + } + return undefined; + } + + for (const key of keys) { + const value = source[key]; + if (typeof value === "string") return value; + } + + return undefined; +} + +async function parseFlexibleResponse( + response: Response, +): Promise { + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return (await response.json()) as JsonRecord; + } + + const text = await response.text(); + if (!text.trim()) return ""; + + try { + return JSON.parse(text) as JsonRecord; + } catch { + return text; + } +} diff --git a/api/wakatime/oauth/callback.ts b/api/wakatime/oauth/callback.ts new file mode 100644 index 00000000..5c0647bb --- /dev/null +++ b/api/wakatime/oauth/callback.ts @@ -0,0 +1,97 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + buildRedirectUri, + exchangeCodeForTokens, + getClientIp, + getRequiredEnv, + isRateLimited, + isSetupEnabled, + json, + verifyState, +} from "../_shared.ts"; + +export default async function handler( + req: IncomingMessage, + res: ServerResponse & { + status: (code: number) => { json: (payload: unknown) => void }; + }, +) { + if (req.method !== "GET") { + json(res, 405, { error: "Method not allowed" }); + return; + } + + if (!isSetupEnabled()) { + json(res, 403, { error: "Setup route disabled" }); + return; + } + + const ip = getClientIp(req); + if (isRateLimited(`oauth-callback:${ip}`, 50)) { + json(res, 429, { error: "Too many requests" }); + return; + } + + const requestUrl = new URL(req.url ?? "", "https://localhost"); + const error = requestUrl.searchParams.get("error"); + if (error) { + json(res, 400, { + error: "OAuth authorization failed", + oauth_error: error, + oauth_error_description: requestUrl.searchParams.get("error_description"), + }); + return; + } + + const code = requestUrl.searchParams.get("code") ?? undefined; + const state = requestUrl.searchParams.get("state") ?? undefined; + if (!code || !state) { + json(res, 400, { error: "Missing OAuth code or state" }); + return; + } + + try { + const clientId = getRequiredEnv("WAKATIME_CLIENT_ID"); + const clientSecret = getRequiredEnv("WAKATIME_CLIENT_SECRET"); + const setupSecret = getRequiredEnv("WAKATIME_SETUP_SECRET"); + const redirectUri = buildRedirectUri(req); + + if (!verifyState(state, setupSecret)) { + json(res, 401, { error: "Invalid or expired OAuth state" }); + return; + } + + const tokenResult = await exchangeCodeForTokens({ + code, + clientId, + clientSecret, + redirectUri, + }); + + if (!tokenResult.refreshToken) { + json(res, 502, { + error: "Token exchange succeeded but no refresh token was returned", + }); + return; + } + + json(res, 200, { + ok: true, + message: "OAuth setup complete", + refresh_token: tokenResult.refreshToken, + next_steps: [ + "Store WAKATIME_REFRESH_TOKEN in your Vercel project env.", + "Set WAKATIME_SETUP_ENABLED=0 after setup.", + ], + vercel_env_command: `vercel env add WAKATIME_REFRESH_TOKEN`, + }); + } catch (exchangeError) { + json(res, 500, { + error: "OAuth callback failed", + detail: + exchangeError instanceof Error + ? exchangeError.message + : "unknown_error", + }); + } +} diff --git a/api/wakatime/oauth/start.ts b/api/wakatime/oauth/start.ts new file mode 100644 index 00000000..6d33ef3c --- /dev/null +++ b/api/wakatime/oauth/start.ts @@ -0,0 +1,64 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + assertSetupSecret, + buildRedirectUri, + getClientIp, + getRequiredEnv, + isRateLimited, + isSetupEnabled, + json, + makeState, +} from "../_shared.ts"; + +export default async function handler( + req: IncomingMessage, + res: ServerResponse & { + status: (code: number) => { json: (payload: unknown) => void }; + }, +) { + if (req.method !== "GET") { + json(res, 405, { error: "Method not allowed" }); + return; + } + + if (!isSetupEnabled()) { + json(res, 403, { error: "Setup route disabled" }); + return; + } + + const ip = getClientIp(req); + if (isRateLimited(`oauth-start:${ip}`, 20)) { + json(res, 429, { error: "Too many requests" }); + return; + } + + const url = new URL(req.url ?? "", "https://localhost"); + const setupSecret = url.searchParams.get("setup_secret") ?? undefined; + if (!assertSetupSecret(setupSecret)) { + json(res, 401, { error: "Unauthorized setup request" }); + return; + } + + try { + const clientId = getRequiredEnv("WAKATIME_CLIENT_ID"); + const setupSecretValue = getRequiredEnv("WAKATIME_SETUP_SECRET"); + const redirectUri = buildRedirectUri(req); + const state = makeState(setupSecretValue); + + const authorizeUrl = new URL("https://wakatime.com/oauth/authorize"); + authorizeUrl.searchParams.set("client_id", clientId); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("redirect_uri", redirectUri); + authorizeUrl.searchParams.set("scope", "read_stats email"); + authorizeUrl.searchParams.set("state", state); + + res.statusCode = 302; + res.setHeader("Location", authorizeUrl.toString()); + res.end(); + } catch (error) { + json(res, 500, { + error: "Failed to build OAuth authorize URL", + detail: error instanceof Error ? error.message : "unknown_error", + }); + } +} diff --git a/api/wakatime/stats.ts b/api/wakatime/stats.ts new file mode 100644 index 00000000..d85dcb14 --- /dev/null +++ b/api/wakatime/stats.ts @@ -0,0 +1,133 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + getClientIp, + getOptionalIntEnv, + getRequiredEnv, + inferCodingStatus, + isRateLimited, + json, + refreshAccessToken, + setStatsCacheHeaders, + wakatimeGet, +} from "./_shared.ts"; + +type WakaTimeUsersResponse = { + data?: { + last_heartbeat_at?: string; + }; +}; + +type WakaTimeStatsResponse = { + data?: { + total_seconds?: number; + human_readable_total?: string; + text?: string; + languages?: Array<{ + name?: string; + percent?: number; + total_seconds?: number; + text?: string; + }>; + }; +}; + +type StatsPayload = { + isCoding: boolean; + last7Text: string; + last7Seconds: number; + languages: Array<{ + name: string; + percent: number; + seconds: number; + text: string; + }>; + updatedAt: string; +}; + +export default async function handler( + req: IncomingMessage, + res: ServerResponse & { + status: (code: number) => { json: (payload: unknown) => void }; + setHeader: (name: string, value: string) => void; + }, +) { + if (req.method !== "GET") { + json(res, 405, { error: "Method not allowed" }); + return; + } + + const ip = getClientIp(req); + if (isRateLimited(`stats:${ip}`, 240)) { + json(res, 429, { error: "Too many requests" }); + return; + } + + setStatsCacheHeaders(res); + + try { + const clientId = getRequiredEnv("WAKATIME_CLIENT_ID"); + const clientSecret = getRequiredEnv("WAKATIME_CLIENT_SECRET"); + const refreshToken = getRequiredEnv("WAKATIME_REFRESH_TOKEN"); + const idleMinutes = getOptionalIntEnv("WAKATIME_IDLE_MINUTES", 10); + + const accessToken = await refreshAccessToken({ + clientId, + clientSecret, + refreshToken, + }); + + const [userResponse, statsResponse] = await Promise.all([ + wakatimeGet("/users/current", accessToken), + wakatimeGet( + "/users/current/stats/last_7_days", + accessToken, + ), + ]); + + const lastHeartbeatAt = userResponse.data?.last_heartbeat_at; + const totalSeconds = clampNumber(statsResponse.data?.total_seconds); + const totalText = ( + statsResponse.data?.human_readable_total ?? + statsResponse.data?.text ?? + "0 secs" + ).trim(); + + const languages = (statsResponse.data?.languages ?? []) + .filter((language) => typeof language?.name === "string") + .map((language) => ({ + name: language.name ?? "Unknown", + percent: clampNumber(language.percent), + seconds: clampNumber(language.total_seconds), + text: ( + language.text ?? `${Math.round(clampNumber(language.total_seconds))}s` + ).trim(), + })) + .sort((a, b) => b.seconds - a.seconds); + + const payload: StatsPayload = { + isCoding: inferCodingStatus(lastHeartbeatAt, idleMinutes), + last7Text: totalText, + last7Seconds: totalSeconds, + languages, + updatedAt: new Date().toISOString(), + }; + + json(res, 200, payload); + } catch (error) { + json(res, 500, { + error: "Failed to load WakaTime stats", + detail: error instanceof Error ? error.message : "unknown_error", + }); + } +} + +function clampNumber(value: unknown): number { + if ( + typeof value !== "number" || + Number.isNaN(value) || + !Number.isFinite(value) + ) { + return 0; + } + return Math.max(0, value); +} diff --git a/package.json b/package.json index 76719d84..e7a5132a 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:vercel": "bunx vercel dev --listen 4321", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview"