From f33a35392a0119d2d4239aa5fd322737e4c4330c Mon Sep 17 00:00:00 2001 From: Shubham Panth Date: Mon, 21 Oct 2024 20:44:07 +0200 Subject: [PATCH] Fix on Game Publishing: Optional Thumbnails and Cookie Policy Update (#2416) * Update session cookie SameSite policy to Lax * Make Game Thumbnail Optional & Rename PR Title * Remove Unused Const * un-restrict game name * Sanitize for filenames * Check For FullyLoggedIn * Add Metrics Over Github Publish Flow * Recording Metric Correctly * Adding Fetch Before Creating Branch For Existing Repo * Additional Required Parameter For HTTPS * Make new session if it doesn't exist * Rewriting Metric Logic To Run Correctly * Get AccessToken From Cookies grab some chocolates from :cookie: * Removing ForkUpstream - Unstable * Enable GameTitle & AutorName + Error Check * Add Error Message * Update env.example with new environment variables * fixes: store all github stuff locally, allow for multiple PRs, make all branches off of hackclub/main, use centralized metrics object on backend, --------- Co-authored-by: Cheru Berhanu --- .env.example | 11 + src/components/navbar-editor.tsx | 353 +++++++++++++++++--------- src/components/navbar.module.css | 4 + src/lib/game-saving/account.ts | 10 +- src/lib/state.ts | 5 + src/pages/api/auth/github/callback.ts | 27 +- src/pages/api/games/metrics.ts | 21 ++ 7 files changed, 290 insertions(+), 141 deletions(-) create mode 100644 src/pages/api/games/metrics.ts diff --git a/.env.example b/.env.example index a74ba76bd8..f52383e452 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,14 @@ STUCK_AIRTABLE_BASE= SENDGRID_API_KEY= LOOPS_API_KEY= DEV_CODE= + +PUBLIC_SPRIG_LLM_API= +EMAIL_FROM= +EMAIL_REPLY_TO= +PUBLIC_SIGNALING_SERVER_HOST= +GITHUB_CLIENT_SECRET= +PUBLIC_GITHUB_CLIENT_ID= +PUBLIC_GITHUB_REDIRECT_URI= +PUBLIC_GALLERY_API= +MAX_ATTEMPTS= +LOCKOUT_DURATION_MS= diff --git a/src/components/navbar-editor.tsx b/src/components/navbar-editor.tsx index dd8fa05d81..8113254c0e 100644 --- a/src/components/navbar-editor.tsx +++ b/src/components/navbar-editor.tsx @@ -8,7 +8,7 @@ import { theme, switchTheme, isNewSaveStrat, - screenRef, + screenRef, GithubState, } from "../lib/state"; import type { RoomState, ThemeType } from "../lib/state"; import Button from "./design-system/button"; @@ -84,6 +84,18 @@ const canDelete = (persistenceState: Signal) => { ); }; +async function reportMetric(metricName: string, value = 1, type = 'increment') { + try { + await fetch('/api/games/metrics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ metric: metricName, value, type }) + }); + } catch (error) { + console.error('Failed to send metric:', error); + } +} + interface EditorNavbarProps { persistenceState: Signal roomState: Signal | undefined @@ -105,74 +117,100 @@ type StuckData = { description: string; }; -const openGitHubAuthPopup = async (userId: string | null, publishDropdown: any, readyPublish: any, isPublish: any, publishSuccess: any) => { +const openGitHubAuthPopup = async (userId: string | null, publishDropdown: any, readyPublish: any, isPublish: any, publishSuccess: any, githubState: any) => { + const startTime = Date.now(); try { - const githubSession = document.cookie - .split('; ') - .find(row => row.startsWith('githubSession=')) - ?.split('=')[1]; + reportMetric('github_auth_popup.initiated'); if (isPublish) { publishDropdown.value = true; publishSuccess.value = true; - return + return; } - if (githubSession) { + if (githubState.value) { publishDropdown.value = true; readyPublish.value = true; return; } - const clientId = import.meta.env.PUBLIC_GITHUB_CLIENT_ID; - const redirectUri = import.meta.env.PUBLIC_GITHUB_REDIRECT_URI; - const scope = 'repo'; - const state = encodeURIComponent(JSON.stringify({ userId })); + const githubAuthUrl = constructGithubAuthUrl(userId); - const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${state}`; - - const width = 600, height = 700; + const width = 600, + height = 700; const left = (screen.width - width) / 2; const top = (screen.height - height) / 2; const authWindow = window.open( githubAuthUrl, - 'GitHub Authorization', + "GitHub Authorization", `width=${width},height=${height},top=${top},left=${left}` ); - if (!authWindow || authWindow.closed || typeof authWindow.closed === 'undefined') { - alert('Popup blocked. Please allow popups for this site.'); - return; - } - - authWindow?.focus(); + if (!authWindow || authWindow.closed || typeof authWindow.closed === "undefined") { + alert("Popup blocked. Please allow popups for this site."); + return; + } - window.addEventListener('message', (event) => { + authWindow.focus(); - if (event.origin !== window.location.origin) { - return; + const authCheckInterval = setInterval(() => { + if (authWindow.closed) { + clearInterval(authCheckInterval); + alert("Authentication window was closed unexpectedly."); + reportMetric("github_auth_popup.closed_unexpectedly"); } + }, 1000); + + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; - const { status, message, accessToken } = event.data; + const { status, message, accessToken, githubUsername } = event.data; - if (status === 'success') { - const expires = new Date(Date.now() + 7 * 864e5).toUTCString(); - document.cookie = `githubSession=${encodeURIComponent(accessToken)}; expires=${expires}; path=/; SameSite=None; Secure`; + const timeTaken = Date.now() - startTime; + + if (status === "success") { + const expires = new Date(Date.now() + 7 * 864e5).toUTCString(); // 7 days + document.cookie = `githubSession=${encodeURIComponent(accessToken)}; expires=${expires}; path=/; SameSite=None; Secure`; + document.cookie = `githubUsername=${encodeURIComponent(githubUsername)}; expires=${expires}; path=/; SameSite=None; Secure`; + githubState.value = { + username: githubUsername, + session: accessToken + } publishDropdown.value = true; readyPublish.value = true; + reportMetric("github_auth_popup.success"); + reportMetric('github_auth_popup.time_taken', timeTaken, 'timing'); + + clearInterval(authCheckInterval); + window.removeEventListener("message", handleMessage); + } else if (status === "error") { + console.error("Error during GitHub authorization:", message); + alert("An error occurred: " + message); + reportMetric("github_auth_popup.failure"); + reportMetric('github_auth_popup.failure_time', timeTaken, 'timing'); } - else if (status === 'error') { - console.error('Error during GitHub authorization:', message); - alert('An error occurred: ' + message); - } - }); + }; + + window.addEventListener("message", handleMessage); } catch (error) { - console.error('Error during GitHub authorization:', error); - alert('An error occurred: ' + (error as Error).message); + console.error("Error during GitHub authorization:", error); + alert("An error occurred: " + (error instanceof Error ? error.message : String(error))); + const timeTaken = Date.now() - startTime; + reportMetric("github_auth_popup.failure"); + reportMetric('github_auth_popup.failure_time', timeTaken, 'timing'); } }; +const constructGithubAuthUrl = (userId: string | null): string => { + const clientId = import.meta.env.PUBLIC_GITHUB_CLIENT_ID; + const redirectUri = import.meta.env.PUBLIC_GITHUB_REDIRECT_URI; + const scope = "repo"; + const state = encodeURIComponent(JSON.stringify({ userId })); + + return `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${state}`; +}; + const prettifyCode = () => { // Check if the codeMirror is ready if (!codeMirror.value) return; @@ -212,6 +250,23 @@ const prettifyCode = () => { ); }; + +const getGithubStateFromCookie = () => { + const username = document.cookie + .split('; ') + .find((row) => row.startsWith('githubUsername=')) + ?.split('=')[1]; + const session = document.cookie + .split('; ') + .find((row) => row.startsWith('githubSession=')) + ?.split('=')[1]; + if (!username || !session) return + return { + username: username as string, + session: session as string + } +} + export default function EditorNavbar(props: EditorNavbarProps) { const showNavPopup = useSignal(false); const showStuckPopup = useSignal(false); @@ -224,18 +279,14 @@ export default function EditorNavbar(props: EditorNavbarProps) { const publishSuccess = useSignal(false); const publishError = useSignal(false); const githubPRUrl = useSignal(null); - + + const githubState = useSignal(undefined) + let hasError = false; - const githubUsername = useSignal(null); - + useSignalEffect(() => { - const session = props.persistenceState.value.session; - if (session && session.user && session.user.githubUsername) { - githubUsername.value = session.user.githubUsername; - } else { - githubUsername.value = "user"; - } - }); + githubState.value = getGithubStateFromCookie() + }) useSignalEffect(() => { const persistenceState = props.persistenceState.value; @@ -264,7 +315,7 @@ export default function EditorNavbar(props: EditorNavbarProps) { // keep track of the submit status for "I'm stuck" requests const isSubmitting = useSignal(false); - const isLoggedIn = props.persistenceState.value.session ? true : false; + const isLoggedIn = props.persistenceState.value.session?.session.full ?? false; const showSavePrompt = useSignal(false); const showSharePopup = useSignal(false); @@ -376,7 +427,6 @@ export default function EditorNavbar(props: EditorNavbarProps) { }; async function validateGameName(gameName: string): Promise<{ valid: boolean; message: string }> { - let existingGames: any[] = []; try { const response = await fetch(import.meta.env.PUBLIC_GALLERY_API); @@ -390,11 +440,6 @@ export default function EditorNavbar(props: EditorNavbarProps) { return { valid: false, message: "Failed to fetch gallery games. Please try again later." }; } - const validNamePattern = /^[a-zA-Z0-9_-]+$/; - if (!validNamePattern.test(gameName)) { - return { valid: false, message: "The game name can only contain alphanumeric characters, dashes, or underscores." }; - } - const lowerCaseGameName = gameName.toLowerCase(); const isUnique = !existingGames.some(game => game.lowerCaseTitle === lowerCaseGameName); if (!isUnique) { @@ -403,10 +448,17 @@ export default function EditorNavbar(props: EditorNavbarProps) { return { valid: true, message: "The game name is valid and unique." }; } + + - const publishToGithub = async (accessToken: string | null | undefined, yourGithubUsername: string | undefined, gameID: string | undefined) => { + const publishToGithub = async (githubState: Signal, gameID: string | undefined) => { + const startTime = Date.now(); try { + reportMetric("github_publish.initiated"); + + githubState.value = githubState?.value ?? getGithubStateFromCookie() + const gameTitleElement = document.getElementById('gameTitle') as HTMLInputElement | null; const authorNameElement = document.getElementById('authorName') as HTMLInputElement | null; const gameDescriptionElement = document.getElementById('gameDescription') as HTMLTextAreaElement | null; @@ -418,34 +470,42 @@ export default function EditorNavbar(props: EditorNavbarProps) { } const gameTitle = gameTitleElement.value.trim(); - const authorName = authorNameElement.value; - const gameDescription = gameDescriptionElement.value; + const authorName = authorNameElement.value.trim(); + const gameDescription = gameDescriptionElement.value.trim(); const gameCode = codeMirror.value?.state.doc.toString() ?? ""; - const image = thumbnailPreview.value; const gameControlsDescription = gameControlsDescriptionElement.value; clearError("gameDescription"); clearError("thumbnail"); clearError("gameControlsDescription"); clearError("gameTitle"); - + clearError("authorName") hasError = false; + if (!gameTitle) { + displayError("gameTitle", "Please enter a game title."); + hasError = true; + } + + if (!authorName) { + displayError("authorName", "Please enter an author name."); + hasError = true; + } const { valid, message: gameNameMessage } = await validateGameName(gameTitle); handleError("gameTitle", !valid, gameNameMessage); handleError("gameDescription", !gameDescription, "Please provide a game description."); - handleError("thumbnail", !image, "Please upload a thumbnail image."); handleError("gameControlsDescription", !gameControlsDescription, "Please provide game controls description."); if (hasError) { return; } - - if (!accessToken) { + + if (!githubState.value?.session) { + reportMetric("github_publish.failure.token_missing"); throw new Error("GitHub access token not found."); } - let isValidToken = await validateGitHubToken(accessToken); + let isValidToken = await validateGitHubToken(githubState.value.session); if (!isValidToken) { console.warn("Token invalid or expired. Attempting re-authentication..."); if ( @@ -455,16 +515,20 @@ export default function EditorNavbar(props: EditorNavbarProps) { ) { if (typeof props.persistenceState.value.game !== 'string') { await openGitHubAuthPopup( - props.persistenceState.value.session?.user?.id ?? null, + props.persistenceState.value.session?.user.id ?? null, publishDropdown, readyPublish, props.persistenceState.value.game.isPublished, - publishSuccess + publishSuccess, + githubState ); } } - accessToken = sessionStorage.getItem("githubAccessToken"); - if (!accessToken || !(await validateGitHubToken(accessToken))) { + + githubState.value = getGithubStateFromCookie() + + if (!githubState.value?.session || !(await validateGitHubToken(githubState.value.session))) { + reportMetric("github_publish.failure.token_reauth_failed"); throw new Error("Failed to re-authenticate with GitHub."); } } @@ -473,60 +537,114 @@ export default function EditorNavbar(props: EditorNavbarProps) { readyPublish.value = false; publishError.value = false; publishSuccess.value = false; + + const accessToken = githubState.value.session + const yourGithubUsername = githubState.value.username let forkedRepo; try { forkedRepo = await forkRepository(accessToken, "hackclub", "sprig"); - } catch { + } catch (error) { + reportMetric("github_publish.failure.fork"); console.warn("Fork might already exist. Fetching existing fork..."); - forkedRepo = await fetchForkedRepository(accessToken, "hackclub", "sprig", yourGithubUsername || ""); + try { + forkedRepo = await fetchForkedRepository(accessToken, "hackclub", "sprig", yourGithubUsername || ""); + } catch (fetchError: any) { + reportMetric("github_publish.failure.fetch_fork"); + throw new Error("Failed to fetch fork: " + fetchError.message); + } } - const latestCommitSha = await fetchLatestCommitSha(accessToken, forkedRepo.owner.login, forkedRepo.name, forkedRepo.default_branch); + const latestCommitSha = await fetchLatestCommitSha(accessToken, "hackclub", "sprig", forkedRepo.default_branch); if (!latestCommitSha) { + reportMetric("github_publish.failure.commit_sha"); throw new Error("Failed to fetch the latest commit SHA."); } const newBranchName = `Automated-PR-${Date.now()}`; - - await createBranch(accessToken, forkedRepo.owner.login, forkedRepo.name, newBranchName, latestCommitSha); + try { + await createBranch(accessToken, forkedRepo.owner.login, forkedRepo.name, newBranchName, latestCommitSha); + } catch (error) { + reportMetric("github_publish.failure.branch"); + throw new Error("Failed to create branch: " + (error instanceof Error ? error.message : String(error))); + } const imageBase64 = thumbnailPreview.value || null; - const imageBlobSha = imageBase64 ? await createBlobForImage(accessToken, forkedRepo.owner.login, forkedRepo.name, imageBase64.split(',')[1]) : null; - - const treeSha = await createTreeAndCommit( - accessToken, - forkedRepo.owner.login, - forkedRepo.name, - latestCommitSha, - [ - { path: `games/${gameTitle}.js`, content: gameCode }, - ...(imageBlobSha ? [{ path: `games/img/${gameTitle}.png`, sha: imageBlobSha }] : []) - ] - ); - - const newCommit = await createCommit(accessToken, forkedRepo.owner.login, forkedRepo.name, `Automated Commit - ${gameTitle}`, treeSha, latestCommitSha); - - await updateBranch(accessToken, forkedRepo.owner.login, forkedRepo.name, newBranchName, newCommit.sha); - - const pr = await createPullRequest( - accessToken, - "hackclub", - "sprig", - `[Automated] ${gameTitle}`, - newBranchName, - "main", - `### Author name\nAuthor: ${authorName}\n\n### About your game\n\n**What is your game about?**\n${gameDescription}\n\n**How do you play your game?**\n${gameControlsDescription}`, - forkedRepo.owner.login, - gameID ?? '' - ); - - githubPRUrl.value = pr.html_url; + let imageBlobSha = null; + try { + if (imageBase64) { + imageBlobSha = await createBlobForImage(accessToken, forkedRepo.owner.login, forkedRepo.name, imageBase64.split(',')[1]); + } + } catch (error) { + reportMetric("github_publish.failure.image_blob"); + throw new Error("Failed to create image blob: " + (error instanceof Error ? error.message : String(error))); + } - publishSuccess.value = true; + const sanitizedGameTitle = gameTitle.replace(/\s+/g, '-'); + + let treeSha; + try { + treeSha = await createTreeAndCommit( + accessToken, + forkedRepo.owner.login, + forkedRepo.name, + latestCommitSha, + [ + { path: `games/${sanitizedGameTitle}.js`, content: gameCode }, + ...(imageBlobSha ? [{ path: `games/img/${sanitizedGameTitle}.png`, sha: imageBlobSha }] : []) + ] + ); + } catch (error) { + reportMetric("github_publish.failure.tree_commit"); + throw new Error("Failed to create tree and commit: " + (error instanceof Error ? error.message : String(error))); + } + + let newCommit; + try { + newCommit = await createCommit(accessToken, forkedRepo.owner.login, forkedRepo.name, `Sprig App - ${gameTitle}`, treeSha, latestCommitSha); + } catch (error) { + reportMetric("github_publish.failure.commit"); + throw new Error("Failed to create commit: " + (error instanceof Error ? error.message : String(error))); + } + + try { + await updateBranch(accessToken, forkedRepo.owner.login, forkedRepo.name, newBranchName, newCommit.sha); + } catch (error) { + reportMetric("github_publish.failure.branch_update"); + throw new Error("Failed to update branch: " + (error instanceof Error ? error.message : String(error))); + } + + try { + const pr = await createPullRequest( + accessToken, + "hackclub", + "sprig", + `[Sprig App] ${gameTitle}`, + newBranchName, + "main", + `### Author name\nAuthor: ${authorName}\n\n### About your game\n\n**What is your game about?**\n${gameDescription}\n\n**How do you play your game?**\n${gameControlsDescription}`, + forkedRepo.owner.login, + gameID ?? '' + ); + + githubPRUrl.value = pr.html_url; + reportMetric("github_publish.success"); + + const timeTaken = Date.now() - startTime; + reportMetric('github_publish.time_taken', timeTaken, 'timing'); + + publishSuccess.value = true; + } catch (error) { + reportMetric("github_publish.failure.pr_creation"); + throw new Error("Failed to create pull request: " + (error instanceof Error ? error.message : String(error))); + } } catch (error) { console.error("Publishing failed:", error); publishError.value = true; + reportMetric("github_publish.failure.general"); + + const timeTaken = Date.now() - startTime; + reportMetric('github_publish.failure_time', timeTaken, 'timing'); } finally { isPublishing.value = false; } @@ -711,11 +829,12 @@ export default function EditorNavbar(props: EditorNavbarProps) { ) { if (typeof props.persistenceState.value.game !== 'string') { await openGitHubAuthPopup( - props.persistenceState.value.session?.user?.id ?? null, + props.persistenceState.value.session?.user.id ?? null, publishDropdown, readyPublish, props.persistenceState.value.game.isPublished, - publishSuccess + publishSuccess, + githubState ); } } else { @@ -735,7 +854,7 @@ export default function EditorNavbar(props: EditorNavbarProps) {

Connected to GitHub

- Awesome! You're now connected to GitHub as {props.persistenceState.value.session?.user?.githubUsername || githubUsername.value}. + Awesome! You're now connected to GitHub as {githubState.value?.username}.

@@ -746,10 +865,9 @@ export default function EditorNavbar(props: EditorNavbarProps) { props.persistenceState.value.game !== "LOADING" ? ( ) : ( Fetching Name... @@ -761,11 +879,11 @@ export default function EditorNavbar(props: EditorNavbarProps) { +
@@ -791,7 +909,7 @@ export default function EditorNavbar(props: EditorNavbarProps) {
- +
)} -
@@ -832,8 +949,7 @@ export default function EditorNavbar(props: EditorNavbarProps) { const gameId = game?.id || null; await publishToGithub( - props.persistenceState.value.session?.user?.githubAccessToken, - props.persistenceState.value.session?.user?.githubUsername, + githubState, gameId ?? '' ); } catch (error) { @@ -865,6 +981,15 @@ export default function EditorNavbar(props: EditorNavbarProps) { +
)} @@ -1107,7 +1232,7 @@ export default function EditorNavbar(props: EditorNavbarProps) {
  • { if (resetState.value === "idle") { diff --git a/src/components/navbar.module.css b/src/components/navbar.module.css index 0669d5f179..ec169d50d2 100644 --- a/src/components/navbar.module.css +++ b/src/components/navbar.module.css @@ -497,3 +497,7 @@ padding: 0 8px; } } + +.newPRButton { + margin-left: 10px; +} \ No newline at end of file diff --git a/src/lib/game-saving/account.ts b/src/lib/game-saving/account.ts index 7ad635d411..1ae086084b 100644 --- a/src/lib/game-saving/account.ts +++ b/src/lib/game-saving/account.ts @@ -35,9 +35,6 @@ export interface User { createdAt: Timestamp email: string username: string | null - githubAccessToken?: string - githubId?: string - githubUsername?: string failedLoginAttempts?: number lockoutUntil?: Timestamp } @@ -233,7 +230,8 @@ export const makeOrUpdateSession = async (cookies: AstroCookies, userId: string, path: '/', maxAge: 60 * 60 * 24 * 365, httpOnly: true, - sameSite: 'strict' + sameSite: 'lax', + secure: true, }) return { session: { id: _session.id, ...data } as Session, @@ -333,8 +331,4 @@ export const getSnapshotData = async (id: string): Promise ownerName: user?.username ?? snapshot.ownerName, code: snapshot.code } -} - -export const updateUserGitHubToken = async (userId: string, githubAccessToken: string, githubId: string, githubUsername: string): Promise => { - await updateDocument('users', userId, { githubAccessToken, githubId, githubUsername }); } \ No newline at end of file diff --git a/src/lib/state.ts b/src/lib/state.ts index 6e8d03550e..0d2b7fe37c 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -114,6 +114,11 @@ export type RoomState = { participants: RoomParticipant[] } +export type GithubState = { + username: string, + session: string +} + export const codeMirror = signal(null) export const codeMirrorEditorText = signal(''); export const muted = signal(false) diff --git a/src/pages/api/auth/github/callback.ts b/src/pages/api/auth/github/callback.ts index 15434fea29..ce32a8b608 100644 --- a/src/pages/api/auth/github/callback.ts +++ b/src/pages/api/auth/github/callback.ts @@ -5,7 +5,6 @@ import { } from "../../../../lib/game-saving/github"; import { getSession, - updateUserGitHubToken, } from "../../../../lib/game-saving/account"; export const get: APIRoute = async ({ request, cookies }) => { @@ -45,11 +44,7 @@ export const get: APIRoute = async ({ request, cookies }) => { if (!sessionInfo) { console.error("No active session found"); return new Response( - '', - { - headers: { "Content-Type": "text/html" }, - } - ); + '') } else if (sessionInfo.user.id !== userId) { console.error( `Session user ID mismatch: expected ${userId}, got ${sessionInfo.user.id}` @@ -62,19 +57,13 @@ export const get: APIRoute = async ({ request, cookies }) => { ); } - await updateUserGitHubToken( - userId, - accessToken, - githubUser.id, - githubUser.login - ); - return new Response( ``, + status: "success", + message: "GitHub authorization successful", + accessToken: "${accessToken}", + githubUsername: "${githubUser.login}" + }, "*"); window.close();`, { headers: { "Content-Type": "text/html" }, } @@ -83,8 +72,8 @@ export const get: APIRoute = async ({ request, cookies }) => { console.error("GitHub OAuth callback error:", error); return new Response( '', + (error as Error).message + + '" }, "*"); window.close();', { headers: { "Content-Type": "text/html" }, } diff --git a/src/pages/api/games/metrics.ts b/src/pages/api/games/metrics.ts new file mode 100644 index 0000000000..74ae8bef89 --- /dev/null +++ b/src/pages/api/games/metrics.ts @@ -0,0 +1,21 @@ +import type { APIContext } from 'astro'; +import metrics from "../../../../metrics"; + +export async function post({ request }: APIContext) { + try { + const { metric, value, type } = await request.json(); + + if (type === 'increment') { + metrics.increment(metric, value || 1); + } else if (type === 'timing') { + metrics.timing(metric, value); + } else { + return new Response('Invalid metric type', { status: 400 }); + } + + return new Response('Metric sent', { status: 200 }); + } catch (error) { + console.error('Error sending metric:', error); + return new Response('Failed to send metric', { status: 500 }); + } +} \ No newline at end of file