Self-hosted Google Search Console (#236)
This commit is contained in:
parent
23c38ca4e5
commit
7a26be46cc
@ -37,3 +37,10 @@
|
||||
# LOOPS_API_KEY=replace-with-your-loops-api-key
|
||||
# LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=replace-with-your-loops-verify-template-id
|
||||
# LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=replace-with-your-loops-reset-template-id
|
||||
|
||||
# Optional in self-hosted modes. Required if you want Google Search Console
|
||||
# integration and MCP tools. BETTER_AUTH_SECRET is also required for GSC (it
|
||||
# encrypts the stored OAuth tokens). See docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md.
|
||||
# GOOGLE_CLIENT_ID=replace-with-your-google-oauth-client-id
|
||||
# GOOGLE_CLIENT_SECRET=replace-with-your-google-oauth-client-secret
|
||||
# BETTER_AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-characters
|
||||
|
||||
@ -29,6 +29,7 @@ Easy to self-host, fork and extend, but we have a managed version too:
|
||||
- [Community](#community)
|
||||
- [Pricing / Costs (Free + API costs)](#pricing--costs)
|
||||
- [DataForSEO API Key Setup](#dataforseo-api-key-setup)
|
||||
- [Google Search Console](#google-search-console)
|
||||
- [Self-hosting](#self-hosting)
|
||||
- [Docker Self Hosting](#docker-self-hosting)
|
||||
- [Cloudflare Self-Hosting](#cloudflare-self-hosting)
|
||||
@ -203,6 +204,12 @@ printf '%s' 'YOUR_LOGIN:YOUR_PASSWORD' | base64
|
||||
- Cloudflare: Set it in the workers UI
|
||||
- Local development: `.env.local`
|
||||
|
||||
## Google Search Console
|
||||
|
||||
Search Console is optional and works in self-hosted deployments using your own
|
||||
Google OAuth client. It takes ~10 minutes of one-time setup — see
|
||||
[`docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md`](./docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md).
|
||||
|
||||
## Self-hosting
|
||||
|
||||
OpenSEO supports two self-hosting paths:
|
||||
|
||||
@ -9,6 +9,11 @@ services:
|
||||
- ALLOWED_HOST=${ALLOWED_HOST:-}
|
||||
- AUTH_MODE=local_noauth
|
||||
- DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY}
|
||||
# Optional: Google Search Console. See
|
||||
# docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-}
|
||||
- VITE_SHOW_DEVTOOLS=false
|
||||
ports:
|
||||
- "127.0.0.1:${PORT:-3001}:${PORT:-3001}"
|
||||
|
||||
118
docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md
Normal file
118
docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md
Normal file
@ -0,0 +1,118 @@
|
||||
# Self-hosted Google Search Console
|
||||
|
||||
Connecting Google Search Console (GSC) lets OpenSEO pull your real clicks,
|
||||
impressions, positions, and URL inspection data, straight from Google.
|
||||
|
||||
It's **optional**: OpenSEO runs fine without it, just without Search Console data.
|
||||
|
||||
## What you'll need
|
||||
|
||||
- A Google account with access to your verified Search Console property.
|
||||
- ~10 minutes in the [Google Cloud Console](https://console.cloud.google.com/).
|
||||
- Three environment variables set on your deployment (see [step 4](#4-set-environment-variables)).
|
||||
|
||||
## 1) Create a Google Cloud project and enable the API
|
||||
|
||||
1. Open the [Google Cloud Console](https://console.cloud.google.com/) and create
|
||||
a project (or pick an existing one).
|
||||
2. Enable the
|
||||
[Google Search Console API](https://console.cloud.google.com/apis/library/searchconsole.googleapis.com)
|
||||
for that project.
|
||||
|
||||
## 2) Configure the OAuth consent screen
|
||||
|
||||
Under **APIs & Services → OAuth consent screen**:
|
||||
|
||||
- Pick **External** (unless everyone using it is in your Google Workspace org).
|
||||
- Fill in the app name, support email, and developer contact email.
|
||||
- While the app is in **Testing**, add the Google accounts that will connect as
|
||||
**test users** — otherwise Google blocks the sign-in with `access_denied`.
|
||||
|
||||
For personal or internal use you don't need to submit for verification; testing
|
||||
mode is enough.
|
||||
|
||||
## 3) Create an OAuth client ID
|
||||
|
||||
Under **APIs & Services → Credentials → Create credentials → OAuth client ID**:
|
||||
|
||||
1. Application type: **Web application**.
|
||||
2. Add an **Authorized redirect URI** that exactly matches your deployment's
|
||||
origin plus `/api/gsc/oauth/callback`:
|
||||
|
||||
| Deployment | Redirect URI |
|
||||
| ------------ | -------------------------------------------------------- |
|
||||
| Deployed | `https://your-openseo-domain.com/api/gsc/oauth/callback` |
|
||||
| Local Docker | `http://localhost:3001/api/gsc/oauth/callback` |
|
||||
|
||||
The scheme, host, and port must match exactly, with no trailing slash.
|
||||
|
||||
3. Save, then copy the **Client ID** and **Client secret**.
|
||||
|
||||
## 4) Set environment variables
|
||||
|
||||
Set these three values, then restart OpenSEO:
|
||||
|
||||
| Variable | Value |
|
||||
| ---------------------- | ----------------------------------------------------------------------- |
|
||||
| `GOOGLE_CLIENT_ID` | Client ID from step 3. |
|
||||
| `GOOGLE_CLIENT_SECRET` | Client secret from step 3. |
|
||||
| `BETTER_AUTH_SECRET` | A random string of **at least 32 characters** (encrypts stored tokens). |
|
||||
|
||||
`BETTER_AUTH_SECRET` is not needed for normal self-hosting — only for Search
|
||||
Console, because the stored OAuth tokens are encrypted at rest with it. Generate
|
||||
one with:
|
||||
|
||||
```sh
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
Where to set them:
|
||||
|
||||
- **Docker self-hosting:** `.env`
|
||||
- **Cloudflare:** the Workers dashboard (as secrets)
|
||||
- **Local development:** `.env.local`
|
||||
|
||||
## 5) Restart and connect
|
||||
|
||||
Restart OpenSEO so it picks up the new variables. For Docker, changing `.env`
|
||||
means Compose has to recreate the container to reapply it:
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate open-seo
|
||||
```
|
||||
|
||||
Then open **Integrations**, click **Connect with Google**, authorize the Google
|
||||
account that owns your verified property, and pick the property to bind to your
|
||||
project.
|
||||
|
||||
## How it works
|
||||
|
||||
- OpenSEO uses your Google client to run the OAuth flow and stores the resulting
|
||||
grant in its database, with the access and refresh tokens **encrypted at rest**
|
||||
(keyed by `BETTER_AUTH_SECRET`).
|
||||
- Access tokens are minted and refreshed on demand — you only authorize once.
|
||||
- Search Console data comes from your own Google account, so OpenSEO never meters credits for it.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`redirect_uri_mismatch` from Google** — the redirect URI in your OAuth client
|
||||
must exactly equal `<your-origin>/api/gsc/oauth/callback`. Re-check scheme
|
||||
(`http` vs `https`), host, port, and that there's no trailing slash.
|
||||
|
||||
**"Google OAuth client not configured" / "not configured for Search Console yet"**
|
||||
(in the app or via the MCP tools) — one of `GOOGLE_CLIENT_ID`,
|
||||
`GOOGLE_CLIENT_SECRET`, or `BETTER_AUTH_SECRET` is missing, or the secret is
|
||||
shorter than 32 characters. Set all three and restart. On Docker, recreate the
|
||||
container so Compose reapplies `.env`:
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate open-seo
|
||||
```
|
||||
|
||||
**`access_denied` during sign-in** — the Google account isn't listed as a test
|
||||
user on the OAuth consent screen (while the app is in Testing mode). Add it under
|
||||
**OAuth consent screen → Test users**.
|
||||
|
||||
**Connected, but no properties to pick** — the Google account you authorized
|
||||
doesn't have a verified property in Search Console. Verify the site in
|
||||
[Search Console](https://search.google.com/search-console) first, then reconnect.
|
||||
11
drizzle/0020_drop_delegated_users.sql
Normal file
11
drizzle/0020_drop_delegated_users.sql
Normal file
@ -0,0 +1,11 @@
|
||||
INSERT OR IGNORE INTO `user` (`id`, `name`, `email`, `email_verified`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
`id`,
|
||||
coalesce(nullif(substr(`email`, 1, instr(`email`, '@') - 1), ''), `email`),
|
||||
`email`,
|
||||
1,
|
||||
cast(unixepoch(`created_at`) * 1000 as integer),
|
||||
cast(unixepoch(`created_at`) * 1000 as integer)
|
||||
FROM `delegated_users`;
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `delegated_users`;
|
||||
2528
drizzle/meta/0020_snapshot.json
Normal file
2528
drizzle/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -141,6 +141,13 @@
|
||||
"when": 1780519331717,
|
||||
"tag": "0019_true_absorbing_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "6",
|
||||
"when": 1780599087400,
|
||||
"tag": "0020_drop_delegated_users",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -11,9 +11,10 @@ import { dismissGscNudge } from "@/serverFunctions/onboarding";
|
||||
|
||||
/**
|
||||
* One-time re-engagement prompt nudging users who finished onboarding *before*
|
||||
* the Search Console step existed to connect GSC. Hosted-only (the connect flow
|
||||
* needs Better Auth). Shows once — server-persisted dismissal means it never
|
||||
* reappears after the user connects or dismisses, on any device.
|
||||
* the Search Console step existed to connect GSC. Hosted-only because this is
|
||||
* a hosted onboarding re-engagement nudge. Shows once — server-persisted
|
||||
* dismissal means it never reappears after the user connects or dismisses, on
|
||||
* any device.
|
||||
*
|
||||
* `suppressed` lets the layout hide this when another modal (e.g. the missing
|
||||
* DataForSEO key prompt) is already showing so the two never stack.
|
||||
|
||||
@ -5,6 +5,7 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
|
||||
import { SitePicker } from "@/client/features/gsc/SitePicker";
|
||||
import { startGscLink } from "@/client/features/gsc/startGscLink";
|
||||
import {
|
||||
@ -30,16 +31,17 @@ export function SearchConsoleConnectionCard({
|
||||
const connectionQuery = useQuery({
|
||||
queryKey: connectionKey,
|
||||
queryFn: () => getGscConnection({ data: { projectId } }),
|
||||
enabled: hosted,
|
||||
});
|
||||
const connection = connectionQuery.data;
|
||||
const connected = Boolean(connection?.connected);
|
||||
const selfHostedNeedsSetup =
|
||||
!hosted && connectionQuery.isSuccess && !connection?.googleOAuthConfigured;
|
||||
|
||||
const showPicker = picking || (connection?.currentUserHasGrant && !connected);
|
||||
const sitesQuery = useQuery({
|
||||
queryKey: ["gscSites", projectId],
|
||||
queryFn: () => listGscSites({ data: { projectId } }),
|
||||
enabled: Boolean(showPicker),
|
||||
enabled: Boolean(showPicker && !selfHostedNeedsSetup),
|
||||
});
|
||||
|
||||
const setSiteMutation = useMutation({
|
||||
@ -70,21 +72,13 @@ export function SearchConsoleConnectionCard({
|
||||
|
||||
const handleConnect = () => void startGscLink(window.location.href);
|
||||
|
||||
if (!hosted) {
|
||||
return (
|
||||
<IntegrationCard>
|
||||
<p className="text-sm text-base-content/60">
|
||||
Available on hosted OpenSEO. Self-hosted? Use a CSV export.
|
||||
</p>
|
||||
</IntegrationCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegrationCard
|
||||
status={
|
||||
connectionQuery.isLoading
|
||||
? undefined
|
||||
: selfHostedNeedsSetup
|
||||
? "setup_required"
|
||||
: connected
|
||||
? "connected"
|
||||
: "disconnected"
|
||||
@ -95,6 +89,8 @@ export function SearchConsoleConnectionCard({
|
||||
<span className="loading loading-spinner loading-sm" />
|
||||
Checking…
|
||||
</div>
|
||||
) : selfHostedNeedsSetup ? (
|
||||
<SelfHostedSetupWarning />
|
||||
) : connected && !picking ? (
|
||||
<ConnectedState
|
||||
siteUrl={connection?.siteUrl ?? ""}
|
||||
@ -156,7 +152,7 @@ function IntegrationCard({
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
status?: "connected" | "disconnected";
|
||||
status?: "connected" | "disconnected" | "setup_required";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@ -177,24 +173,39 @@ function IntegrationCard({
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: "connected" | "disconnected" }) {
|
||||
function StatusPill({
|
||||
status,
|
||||
}: {
|
||||
status: "connected" | "disconnected" | "setup_required";
|
||||
}) {
|
||||
const connected = status === "connected";
|
||||
const setupRequired = status === "setup_required";
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
|
||||
connected
|
||||
? "border-success/30 bg-success/10 text-success"
|
||||
: setupRequired
|
||||
? "border-warning/30 bg-warning/10 text-warning"
|
||||
: "border-base-300 bg-base-200 text-base-content/60",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"size-1.5 rounded-full",
|
||||
connected ? "bg-success" : "bg-base-content/40",
|
||||
connected
|
||||
? "bg-success"
|
||||
: setupRequired
|
||||
? "bg-warning"
|
||||
: "bg-base-content/40",
|
||||
].join(" ")}
|
||||
/>
|
||||
{connected ? "Connected" : "Not connected"}
|
||||
{connected
|
||||
? "Connected"
|
||||
: setupRequired
|
||||
? "Setup required"
|
||||
: "Not connected"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
27
src/client/features/gsc/SelfHostedSetupWarning.tsx
Normal file
27
src/client/features/gsc/SelfHostedSetupWarning.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
|
||||
import { GSC_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/gsc";
|
||||
|
||||
/**
|
||||
* Shown in self-hosted deployments that haven't set GOOGLE_CLIENT_ID/SECRET yet
|
||||
* — in both the Integrations card and the onboarding step.
|
||||
*/
|
||||
export function SelfHostedSetupWarning() {
|
||||
return (
|
||||
<div className="alert alert-warning items-start text-sm">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Google OAuth client not configured</p>
|
||||
<p className="text-base-content/70">
|
||||
Add your Google client ID and secret to this OpenSEO deployment before
|
||||
connecting Search Console.
|
||||
</p>
|
||||
<SafeExternalLink
|
||||
url={GSC_SELF_HOSTED_SETUP_DOCS_URL}
|
||||
label="Open setup guide"
|
||||
className="inline-flex items-center gap-1 font-medium underline underline-offset-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { toast } from "sonner";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
import { startSelfHostedGscLink } from "@/serverFunctions/gsc";
|
||||
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
|
||||
|
||||
/**
|
||||
@ -12,6 +14,12 @@ import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
|
||||
*/
|
||||
export async function startGscLink(callbackURL: string): Promise<void> {
|
||||
try {
|
||||
if (!isHostedClientAuthMode()) {
|
||||
const res = await startSelfHostedGscLink({ data: { callbackURL } });
|
||||
window.location.href = res.url;
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await authClient.oauth2.link({
|
||||
providerId: GSC_OAUTH_PROVIDER_ID,
|
||||
callbackURL,
|
||||
|
||||
@ -3,11 +3,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
|
||||
import { SitePicker } from "@/client/features/gsc/SitePicker";
|
||||
import { startGscLink } from "@/client/features/gsc/startGscLink";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
import {
|
||||
getGscConnection,
|
||||
listGscSites,
|
||||
@ -19,14 +19,12 @@ import { getOrCreateDefaultProject } from "@/serverFunctions/projects";
|
||||
* Onboarding step for connecting Google Search Console: link the account-level
|
||||
* OAuth grant, then bind a verified property to the user's default project —
|
||||
* the same binding the project's Integrations page does — so it's done in one
|
||||
* place. Hosted-only (the connect flow needs Better Auth).
|
||||
* place.
|
||||
*/
|
||||
export function SearchConsoleOnboardingStep() {
|
||||
const hosted = isHostedClientAuthMode();
|
||||
const projectQuery = useQuery({
|
||||
queryKey: ["defaultProject"],
|
||||
queryFn: () => getOrCreateDefaultProject(),
|
||||
enabled: hosted,
|
||||
});
|
||||
|
||||
return (
|
||||
@ -35,11 +33,7 @@ export function SearchConsoleOnboardingStep() {
|
||||
Connect with Google Search Console now?
|
||||
</h2>
|
||||
|
||||
{!hosted ? (
|
||||
<p className="rounded-lg border border-base-300 bg-base-200/40 px-3 py-2.5 text-sm text-base-content/60">
|
||||
Available on hosted OpenSEO. Self-hosted? Use a CSV export.
|
||||
</p>
|
||||
) : projectQuery.data ? (
|
||||
{projectQuery.data ? (
|
||||
<GscConnect projectId={projectQuery.data.id} />
|
||||
) : (
|
||||
<Checking />
|
||||
@ -66,11 +60,13 @@ function GscConnect({ projectId }: { projectId: string }) {
|
||||
const connection = connectionQuery.data;
|
||||
const connected = Boolean(connection?.connected);
|
||||
const hasGrant = Boolean(connection?.currentUserHasGrant);
|
||||
const needsSetup =
|
||||
connectionQuery.isSuccess && !connection?.googleOAuthConfigured;
|
||||
|
||||
const sitesQuery = useQuery({
|
||||
queryKey: ["gscSites", projectId],
|
||||
queryFn: () => listGscSites({ data: { projectId } }),
|
||||
enabled: hasGrant && !connected,
|
||||
enabled: hasGrant && !connected && !needsSetup,
|
||||
});
|
||||
|
||||
const setSiteMutation = useMutation({
|
||||
@ -90,6 +86,10 @@ function GscConnect({ projectId }: { projectId: string }) {
|
||||
|
||||
if (connectionQuery.isLoading) return <Checking />;
|
||||
|
||||
if (needsSetup) {
|
||||
return <SelfHostedSetupWarning />;
|
||||
}
|
||||
|
||||
if (connected) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-success/30 bg-success/10 p-3.5 text-sm">
|
||||
|
||||
@ -9,16 +9,6 @@ import {
|
||||
import { sql } from "drizzle-orm";
|
||||
import { organization, user } from "./better-auth-schema";
|
||||
|
||||
// This stores users for Cloudflare Access and local_noauth mode
|
||||
// since they don't map to better-auth's user schema
|
||||
export const delegatedUsers = sqliteTable("delegated_users", {
|
||||
id: text("id").primaryKey(),
|
||||
email: text("email").notNull().unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
export const userOnboardingAnswers = sqliteTable(
|
||||
"user_onboarding_answers",
|
||||
{
|
||||
|
||||
@ -3,7 +3,7 @@ import { sql } from "drizzle-orm";
|
||||
import { organization } from "./better-auth-schema";
|
||||
import { projects } from "./app.schema";
|
||||
|
||||
// Connected Google Search Console property per project (hosted-only).
|
||||
// Connected Google Search Console property per project.
|
||||
// OAuth tokens live in the better-auth `account` table under providerId
|
||||
// "google-search-console"; this row only records which verified property maps
|
||||
// to a project and whose grant to use when calling the GSC API.
|
||||
|
||||
@ -1,16 +1,7 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { genericOAuth, organization } from "better-auth/plugins";
|
||||
import { baseAuthOptions } from "@/lib/auth-options";
|
||||
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
|
||||
|
||||
/** Read-only Search Console scope. openid/email/profile are also required —
|
||||
* the genericOAuth callback rejects with `name_is_missing` without a name claim. */
|
||||
const GSC_OAUTH_SCOPES = [
|
||||
"openid",
|
||||
"email",
|
||||
"profile",
|
||||
"https://www.googleapis.com/auth/webmasters.readonly",
|
||||
];
|
||||
import { GSC_OAUTH_PROVIDER_ID, GSC_OAUTH_SCOPES } from "@/shared/gsc";
|
||||
|
||||
export function createBaseAuthConfig() {
|
||||
return {
|
||||
@ -35,7 +26,7 @@ export function createBaseAuthConfig() {
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET?.trim() ?? "",
|
||||
discoveryUrl:
|
||||
"https://accounts.google.com/.well-known/openid-configuration",
|
||||
scopes: GSC_OAUTH_SCOPES,
|
||||
scopes: [...GSC_OAUTH_SCOPES],
|
||||
accessType: "offline", // request a refresh token
|
||||
prompt: "consent", // force refresh-token issuance on re-consent
|
||||
pkce: true,
|
||||
|
||||
@ -4,6 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||
import { db } from "@/db";
|
||||
import { z } from "zod";
|
||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||
import { createBaseAuthConfig } from "@/lib/auth-config";
|
||||
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||
import {
|
||||
@ -24,7 +25,12 @@ const hostedBaseUrlSchema = z
|
||||
}, "BETTER_AUTH_URL must use https or localhost");
|
||||
|
||||
function createAuth() {
|
||||
const baseUrl = getHostedBaseUrl();
|
||||
// Hosted needs the real configured URL (cookies, callbacks, /api/auth routes
|
||||
// all use it). Self-hosted only builds this instance to mint/refresh Search
|
||||
// Console tokens, which never read baseURL — so a placeholder is fine there.
|
||||
const baseUrl = isHostedAuthMode(env.AUTH_MODE)
|
||||
? getHostedBaseUrl()
|
||||
: "http://localhost";
|
||||
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
||||
const baseAuthConfig = createBaseAuthConfig();
|
||||
|
||||
@ -142,11 +148,14 @@ export function getHostedBaseUrl() {
|
||||
return hostedBaseUrlSchema.parse(baseUrl);
|
||||
}
|
||||
|
||||
// Required in hosted mode, and in self-hosted mode when Search Console is
|
||||
// enabled (it keys the OAuth-token encryption and is needed to build the auth
|
||||
// instance that mints/refreshes Search Console tokens).
|
||||
function getHostedSecret() {
|
||||
const secret = env.BETTER_AUTH_SECRET?.trim();
|
||||
|
||||
if (!secret) {
|
||||
throw new Error("BETTER_AUTH_SECRET is required in hosted mode");
|
||||
throw new Error("BETTER_AUTH_SECRET is required");
|
||||
}
|
||||
|
||||
if (secret.length < 32) {
|
||||
@ -157,6 +166,15 @@ function getHostedSecret() {
|
||||
}
|
||||
|
||||
function getSocialProviders() {
|
||||
// Google social login is hosted-only. Self-hosted builds the auth instance
|
||||
// solely for Search Console token ops, which use the genericOAuth provider
|
||||
// (createBaseAuthConfig) with its own creds — so it must NOT require the
|
||||
// social-login config here, otherwise getAuth() construction would be coupled
|
||||
// to GSC creds rather than just BETTER_AUTH_SECRET.
|
||||
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
google: getGoogleSocialProviderConfig(),
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { db } from "@/db";
|
||||
import { delegatedUsers } from "@/db/schema";
|
||||
import { user } from "@/db/schema";
|
||||
import { ensureDelegatedOrganizationForUser } from "@/server/auth/delegated-organization";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { EnsuredUserContext } from "./types";
|
||||
@ -7,30 +7,49 @@ import type { EnsuredUserContext } from "./types";
|
||||
const LOCAL_ADMIN_USER_ID = "local-admin";
|
||||
const LOCAL_ADMIN_EMAIL = "admin@localhost";
|
||||
|
||||
// Externally-authenticated users (Cloudflare Access, local_noauth) are stored
|
||||
// in better-auth's `user` table just like hosted users — only the way we
|
||||
// authenticate them differs (per-request, no better-auth session). Keeping a
|
||||
// single user table means the OAuth `account` grant and every app table that
|
||||
// references `user.id` resolve the same way in all auth modes.
|
||||
function deriveUserName(email: string) {
|
||||
return email.split("@")[0] || "OpenSEO";
|
||||
}
|
||||
|
||||
async function ensureUserRecord(userId: string, userEmail: string) {
|
||||
const existingUser = await db.query.delegatedUsers.findFirst({
|
||||
where: eq(delegatedUsers.id, userId),
|
||||
const existing = await db.query.user.findFirst({
|
||||
columns: { email: true },
|
||||
where: eq(user.id, userId),
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
await db.insert(delegatedUsers).values({
|
||||
id: userId,
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
if (existingUser.email !== userEmail) {
|
||||
if (!existing) {
|
||||
// Concurrent first-load requests can all see "no row" and race to insert
|
||||
// the same id; onConflictDoNothing on the PK makes the losers no-ops instead
|
||||
// of failing. Scoped to the id so a genuine email-unique collision (two
|
||||
// distinct ids sharing an email) still surfaces loudly.
|
||||
await db
|
||||
.update(delegatedUsers)
|
||||
.set({ email: userEmail })
|
||||
.where(eq(delegatedUsers.id, userId));
|
||||
.insert(user)
|
||||
.values({
|
||||
id: userId,
|
||||
name: deriveUserName(userEmail),
|
||||
email: userEmail,
|
||||
emailVerified: true,
|
||||
})
|
||||
.onConflictDoNothing({ target: user.id });
|
||||
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
return existingUser.email;
|
||||
if (existing.email !== userEmail) {
|
||||
await db
|
||||
.update(user)
|
||||
.set({ email: userEmail, name: deriveUserName(userEmail) })
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
return existing.email;
|
||||
}
|
||||
|
||||
export async function resolveDelegatedContext(
|
||||
|
||||
@ -29,6 +29,7 @@ import { Route as AppAiRouteImport } from './routes/_app/ai'
|
||||
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
|
||||
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||
import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback'
|
||||
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
|
||||
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
||||
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
||||
@ -144,6 +145,11 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
path: '/api/auth/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({
|
||||
id: '/api/gsc/oauth/callback',
|
||||
path: '/api/gsc/oauth/callback',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
|
||||
id: '/help/dataforseo-api-key',
|
||||
path: '/help/dataforseo-api-key',
|
||||
@ -254,6 +260,7 @@ export interface FileRoutesByFullPath {
|
||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
@ -287,6 +294,7 @@ export interface FileRoutesByTo {
|
||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||
@ -324,6 +332,7 @@ export interface FileRoutesById {
|
||||
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
@ -360,6 +369,7 @@ export interface FileRouteTypes {
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/api/gsc/oauth/callback'
|
||||
| '/p/$projectId/audit'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/brand-lookup'
|
||||
@ -393,6 +403,7 @@ export interface FileRouteTypes {
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/api/gsc/oauth/callback'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/brand-lookup'
|
||||
| '/p/$projectId/domain'
|
||||
@ -429,6 +440,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/api/gsc/oauth/callback'
|
||||
| '/_project/p/$projectId/audit'
|
||||
| '/_project/p/$projectId/backlinks'
|
||||
| '/_project/p/$projectId/brand-lookup'
|
||||
@ -456,6 +468,7 @@ export interface RootRouteChildren {
|
||||
Char91DotwellKnownChar93OpenaiAppsChallengeRoute: typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
||||
ApiGscOauthCallbackRoute: typeof ApiGscOauthCallbackRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@ -600,6 +613,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/gsc/oauth/callback': {
|
||||
id: '/api/gsc/oauth/callback'
|
||||
path: '/api/gsc/oauth/callback'
|
||||
fullPath: '/api/gsc/oauth/callback'
|
||||
preLoaderRoute: typeof ApiGscOauthCallbackRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_app/help/dataforseo-api-key': {
|
||||
id: '/_app/help/dataforseo-api-key'
|
||||
path: '/help/dataforseo-api-key'
|
||||
@ -857,6 +877,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
Char91DotwellKnownChar93OpenaiAppsChallengeRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
||||
ApiGscOauthCallbackRoute: ApiGscOauthCallbackRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
62
src/routes/api/gsc/oauth/callback.ts
Normal file
62
src/routes/api/gsc/oauth/callback.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
|
||||
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
|
||||
import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { handleSelfHostedGscOAuthCallback } from "@/server/features/gsc/selfHostedOAuth";
|
||||
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
||||
|
||||
async function resolveSelfHostedContext(request: Request) {
|
||||
const authMode = getAuthMode(env.AUTH_MODE);
|
||||
|
||||
if (isHostedAuthMode(authMode)) return null;
|
||||
|
||||
return authMode === "local_noauth"
|
||||
? resolveLocalNoAuthContext()
|
||||
: resolveCloudflareAccessContext(request.headers);
|
||||
}
|
||||
|
||||
function responseForError(error: unknown) {
|
||||
if (error instanceof AppError) {
|
||||
const status =
|
||||
error.code === "UNAUTHENTICATED"
|
||||
? 401
|
||||
: error.code === "FORBIDDEN"
|
||||
? 403
|
||||
: error.code === "VALIDATION_ERROR"
|
||||
? 400
|
||||
: 500;
|
||||
return new Response(error.message, { status });
|
||||
}
|
||||
|
||||
return new Response("Search Console OAuth failed", { status: 500 });
|
||||
}
|
||||
|
||||
async function handleCallbackRequest(request: Request) {
|
||||
try {
|
||||
const context = await resolveSelfHostedContext(request);
|
||||
if (!context) return new Response("Not found", { status: 404 });
|
||||
|
||||
return await handleSelfHostedGscOAuthCallback({
|
||||
request,
|
||||
user: {
|
||||
userId: context.userId,
|
||||
userEmail: context.userEmail,
|
||||
},
|
||||
publicOrigin: getPublicOrigin(request),
|
||||
});
|
||||
} catch (error) {
|
||||
return responseForError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/gsc/oauth/callback")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
return handleCallbackRequest(request);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
28
src/server/features/gsc/oauth-config.ts
Normal file
28
src/server/features/gsc/oauth-config.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { getOptionalEnvValue } from "@/server/lib/runtime-env";
|
||||
|
||||
type GscOAuthClientConfig = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
export async function getGscOAuthClientConfig(): Promise<GscOAuthClientConfig | null> {
|
||||
const clientId = (await getOptionalEnvValue("GOOGLE_CLIENT_ID"))?.trim();
|
||||
const clientSecret = (
|
||||
await getOptionalEnvValue("GOOGLE_CLIENT_SECRET")
|
||||
)?.trim();
|
||||
|
||||
if (!clientId || !clientSecret) return null;
|
||||
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
// Self-hosted Search Console needs the Google OAuth client AND BETTER_AUTH_SECRET
|
||||
// (>=32 chars): the secret keys OAuth-token encryption and lets us build the
|
||||
// Better Auth instance that mints/refreshes tokens. Both must be set before we
|
||||
// surface the connect flow.
|
||||
export async function hasSelfHostedGscConfig(): Promise<boolean> {
|
||||
if (!(await getGscOAuthClientConfig())) return false;
|
||||
|
||||
const secret = (await getOptionalEnvValue("BETTER_AUTH_SECRET"))?.trim();
|
||||
return Boolean(secret && secret.length >= 32);
|
||||
}
|
||||
334
src/server/features/gsc/selfHostedOAuth.ts
Normal file
334
src/server/features/gsc/selfHostedOAuth.ts
Normal file
@ -0,0 +1,334 @@
|
||||
import { symmetricEncrypt } from "better-auth/crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { decodeJwt } from "jose";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { account } from "@/db/schema";
|
||||
import { getAuth } from "@/lib/auth";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { GSC_OAUTH_PROVIDER_ID, GSC_OAUTH_SCOPES } from "@/shared/gsc";
|
||||
import {
|
||||
getGscOAuthClientConfig,
|
||||
hasSelfHostedGscConfig,
|
||||
} from "./oauth-config";
|
||||
|
||||
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
|
||||
type SelfHostedGscUser = {
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
const oauthStateSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
callbackPath: z.string().min(1),
|
||||
exp: z.number().int(),
|
||||
});
|
||||
|
||||
const googleTokenResponseSchema = z.object({
|
||||
access_token: z.string().min(1),
|
||||
expires_in: z.number().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
id_token: z.string().optional(),
|
||||
token_type: z.string().optional(),
|
||||
});
|
||||
|
||||
const googleIdTokenSchema = z.object({
|
||||
sub: z.string().min(1),
|
||||
});
|
||||
|
||||
type GoogleTokenResponse = z.infer<typeof googleTokenResponseSchema>;
|
||||
|
||||
function bytesToBase64Url(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replaceAll("=", "");
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value: string) {
|
||||
const padded = `${value}${"=".repeat((4 - (value.length % 4)) % 4)}`;
|
||||
const binary = atob(padded.replaceAll("-", "+").replaceAll("_", "/"));
|
||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function getStateKey(clientSecret: string) {
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(`openseo:gsc:${clientSecret}`),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
}
|
||||
|
||||
async function signState(payload: string, clientSecret: string) {
|
||||
const signature = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
await getStateKey(clientSecret),
|
||||
new TextEncoder().encode(payload),
|
||||
);
|
||||
return bytesToBase64Url(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
function getSafeCallbackPath(callbackURL: string, publicOrigin: string) {
|
||||
try {
|
||||
const url = new URL(callbackURL, publicOrigin);
|
||||
if (url.origin !== publicOrigin) return "/";
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
|
||||
async function createState(input: {
|
||||
clientSecret: string;
|
||||
userId: string;
|
||||
callbackURL: string;
|
||||
publicOrigin: string;
|
||||
}) {
|
||||
const payload = bytesToBase64Url(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
userId: input.userId,
|
||||
callbackPath: getSafeCallbackPath(
|
||||
input.callbackURL,
|
||||
input.publicOrigin,
|
||||
),
|
||||
exp: Date.now() + 10 * 60 * 1_000,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const signature = await signState(payload, input.clientSecret);
|
||||
return `${payload}.${signature}`;
|
||||
}
|
||||
|
||||
async function verifyState(state: string, clientSecret: string) {
|
||||
const [payload, signature] = state.split(".");
|
||||
if (!payload || !signature) {
|
||||
throw new AppError("VALIDATION_ERROR", "Invalid Search Console state");
|
||||
}
|
||||
|
||||
const ok = await crypto.subtle.verify(
|
||||
"HMAC",
|
||||
await getStateKey(clientSecret),
|
||||
base64UrlToBytes(signature),
|
||||
new TextEncoder().encode(payload),
|
||||
);
|
||||
if (!ok) {
|
||||
throw new AppError("VALIDATION_ERROR", "Invalid Search Console state");
|
||||
}
|
||||
|
||||
const parsed = oauthStateSchema.parse(
|
||||
JSON.parse(new TextDecoder().decode(base64UrlToBytes(payload))),
|
||||
);
|
||||
if (parsed.exp < Date.now()) {
|
||||
throw new AppError("VALIDATION_ERROR", "Expired Search Console state");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getRedirectUri(publicOrigin: string) {
|
||||
return `${publicOrigin}/api/gsc/oauth/callback`;
|
||||
}
|
||||
|
||||
function accessTokenExpiresAt(tokens: GoogleTokenResponse) {
|
||||
return new Date(Date.now() + (tokens.expires_in ?? 3600) * 1_000);
|
||||
}
|
||||
|
||||
function storedScope(tokens: GoogleTokenResponse) {
|
||||
return tokens.scope
|
||||
? tokens.scope.trim().split(/\s+/).join(",")
|
||||
: GSC_OAUTH_SCOPES.join(",");
|
||||
}
|
||||
|
||||
function getGoogleAccountId(tokens: GoogleTokenResponse) {
|
||||
if (!tokens.id_token) {
|
||||
throw new AppError(
|
||||
"VALIDATION_ERROR",
|
||||
"Google did not return an ID token for Search Console.",
|
||||
);
|
||||
}
|
||||
|
||||
return googleIdTokenSchema.parse(decodeJwt(tokens.id_token)).sub;
|
||||
}
|
||||
|
||||
async function upsertGrant(input: {
|
||||
user: SelfHostedGscUser;
|
||||
tokens: GoogleTokenResponse;
|
||||
}) {
|
||||
// Encrypt tokens at rest exactly the way Better Auth's setTokenUtil does
|
||||
// (same key from BETTER_AUTH_SECRET, same crypto, same encryptOAuthTokens
|
||||
// gate), so getAccessToken decrypts them on read — and so flipping the flag
|
||||
// can never desync the write and read paths.
|
||||
const ctx = await getAuth().$context;
|
||||
const encrypt = (value: string) =>
|
||||
ctx.options.account?.encryptOAuthTokens
|
||||
? symmetricEncrypt({ key: ctx.secretConfig, data: value })
|
||||
: value;
|
||||
|
||||
const existing = await db
|
||||
.select({ id: account.id, refreshToken: account.refreshToken })
|
||||
.from(account)
|
||||
.where(
|
||||
and(
|
||||
eq(account.userId, input.user.userId),
|
||||
eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const accountValues = {
|
||||
accountId: getGoogleAccountId(input.tokens),
|
||||
providerId: GSC_OAUTH_PROVIDER_ID,
|
||||
userId: input.user.userId,
|
||||
accessToken: await encrypt(input.tokens.access_token),
|
||||
// A fresh refresh token is encrypted here; an absent one falls back to the
|
||||
// already-encrypted value stored on the existing grant.
|
||||
refreshToken: input.tokens.refresh_token
|
||||
? await encrypt(input.tokens.refresh_token)
|
||||
: (existing[0]?.refreshToken ?? null),
|
||||
idToken: input.tokens.id_token
|
||||
? await encrypt(input.tokens.id_token)
|
||||
: null,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt(input.tokens),
|
||||
refreshTokenExpiresAt: null,
|
||||
scope: storedScope(input.tokens),
|
||||
password: null,
|
||||
};
|
||||
|
||||
if (existing[0]) {
|
||||
await db
|
||||
.update(account)
|
||||
.set({ ...accountValues, updatedAt: new Date() })
|
||||
.where(eq(account.id, existing[0].id));
|
||||
return;
|
||||
}
|
||||
|
||||
await db.insert(account).values({
|
||||
id: crypto.randomUUID(),
|
||||
...accountValues,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async function exchangeCode(input: {
|
||||
code: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
}) {
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
code: input.code,
|
||||
client_id: input.clientId,
|
||||
client_secret: input.clientSecret,
|
||||
redirect_uri: input.redirectUri,
|
||||
grant_type: "authorization_code",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AppError(
|
||||
"VALIDATION_ERROR",
|
||||
"Google rejected the Search Console authorization code.",
|
||||
);
|
||||
}
|
||||
|
||||
return googleTokenResponseSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
export async function createSelfHostedGscAuthorizationUrl(input: {
|
||||
user: SelfHostedGscUser;
|
||||
callbackURL: string;
|
||||
publicOrigin: string;
|
||||
}) {
|
||||
const config = await getGscOAuthClientConfig();
|
||||
if (!config || !(await hasSelfHostedGscConfig())) {
|
||||
throw new AppError(
|
||||
"AUTH_CONFIG_MISSING",
|
||||
"Search Console is not configured. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET.",
|
||||
);
|
||||
}
|
||||
|
||||
const redirectUri = getRedirectUri(input.publicOrigin);
|
||||
const state = await createState({
|
||||
clientSecret: config.clientSecret,
|
||||
userId: input.user.userId,
|
||||
callbackURL: input.callbackURL,
|
||||
publicOrigin: input.publicOrigin,
|
||||
});
|
||||
const url = new URL(GOOGLE_AUTH_URL);
|
||||
url.searchParams.set("client_id", config.clientId);
|
||||
url.searchParams.set("redirect_uri", redirectUri);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", GSC_OAUTH_SCOPES.join(" "));
|
||||
url.searchParams.set("access_type", "offline");
|
||||
url.searchParams.set("prompt", "consent");
|
||||
url.searchParams.set("state", state);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function handleSelfHostedGscOAuthCallback(input: {
|
||||
request: Request;
|
||||
user: SelfHostedGscUser;
|
||||
publicOrigin: string;
|
||||
}) {
|
||||
const config = await getGscOAuthClientConfig();
|
||||
if (!config) {
|
||||
return new Response("Missing Google Search Console OAuth configuration", {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(input.request.url);
|
||||
const stateParam = url.searchParams.get("state");
|
||||
if (!stateParam) {
|
||||
return new Response("Missing Search Console OAuth state", { status: 400 });
|
||||
}
|
||||
|
||||
const state = await verifyState(stateParam, config.clientSecret);
|
||||
if (state.userId !== input.user.userId) {
|
||||
return new Response("Search Console OAuth user mismatch", { status: 403 });
|
||||
}
|
||||
|
||||
// state.callbackPath is a validated same-origin relative path
|
||||
// (getSafeCallbackPath). Redirect with a *relative* Location so the browser
|
||||
// resolves it against the real request origin — this avoids trusting
|
||||
// x-forwarded-host for the final hop.
|
||||
const redirectToCallback = () =>
|
||||
new Response(null, {
|
||||
status: 303,
|
||||
headers: { Location: state.callbackPath },
|
||||
});
|
||||
|
||||
if (url.searchParams.get("error")) {
|
||||
return redirectToCallback();
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) {
|
||||
return new Response("Missing Search Console OAuth code", { status: 400 });
|
||||
}
|
||||
|
||||
const tokens = await exchangeCode({
|
||||
code,
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
redirectUri: getRedirectUri(input.publicOrigin),
|
||||
});
|
||||
await upsertGrant({ user: input.user, tokens });
|
||||
|
||||
return redirectToCallback();
|
||||
}
|
||||
@ -103,8 +103,10 @@ export function createGscClient(opts: { userId: string }) {
|
||||
async function getToken(): Promise<string> {
|
||||
let result: { accessToken?: string } | undefined;
|
||||
try {
|
||||
// Headerless call: getAccessToken trusts body.userId only when no request
|
||||
// Headerless call: getAccessToken trusts body.userId when no request
|
||||
// session is present, and auto-refreshes via the genericOAuth provider.
|
||||
// Works in every auth mode — self-hosted builds the same Better Auth
|
||||
// instance once BETTER_AUTH_SECRET is set.
|
||||
result = await getAuth().api.getAccessToken({
|
||||
body: { providerId: GSC_OAUTH_PROVIDER_ID, userId: opts.userId },
|
||||
});
|
||||
|
||||
@ -2,7 +2,9 @@ import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||
|
||||
let workersEnvPromise: Promise<Record<string, unknown> | null> | null = null;
|
||||
|
||||
async function getEnvValue(name: string): Promise<string | undefined> {
|
||||
export async function getOptionalEnvValue(
|
||||
name: string,
|
||||
): Promise<string | undefined> {
|
||||
const processValue =
|
||||
typeof process !== "undefined" ? process.env?.[name] : undefined;
|
||||
if (processValue) {
|
||||
@ -15,7 +17,7 @@ async function getEnvValue(name: string): Promise<string | undefined> {
|
||||
}
|
||||
|
||||
export async function getRequiredEnvValue(name: string): Promise<string> {
|
||||
const value = await getEnvValue(name);
|
||||
const value = await getOptionalEnvValue(name);
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
@ -23,7 +25,7 @@ export async function getRequiredEnvValue(name: string): Promise<string> {
|
||||
}
|
||||
|
||||
export async function isHostedServerAuthMode(): Promise<boolean> {
|
||||
return isHostedAuthMode(await getEnvValue("AUTH_MODE"));
|
||||
return isHostedAuthMode(await getOptionalEnvValue("AUTH_MODE"));
|
||||
}
|
||||
|
||||
async function getWorkersEnv(): Promise<Record<string, unknown> | null> {
|
||||
|
||||
@ -6,6 +6,7 @@ import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getProjectForOrganization: vi.fn(),
|
||||
isHostedServerAuthMode: vi.fn(),
|
||||
hasSelfHostedGscConfig: vi.fn(),
|
||||
GscService: {
|
||||
getPerformance: vi.fn(),
|
||||
inspectUrls: vi.fn(),
|
||||
@ -33,6 +34,9 @@ vi.mock("cloudflare:workers", () => ({ env: {} }));
|
||||
vi.mock("@/server/lib/runtime-env", () => ({
|
||||
isHostedServerAuthMode: mocks.isHostedServerAuthMode,
|
||||
}));
|
||||
vi.mock("@/server/features/gsc/oauth-config", () => ({
|
||||
hasSelfHostedGscConfig: mocks.hasSelfHostedGscConfig,
|
||||
}));
|
||||
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
||||
ProjectService: {
|
||||
getProjectForOrganization: mocks.getProjectForOrganization,
|
||||
@ -75,6 +79,8 @@ describe("search console MCP tools", () => {
|
||||
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
|
||||
mocks.isHostedServerAuthMode.mockReset();
|
||||
mocks.isHostedServerAuthMode.mockResolvedValue(true);
|
||||
mocks.hasSelfHostedGscConfig.mockReset();
|
||||
mocks.hasSelfHostedGscConfig.mockResolvedValue(false);
|
||||
mocks.GscService.getPerformance.mockReset();
|
||||
mocks.GscService.inspectUrls.mockReset();
|
||||
});
|
||||
@ -211,8 +217,9 @@ describe("search console MCP tools", () => {
|
||||
expect(mocks.GscService.getPerformance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a hosted-only message in self-hosted mode", async () => {
|
||||
it("returns a setup message in self-hosted mode without a Google client", async () => {
|
||||
mocks.isHostedServerAuthMode.mockResolvedValue(false);
|
||||
mocks.hasSelfHostedGscConfig.mockResolvedValue(false);
|
||||
const { getSearchConsolePerformanceTool } =
|
||||
await import("./search-console-tools");
|
||||
|
||||
@ -221,10 +228,40 @@ describe("search console MCP tools", () => {
|
||||
toolExtra,
|
||||
);
|
||||
|
||||
expect(result.structuredContent).toMatchObject({ reason: "hosted_only" });
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
reason: "gsc_oauth_not_configured",
|
||||
});
|
||||
expect(mocks.GscService.getPerformance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows performance queries in self-hosted mode with a Google client", async () => {
|
||||
mocks.isHostedServerAuthMode.mockResolvedValue(false);
|
||||
mocks.hasSelfHostedGscConfig.mockResolvedValue(true);
|
||||
mocks.GscService.getPerformance.mockResolvedValue({
|
||||
siteUrl: "https://example.com/",
|
||||
connectedBy: "alice@example.com",
|
||||
request: {
|
||||
dimensions: ["query"],
|
||||
startDate: "2026-04-27",
|
||||
endDate: "2026-05-25",
|
||||
rowLimit: 1000,
|
||||
},
|
||||
rows: [],
|
||||
});
|
||||
const { getSearchConsolePerformanceTool } =
|
||||
await import("./search-console-tools");
|
||||
|
||||
const result = await getSearchConsolePerformanceTool.handler(
|
||||
{ projectId: "project_1" },
|
||||
toolExtra,
|
||||
);
|
||||
|
||||
expect(mocks.GscService.getPerformance).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectId: "project_1" }),
|
||||
);
|
||||
expect(result.structuredContent).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("inspects multiple URLs and reports partial failures inline", async () => {
|
||||
mocks.GscService.inspectUrls.mockResolvedValue({
|
||||
siteUrl: "sc-domain:example.com",
|
||||
@ -285,8 +322,9 @@ describe("search console MCP tools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a hosted-only message for inspect_urls in self-hosted mode", async () => {
|
||||
it("returns a setup message for inspect_urls in self-hosted mode without a Google client", async () => {
|
||||
mocks.isHostedServerAuthMode.mockResolvedValue(false);
|
||||
mocks.hasSelfHostedGscConfig.mockResolvedValue(false);
|
||||
const { inspectUrlsTool } = await import("./search-console-tools");
|
||||
|
||||
const result = await inspectUrlsTool.handler(
|
||||
@ -294,7 +332,9 @@ describe("search console MCP tools", () => {
|
||||
toolExtra,
|
||||
);
|
||||
|
||||
expect(result.structuredContent).toMatchObject({ reason: "hosted_only" });
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
reason: "gsc_oauth_not_configured",
|
||||
});
|
||||
expect(mocks.GscService.inspectUrls).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,6 +6,7 @@ import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
|
||||
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
|
||||
import { projectIdSchema } from "@/server/mcp/schemas";
|
||||
import { buildDashboardUrl } from "@/server/mcp/urls";
|
||||
import { hasSelfHostedGscConfig } from "@/server/features/gsc/oauth-config";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
import {
|
||||
GscNotConnectedError,
|
||||
@ -21,6 +22,7 @@ import {
|
||||
type GscPerformanceInput,
|
||||
} from "@/server/features/gsc/searchAnalytics";
|
||||
import { GscApiError, GscTokenError } from "@/server/lib/gscClient";
|
||||
import { GSC_SELF_HOSTED_SETUP_DOCS_URL } from "@/shared/gsc";
|
||||
|
||||
const TEXT_SUMMARY_ROWS = 15;
|
||||
|
||||
@ -33,17 +35,28 @@ function integrationsUrl(baseUrl: string, projectId: string): string {
|
||||
return buildDashboardUrl(baseUrl, `/p/${projectId}/integrations`);
|
||||
}
|
||||
|
||||
/** GSC connect requires Better Auth, which only runs in hosted mode. In
|
||||
* self-hosted deployments the tools return this instead of a broken flow. */
|
||||
async function hostedOnlyResponse(
|
||||
/** Self-hosted GSC requires the operator to provide a Google OAuth client and
|
||||
* BETTER_AUTH_SECRET. Hosted mode always has both; self-hosted tools return this
|
||||
* setup nudge before attempting a token lookup when either is missing. */
|
||||
async function missingSelfHostedGoogleClientResponse(
|
||||
context: ProjectAuthContext,
|
||||
projectId: string,
|
||||
) {
|
||||
if (await isHostedServerAuthMode()) return null;
|
||||
const [hosted, configured] = await Promise.all([
|
||||
isHostedServerAuthMode(),
|
||||
hasSelfHostedGscConfig(),
|
||||
]);
|
||||
if (hosted || configured) return null;
|
||||
|
||||
return mcpResponse({
|
||||
text: "Google Search Console connect is only available on the hosted OpenSEO service, not in self-hosted mode. Use a GSC CSV export instead.",
|
||||
text: `This self-hosted OpenSEO deployment is not configured for Search Console yet. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET, then reconnect Search Console from Integrations. Setup docs: ${GSC_SELF_HOSTED_SETUP_DOCS_URL}`,
|
||||
meta: buildProjectMeta(context, projectId),
|
||||
structuredContent: { ok: false, connected: false, reason: "hosted_only" },
|
||||
structuredContent: {
|
||||
ok: false,
|
||||
connected: false,
|
||||
reason: "gsc_oauth_not_configured",
|
||||
setupDocsUrl: GSC_SELF_HOSTED_SETUP_DOCS_URL,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -147,6 +160,7 @@ export const getSearchConsolePerformanceTool = {
|
||||
ok: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
connectUrl: z.string().optional(),
|
||||
setupDocsUrl: z.string().optional(),
|
||||
siteUrl: z.string().optional(),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
@ -176,7 +190,10 @@ export const getSearchConsolePerformanceTool = {
|
||||
},
|
||||
},
|
||||
handler: withMcpProjectAuth(async (args: PerfArgs, context) => {
|
||||
const blocked = await hostedOnlyResponse(context, args.projectId);
|
||||
const blocked = await missingSelfHostedGoogleClientResponse(
|
||||
context,
|
||||
args.projectId,
|
||||
);
|
||||
if (blocked) return blocked;
|
||||
|
||||
const connectUrl = integrationsUrl(context.baseUrl, args.projectId);
|
||||
@ -292,6 +309,7 @@ export const inspectUrlsTool = {
|
||||
ok: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
connectUrl: z.string().optional(),
|
||||
setupDocsUrl: z.string().optional(),
|
||||
siteUrl: z.string().optional(),
|
||||
results: z
|
||||
.array(
|
||||
@ -313,7 +331,10 @@ export const inspectUrlsTool = {
|
||||
},
|
||||
},
|
||||
handler: withMcpProjectAuth(async (args: InspectArgs, context) => {
|
||||
const blocked = await hostedOnlyResponse(context, args.projectId);
|
||||
const blocked = await missingSelfHostedGoogleClientResponse(
|
||||
context,
|
||||
args.projectId,
|
||||
);
|
||||
if (blocked) return blocked;
|
||||
|
||||
const connectUrl = integrationsUrl(context.baseUrl, args.projectId);
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
import { waitUntil } from "cloudflare:workers";
|
||||
import { z } from "zod";
|
||||
import { GscService } from "@/server/features/gsc/services/GscService";
|
||||
import { hasSelfHostedGscConfig } from "@/server/features/gsc/oauth-config";
|
||||
import { createSelfHostedGscAuthorizationUrl } from "@/server/features/gsc/selfHostedOAuth";
|
||||
import { captureServerEvent } from "@/server/lib/posthog";
|
||||
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
import {
|
||||
requireAuthenticatedContext,
|
||||
requireProjectContext,
|
||||
@ -12,6 +17,9 @@ const projectScopedSchema = z.object({ projectId: z.string().min(1) });
|
||||
const setSiteSchema = projectScopedSchema.extend({
|
||||
siteUrl: z.string().min(1),
|
||||
});
|
||||
const startSelfHostedLinkSchema = z.object({
|
||||
callbackURL: z.string().min(1),
|
||||
});
|
||||
|
||||
// Account-level grant check (no project needed) for surfaces like onboarding
|
||||
// where the user hasn't picked a project yet. The OAuth grant is per-account;
|
||||
@ -26,13 +34,17 @@ export const getGscConnection = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => projectScopedSchema.parse(data))
|
||||
.handler(async ({ context }) => {
|
||||
const [connection, currentUserHasGrant] = await Promise.all([
|
||||
const [connection, currentUserHasGrant, hosted, gscConfigured] =
|
||||
await Promise.all([
|
||||
GscService.getConnection(context.projectId),
|
||||
GscService.userHasGrant(context.userId),
|
||||
isHostedServerAuthMode(),
|
||||
hasSelfHostedGscConfig(),
|
||||
]);
|
||||
return {
|
||||
connected: Boolean(connection),
|
||||
currentUserHasGrant,
|
||||
googleOAuthConfigured: hosted || gscConfigured,
|
||||
siteUrl: connection?.siteUrl ?? null,
|
||||
connectedByEmail: connection?.connectedAccountEmail ?? null,
|
||||
connectedAt: connection?.createdAt ?? null,
|
||||
@ -97,3 +109,20 @@ export const disconnectGsc = createServerFn({ method: "POST" })
|
||||
);
|
||||
return { connected: false as const };
|
||||
});
|
||||
|
||||
export const startSelfHostedGscLink = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.inputValidator((data: unknown) => startSelfHostedLinkSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
const publicOrigin = getPublicOrigin(getRequest());
|
||||
const url = await createSelfHostedGscAuthorizationUrl({
|
||||
user: {
|
||||
userId: context.userId,
|
||||
userEmail: context.userEmail,
|
||||
},
|
||||
callbackURL: data.callbackURL,
|
||||
publicOrigin,
|
||||
});
|
||||
|
||||
return { url };
|
||||
});
|
||||
|
||||
@ -29,7 +29,7 @@ export const getOnboardingAnswers = createServerFn({ method: "GET" })
|
||||
},
|
||||
where: eq(userOnboardingAnswers.userId, context.userId),
|
||||
});
|
||||
const hostedUser = await db.query.user.findFirst({
|
||||
const userRecord = await db.query.user.findFirst({
|
||||
columns: {
|
||||
createdAt: true,
|
||||
},
|
||||
@ -53,7 +53,7 @@ export const getOnboardingAnswers = createServerFn({ method: "GET" })
|
||||
return {
|
||||
completedAt: answers?.completedAt ?? null,
|
||||
gscNudgeDismissedAt: answers?.gscNudgeDismissedAt ?? null,
|
||||
userCreatedAt: hostedUser?.createdAt?.toISOString() ?? null,
|
||||
userCreatedAt: userRecord?.createdAt?.toISOString() ?? null,
|
||||
answers: {
|
||||
interestedFeatures,
|
||||
workFor: answers?.workFor ?? null,
|
||||
|
||||
@ -2,3 +2,13 @@
|
||||
* Kept in `shared` so both server (auth config, GSC client) and client (connect
|
||||
* button) can reference it without importing the server-only auth config. */
|
||||
export const GSC_OAUTH_PROVIDER_ID = "google-search-console";
|
||||
|
||||
export const GSC_OAUTH_SCOPES = [
|
||||
"openid",
|
||||
"email",
|
||||
"profile",
|
||||
"https://www.googleapis.com/auth/webmasters.readonly",
|
||||
] as const;
|
||||
|
||||
export const GSC_SELF_HOSTED_SETUP_DOCS_URL =
|
||||
"https://github.com/every-app/open-seo/blob/main/docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user