From a62651032b23a7e917a2d3fc769d1a703ef2e5d6 Mon Sep 17 00:00:00 2001 From: Josias Date: Thu, 17 Oct 2024 13:30:30 +0100 Subject: [PATCH 1/5] fold template literals after update has been propagated in editor (#2401) * fold bitmaps right after update has been propagated in editor modal Also rename updateCulprit -> lastUpdater since 'culprit' has a negative connotation to it * don't automatically unfold bitmap when we receive update from yjs * don't fold all bitmaps * add foldTemplateLiteral function --- .../big-interactive-pages/editor.tsx | 8 ++++ src/components/popups-etc/editor-modal.tsx | 30 +++++++------- src/lib/codemirror/init.ts | 41 ++++++++++++++++++- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/src/components/big-interactive-pages/editor.tsx b/src/components/big-interactive-pages/editor.tsx index 34c7773aea..59c9aa695e 100644 --- a/src/components/big-interactive-pages/editor.tsx +++ b/src/components/big-interactive-pages/editor.tsx @@ -88,6 +88,14 @@ const minHelpAreaHeight = 32; let defaultHelpAreaHeight = 350; const helpAreaHeightMargin = 0; // The margin between the screen and help area +export const foldTemplateLiteral = (from: number, to: number) => { + if (!codeMirror.value) return; + collapseRanges( + codeMirror.value, + [[from, to]] + ); +} + export const foldAllTemplateLiterals = () => { if (!codeMirror.value) return; const code = codeMirror.value.state.doc.toString() ?? ""; diff --git a/src/components/popups-etc/editor-modal.tsx b/src/components/popups-etc/editor-modal.tsx index c43d59d558..62dffca63f 100644 --- a/src/components/popups-etc/editor-modal.tsx +++ b/src/components/popups-etc/editor-modal.tsx @@ -8,15 +8,15 @@ import styles from './editor-modal.module.css' import levenshtein from 'js-levenshtein' import { runGameHeadless } from '../../lib/engine' -const enum UpdateCulprit { +const enum LastUpdater { RESET, OpenEditor, - CodeMirror + CodeMirror } export default function EditorModal() { const Content = openEditor.value ? editors[openEditor.value.kind].modalContent : () => null const text = useSignal(openEditor.value?.text ?? ''); - const [updateCulprit, setUpdateCulprit] = useState(UpdateCulprit.RESET); + const [lastUpdater, setLastUpdater] = useState(LastUpdater.RESET); useSignalEffect(() => { if (openEditor.value) text.value = openEditor.value.text @@ -28,17 +28,17 @@ export default function EditorModal() { * 1. If an update comes from the underlying codemirror document, the first effect doesn't run as we're already updating the editor modal from there * 2. If an update was originally made from the currently open editor modal, the changes to the codemirror document will * not trigger an update to the open editor modal - * + * * Both of these help us avoid Out-of-order errors or Cycles */ // Sync editor text changes with code useEffect(() => { // useEffect #1 // if update comes from codemirror doc (probably from a collab session) - // this ensures that updates are not triggered from this effect which may cause an + // this ensures that updates are not triggered from this effect which may cause an // Out-of-order / Cycles - if (updateCulprit === UpdateCulprit.CodeMirror) { - setUpdateCulprit(UpdateCulprit.RESET); + if (lastUpdater === LastUpdater.CodeMirror) { + setLastUpdater(LastUpdater.RESET); return; } // Signals are killing me but useEffect was broken and I need to ship this @@ -62,21 +62,21 @@ export default function EditorModal() { to: _openEditor.editRange.from + _text.length } } - setUpdateCulprit(UpdateCulprit.OpenEditor); + setLastUpdater(LastUpdater.OpenEditor); }, [text.value]); useEffect(() => { // if update comes from codemirror doc (probably from a collab session) - // this ensures that updates are not triggered from this effect which may cause an + // this ensures that updates are not triggered from this effect which may cause an // Out-of-order / Cycles - if (updateCulprit === UpdateCulprit.OpenEditor) { - setUpdateCulprit(UpdateCulprit.RESET); + if (lastUpdater === LastUpdater.OpenEditor) { + setLastUpdater(LastUpdater.RESET); return; } // just do this to sync the editor text with the code mirror text computeAndUpdateModalEditor(); - setUpdateCulprit(UpdateCulprit.CodeMirror); + setLastUpdater(LastUpdater.CodeMirror); // updateCulprit.value = UPDATE_CULPRIT.CodeMirror; }, [codeMirrorEditorText.value]); @@ -124,12 +124,12 @@ export default function EditorModal() { ...openEditor.value as OpenEditor, editRange: { from: editRange!.from, - to: editRange!.to + to: editRange!.to }, text: openEditorCode } - - } + + } } usePopupCloseClick(styles.content!, () => openEditor.value = null, !!openEditor.value) diff --git a/src/lib/codemirror/init.ts b/src/lib/codemirror/init.ts index f8d5b1f77a..717863259a 100644 --- a/src/lib/codemirror/init.ts +++ b/src/lib/codemirror/init.ts @@ -3,14 +3,15 @@ import { getSearchQuery, highlightSelectionMatches, search, searchKeymap, setSea import widgets from './widgets' import { effect, signal } from '@preact/signals' import { h, render } from 'preact' -import { bracketMatching, defaultHighlightStyle, foldGutter, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from '@codemirror/language' +import { bracketMatching, defaultHighlightStyle, foldedRanges, foldEffect, unfoldEffect, foldGutter, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from '@codemirror/language' import { history, defaultKeymap, historyKeymap, indentWithTab, insertNewlineAndIndent } from '@codemirror/commands' import { javascript } from '@codemirror/lang-javascript' import SearchBox from '../../components/search-box' import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightActiveLine } from '@codemirror/view' import { lintGutter } from "@codemirror/lint"; import type { NormalizedError } from '../state' -import { codeMirrorEditorText } from '../state' +import { codeMirrorEditorText, codeMirror } from '../state' +import { foldTemplateLiteral } from '../../components/big-interactive-pages/editor' export function diagnosticsFromErrorLog(view: EditorView, errorLog: NormalizedError[]) { return errorLog.filter(error => error.line) @@ -27,6 +28,42 @@ export function diagnosticsFromErrorLog(view: EditorView, errorLog: NormalizedEr export const initialExtensions = (onUpdate: any, onRunShortcut: any, yCollab?: any) => ([ EditorView.updateListener.of(update => { + update.transactions.forEach(transaction => { + // if it's a simple fold/unfold command, resolve + const isFoldOrUnfoldEffect = transaction.effects.map(stateEffect => { + return stateEffect.is(unfoldEffect) || stateEffect.is(foldEffect) + }); + + if (isFoldOrUnfoldEffect.includes(true)) return; + + const previousFoldedRanges = foldedRanges(transaction.startState); + const currentFoldedRanges = foldedRanges(codeMirror.value!.state); + function arrayFromFoldRangeIter(foldRanges: any) { + const iter = foldRanges.iter(); + const out: any[] = []; + while (iter.value != null) { + out.push({ from: iter.from, to: iter.to }); + iter.next(); + } + return out; + } + + const previousFoldRanges = arrayFromFoldRangeIter(previousFoldedRanges); + const currentFoldRanges = arrayFromFoldRangeIter(currentFoldedRanges); + + const foldRangeDiffs = [ + ...previousFoldRanges.filter(range => { + return !currentFoldRanges.some(oRange => (oRange.from === range.from && oRange.to === range.to)) + }), + ...currentFoldRanges.filter(range => { + return !previousFoldRanges.some(oRange => (oRange.from === range.from && oRange.to === range.to)) + }), + ]; + + foldRangeDiffs.forEach(range => foldTemplateLiteral(range.from, range.to)); + + }); + const newEditorText = update.state.doc.toString(); codeMirrorEditorText.value = newEditorText; }), From d49a7497e252c77e7af99549136083e4a18be36a Mon Sep 17 00:00:00 2001 From: Cheru Berhanu Date: Mon, 21 Oct 2024 11:38:49 -0400 Subject: [PATCH 2/5] add newline for testing (#2491) --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index aff8599236..a74ba76bd8 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,4 @@ GRAPHITE_HOST= STUCK_AIRTABLE_BASE= SENDGRID_API_KEY= LOOPS_API_KEY= -DEV_CODE= \ No newline at end of file +DEV_CODE= From f33a35392a0119d2d4239aa5fd322737e4c4330c Mon Sep 17 00:00:00 2001 From: Shubham Panth Date: Mon, 21 Oct 2024 20:44:07 +0200 Subject: [PATCH 3/5] 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 From 8ac3816428d3388f46b46cf0aecc2e8808309c6e Mon Sep 17 00:00:00 2001 From: Mare Cosmin <147330889+Cosmin-Mare@users.noreply.github.com> Date: Tue, 22 Oct 2024 17:35:55 +0300 Subject: [PATCH 4/5] Fix run button aesthetic (#2472) fix run button aesthetic --- src/components/navbar-editor.tsx | 19 +++++++++++++------ src/components/navbar.module.css | 17 +---------------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/components/navbar-editor.tsx b/src/components/navbar-editor.tsx index 8113254c0e..52f6016d01 100644 --- a/src/components/navbar-editor.tsx +++ b/src/components/navbar-editor.tsx @@ -1010,12 +1010,19 @@ export default function EditorNavbar(props: EditorNavbarProps) {
  • - - + + +