mirror of
https://github.com/hex248/ob248.com.git
synced 2026-02-07 18:23:04 +00:00
10
.env.example
10
.env.example
@@ -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
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,5 +22,3 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
.vercel
|
|
||||||
.env
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
import { createHash, randomUUID } from "node:crypto";
|
|
||||||
|
|
||||||
const WAKATIME_TOKEN_URL = "https://wakatime.com/oauth/token";
|
|
||||||
|
|
||||||
type JsonRecord = Record<string, unknown>;
|
|
||||||
|
|
||||||
const minuteMs = 60_000;
|
|
||||||
const hourMs = 60 * minuteMs;
|
|
||||||
|
|
||||||
const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
|
|
||||||
|
|
||||||
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, string | string[] | undefined>;
|
|
||||||
}): 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<string> {
|
|
||||||
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<T>(
|
|
||||||
path: string,
|
|
||||||
accessToken: string,
|
|
||||||
): Promise<T> {
|
|
||||||
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, string | string[] | undefined>;
|
|
||||||
}): 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<JsonRecord | string> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<WakaTimeUsersResponse>("/users/current", accessToken),
|
|
||||||
wakatimeGet<WakaTimeStatsResponse>(
|
|
||||||
"/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);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"dev:vercel": "bunx vercel dev --listen 4321",
|
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
|
|||||||
Reference in New Issue
Block a user