diff --git a/.env.example b/.env.example deleted file mode 100644 index ce8afde8..00000000 --- a/.env.example +++ /dev/null @@ -1,10 +0,0 @@ -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 782334b8..a547bf36 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,3 @@ 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 deleted file mode 100644 index 6d67bdf0..00000000 --- a/api/wakatime/_shared.ts +++ /dev/null @@ -1,291 +0,0 @@ -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 deleted file mode 100644 index 5c0647bb..00000000 --- a/api/wakatime/oauth/callback.ts +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 6d33ef3c..00000000 --- a/api/wakatime/oauth/start.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index d85dcb14..00000000 --- a/api/wakatime/stats.ts +++ /dev/null @@ -1,133 +0,0 @@ -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 e7a5132a..76719d84 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "type": "module", "scripts": { "dev": "vite", - "dev:vercel": "bunx vercel dev --listen 4321", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview"