-
Notifications
You must be signed in to change notification settings - Fork 322
Feature/gsc #312
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature/gsc #312
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| import { googleGsc } from '@openpanel/auth'; | ||
| import { db, encrypt } from '@openpanel/db'; | ||
| import type { FastifyReply, FastifyRequest } from 'fastify'; | ||
| import { z } from 'zod'; | ||
| import { LogError } from '@/utils/errors'; | ||
|
|
||
| const OAUTH_SENSITIVE_KEYS = ['code', 'state']; | ||
|
|
||
| function sanitizeOAuthQuery( | ||
| query: Record<string, unknown> | null | undefined | ||
| ): Record<string, string> { | ||
| if (!query || typeof query !== 'object') { | ||
| return {}; | ||
| } | ||
| return Object.fromEntries( | ||
| Object.entries(query).map(([k, v]) => [ | ||
| k, | ||
| OAUTH_SENSITIVE_KEYS.includes(k) ? '<redacted>' : String(v), | ||
| ]) | ||
| ); | ||
| } | ||
|
|
||
| export async function gscGoogleCallback( | ||
| req: FastifyRequest, | ||
| reply: FastifyReply | ||
| ) { | ||
| try { | ||
| const schema = z.object({ | ||
| code: z.string(), | ||
| state: z.string(), | ||
| }); | ||
|
|
||
| const query = schema.safeParse(req.query); | ||
| if (!query.success) { | ||
| throw new LogError( | ||
| 'Invalid GSC callback query params', | ||
| sanitizeOAuthQuery(req.query as Record<string, unknown>) | ||
| ); | ||
| } | ||
|
|
||
| const { code, state } = query.data; | ||
|
|
||
| const rawStoredState = req.cookies.gsc_oauth_state ?? null; | ||
| const rawCodeVerifier = req.cookies.gsc_code_verifier ?? null; | ||
| const rawProjectId = req.cookies.gsc_project_id ?? null; | ||
|
|
||
| const storedStateResult = | ||
| rawStoredState !== null ? req.unsignCookie(rawStoredState) : null; | ||
| const codeVerifierResult = | ||
| rawCodeVerifier !== null ? req.unsignCookie(rawCodeVerifier) : null; | ||
| const projectIdResult = | ||
| rawProjectId !== null ? req.unsignCookie(rawProjectId) : null; | ||
|
|
||
| if ( | ||
| !( | ||
| storedStateResult?.value && | ||
| codeVerifierResult?.value && | ||
| projectIdResult?.value | ||
| ) | ||
| ) { | ||
| throw new LogError('Missing GSC OAuth cookies', { | ||
| storedState: !storedStateResult?.value, | ||
| codeVerifier: !codeVerifierResult?.value, | ||
| projectId: !projectIdResult?.value, | ||
| }); | ||
| } | ||
|
|
||
| if ( | ||
| !( | ||
| storedStateResult?.valid && | ||
| codeVerifierResult?.valid && | ||
| projectIdResult?.valid | ||
| ) | ||
| ) { | ||
| throw new LogError('Invalid GSC OAuth cookies', { | ||
| storedState: !storedStateResult?.value, | ||
| codeVerifier: !codeVerifierResult?.value, | ||
| projectId: !projectIdResult?.value, | ||
| }); | ||
| } | ||
|
|
||
| const stateStr = storedStateResult?.value; | ||
| const codeVerifierStr = codeVerifierResult?.value; | ||
| const projectIdStr = projectIdResult?.value; | ||
|
|
||
| if (state !== stateStr) { | ||
| throw new LogError('GSC OAuth state mismatch', { | ||
| hasState: true, | ||
| hasStoredState: true, | ||
| stateMismatch: true, | ||
| }); | ||
| } | ||
|
|
||
| const tokens = await googleGsc.validateAuthorizationCode( | ||
| code, | ||
| codeVerifierStr | ||
| ); | ||
|
|
||
| const accessToken = tokens.accessToken(); | ||
| const refreshToken = tokens.hasRefreshToken() | ||
| ? tokens.refreshToken() | ||
| : null; | ||
| const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); | ||
|
|
||
| if (!refreshToken) { | ||
| throw new LogError('No refresh token returned from Google GSC OAuth'); | ||
| } | ||
|
|
||
| const project = await db.project.findUnique({ | ||
| where: { id: projectIdStr }, | ||
| select: { id: true, organizationId: true }, | ||
| }); | ||
|
|
||
| if (!project) { | ||
| throw new LogError('Project not found for GSC connection', { | ||
| projectId: projectIdStr, | ||
| }); | ||
| } | ||
|
|
||
| await db.gscConnection.upsert({ | ||
| where: { projectId: projectIdStr }, | ||
| create: { | ||
| projectId: projectIdStr, | ||
| accessToken: encrypt(accessToken), | ||
| refreshToken: encrypt(refreshToken), | ||
| accessTokenExpiresAt, | ||
| siteUrl: '', | ||
| }, | ||
| update: { | ||
| accessToken: encrypt(accessToken), | ||
| refreshToken: encrypt(refreshToken), | ||
| accessTokenExpiresAt, | ||
| lastSyncStatus: null, | ||
| lastSyncError: null, | ||
| }, | ||
| }); | ||
|
|
||
| reply.clearCookie('gsc_oauth_state'); | ||
| reply.clearCookie('gsc_code_verifier'); | ||
| reply.clearCookie('gsc_project_id'); | ||
|
|
||
| const dashboardUrl = | ||
| process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!; | ||
|
Comment on lines
+142
to
+143
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-null assertion on environment variable could cause runtime errors. If neither 🛡️ Proposed fix to add validation or fallback+ const dashboardUrl =
+ process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL;
+ if (!dashboardUrl) {
+ throw new LogError('DASHBOARD_URL environment variable is not configured');
+ }
- const dashboardUrl =
- process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!;
const redirectUrl = `${dashboardUrl}/${project.organizationId}/${projectIdStr}/settings/gsc`;And similarly in function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
+ const baseUrl = process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL;
+ if (!baseUrl) {
+ // Fallback to a generic error response if URL is not configured
+ return reply.status(500).send({ error: 'Server configuration error' });
+ }
- const url = new URL(
- process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
- );
+ const url = new URL(baseUrl);🤖 Prompt for AI Agents |
||
| const redirectUrl = `${dashboardUrl}/${project.organizationId}/${projectIdStr}/settings/gsc`; | ||
| return reply.redirect(redirectUrl); | ||
| } catch (error) { | ||
| req.log.error(error); | ||
| reply.clearCookie('gsc_oauth_state'); | ||
| reply.clearCookie('gsc_code_verifier'); | ||
| reply.clearCookie('gsc_project_id'); | ||
| return redirectWithError(reply, error); | ||
| } | ||
| } | ||
|
|
||
| function redirectWithError(reply: FastifyReply, error: LogError | unknown) { | ||
| const url = new URL( | ||
| process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL! | ||
| ); | ||
| url.pathname = '/login'; | ||
| if (error instanceof LogError) { | ||
| url.searchParams.set('error', error.message); | ||
| } else { | ||
| url.searchParams.set('error', 'Failed to connect Google Search Console'); | ||
| } | ||
| url.searchParams.set('correlationId', reply.request.id); | ||
| return reply.redirect(url.toString()); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller'; | ||
| import type { FastifyPluginCallback } from 'fastify'; | ||
|
|
||
| const router: FastifyPluginCallback = async (fastify) => { | ||
| fastify.route({ | ||
| method: 'GET', | ||
| url: '/callback', | ||
| handler: gscGoogleCallback, | ||
| }); | ||
| }; | ||
|
|
||
| export default router; |
Uh oh!
There was an error while loading. Please reload this page.