Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions apps/api/src/controllers/gsc-oauth-callback.controller.ts
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Non-null assertion on environment variable could cause runtime errors.

If neither DASHBOARD_URL nor NEXT_PUBLIC_DASHBOARD_URL is set, this results in undefined, causing the redirect to fail. The same issue exists in redirectWithError where new URL(undefined) would throw.

🛡️ 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 redirectWithError:

 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
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/controllers/gsc-oauth-callback.controller.ts` around lines 142 -
143, The code currently uses a non-null assertion for
DASHBOARD_URL/NEXT_PUBLIC_DASHBOARD_URL when building dashboardUrl and in
redirectWithError, which can produce runtime errors if neither env var is set;
update the logic in the scope where dashboardUrl is computed (and in
redirectWithError) to explicitly check for the presence of
process.env.DASHBOARD_URL or process.env.NEXT_PUBLIC_DASHBOARD_URL, throw or
return a clear error if both are missing, or provide a safe fallback URL (e.g.,
"/") before calling new URL or performing redirects, and remove the non-null
assertion to ensure runtime safety (refer to the dashboardUrl constant and the
redirectWithError function).

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());
}
2 changes: 2 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { timestampHook } from './hooks/timestamp.hook';
import aiRouter from './routes/ai.router';
import eventRouter from './routes/event.router';
import exportRouter from './routes/export.router';
import gscCallbackRouter from './routes/gsc-callback.router';
import importRouter from './routes/import.router';
import insightsRouter from './routes/insights.router';
import liveRouter from './routes/live.router';
Expand Down Expand Up @@ -194,6 +195,7 @@ const startServer = async () => {
instance.register(liveRouter, { prefix: '/live' });
instance.register(webhookRouter, { prefix: '/webhook' });
instance.register(oauthRouter, { prefix: '/oauth' });
instance.register(gscCallbackRouter, { prefix: '/gsc' });
instance.register(miscRouter, { prefix: '/misc' });
instance.register(aiRouter, { prefix: '/ai' });
});
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/routes/gsc-callback.router.ts
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;
34 changes: 19 additions & 15 deletions apps/start/src/components/chat/chat-report.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import { pushModal } from '@/modals';
import type {
IReport,
IChartRange,
IChartType,
IInterval,
IReport,
} from '@openpanel/validation';
import { SaveIcon } from 'lucide-react';
import { useState } from 'react';
import { ReportChart } from '../report-chart';
import { ReportChartType } from '../report/ReportChartType';
import { ReportInterval } from '../report/ReportInterval';
import { ReportChart } from '../report-chart';
import { TimeWindowPicker } from '../time-window-picker';
import { Button } from '../ui/button';
import { pushModal } from '@/modals';

export function ChatReport({
lazy,
...props
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) {
}: {
report: IReport & { startDate: string; endDate: string };
lazy: boolean;
}) {
const [chartType, setChartType] = useState<IChartType>(
props.report.chartType,
props.report.chartType
);
const [startDate, setStartDate] = useState<string>(props.report.startDate);
const [endDate, setEndDate] = useState<string>(props.report.endDate);
Expand All @@ -35,47 +38,48 @@ export function ChatReport({
};
return (
<div className="card">
<div className="text-center text-sm font-mono font-medium pt-4">
<div className="pt-4 text-center font-medium font-mono text-sm">
{props.report.name}
</div>
<div className="p-4">
<ReportChart lazy={lazy} report={report} />
</div>
<div className="row justify-between gap-1 border-t border-border p-2">
<div className="row justify-between gap-1 border-border border-t p-2">
<div className="col md:row gap-1">
<TimeWindowPicker
className="min-w-0"
endDate={report.endDate}
onChange={setRange}
value={report.range}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
endDate={report.endDate}
onIntervalChange={setInterval}
onStartDateChange={setStartDate}
startDate={report.startDate}
value={report.range}
/>
<ReportInterval
chartType={chartType}
className="min-w-0"
interval={interval}
range={range}
chartType={chartType}
onChange={setInterval}
range={range}
/>
<ReportChartType
value={chartType}
onChange={(type) => {
setChartType(type);
}}
value={chartType}
/>
</div>
<Button
icon={SaveIcon}
variant="outline"
size="sm"
onClick={() => {
pushModal('SaveReport', {
report,
disableRedirect: true,
});
}}
size="sm"
variant="outline"
>
Save report
</Button>
Expand Down
18 changes: 13 additions & 5 deletions apps/start/src/components/overview/overview-range.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,25 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { TimeWindowPicker } from '@/components/time-window-picker';

export function OverviewRange() {
const { range, setRange, setStartDate, setEndDate, endDate, startDate } =
useOverviewOptions();
const {
range,
setRange,
setStartDate,
setEndDate,
endDate,
startDate,
setInterval,
} = useOverviewOptions();

return (
<TimeWindowPicker
endDate={endDate}
onChange={setRange}
value={range}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
endDate={endDate}
onIntervalChange={setInterval}
onStartDateChange={setStartDate}
startDate={startDate}
value={range}
/>
);
}
Loading