Skip to content

Commit

Permalink
fix: Add safety checks and fix secret handling in SEO Audit plugin (#5)
Browse files Browse the repository at this point in the history
* Add safety checks and fix secret handling in SEO Audit plugin

* Add rel="noreferrer"
  • Loading branch information
endtwist committed Sep 4, 2024
1 parent 00e327c commit 37ca9ac
Show file tree
Hide file tree
Showing 5 changed files with 137 additions and 40 deletions.
58 changes: 52 additions & 6 deletions src/SEOAudit/MissingSecretError.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import {Card, Code, Text} from '@sanity/ui'
import {Button, Card, Code, Text} from '@sanity/ui'

Check warning on line 1 in src/SEOAudit/MissingSecretError.tsx

View workflow job for this annotation

GitHub Actions / Lint & Build

Run autofix to sort these imports!
import {FC, useState} from 'react'
import {SettingsView} from '@sanity/studio-secrets'

import {pluginConfigKeys} from '../utils/configKeys'

export const MissingSecretError: FC<{secretsNamespace: string}> = ({secretsNamespace}) => {
const [showSettings, setShowSettings] = useState(false)

export const MissingSecretError = () => {
return (
<Card
padding={[3, 3, 4]}
Expand Down Expand Up @@ -45,15 +51,55 @@ export const MissingSecretError = () => {
size={1}
style={{
display: 'block',
marginBlock: '1em',
padding: '1em',
margin: '0 auto',
maxWidth: '50em',
}}
>
Please add it using{' '}
<a href="https://github.com/sanity-io/sanity-studio-secrets">
sanity-studio-secrets plugin
To use the SEO Audit plugin, you will need to sign up for a{' '}
<a href="http://dataforseo.com/" target="_blank" rel="noreferrer">
DataForSEO
</a>{' '}
account and obtain an API key. These checks are run using the{' '}
<a
href="https://docs.dataforseo.com/v3/on_page/instant_pages"
target="_blank"
rel="noreferrer"
>
Instant Pages (Live)
</a>{' '}
endpoint. Pricing can be{' '}
<a
href="https://dataforseo.com/help-center/cost-of-onpage-api-parameters"
target="_blank"
rel="noreferrer"
>
found here
</a>
.
</Text>

<Text
align="center"
size={1}
style={{
display: 'block',
marginBlock: '1em',
}}
>
<Button onClick={() => setShowSettings(true)}>Add Secret</Button>

Check warning on line 90 in src/SEOAudit/MissingSecretError.tsx

View workflow job for this annotation

GitHub Actions / Lint & Build

JSX props should not use arrow functions
</Text>

{showSettings && (
<SettingsView
title="Preflight: SEO Audit API Key"
namespace={secretsNamespace}
keys={pluginConfigKeys}
onClose={() => {

Check warning on line 98 in src/SEOAudit/MissingSecretError.tsx

View workflow job for this annotation

GitHub Actions / Lint & Build

JSX props should not use arrow functions
setShowSettings(false)
}}
/>
)}
</Card>
)
}
22 changes: 6 additions & 16 deletions src/SEOAudit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,15 @@ These checks are run using the [Instant Pages (Live)](https://docs.dataforseo.co

Usage of this service requires registration and creation of an API key, however usage with placeholder/sandbox data doesn't require payment.

To create and set an API key, [follow the instructions here](https://docs.dataforseo.com/v3/auth/).
To create and get an API key, [follow the instructions here](https://docs.dataforseo.com/v3/auth/).

For security, secrets are managed using the [`studio-secrets` plugin](https://github.com/sanity-io/sanity-studio-secrets), since Sanity Studio is a client-side application
and does not have server-side capabilities to securely store secrets.
For security, secrets are managed using the [`studio-secrets` plugin](https://github.com/sanity-io/sanity-studio-secrets), since Sanity Studio is a client-side application and does not have server-side capabilities to securely store secrets.

Follow the instructions in the `studio-secrets` plugin to create a new secret named `DATA_FOR_SEO_API_KEY`.

This API key only needs to be set once by the project administrator, and will be shared by all content editors.
You will set the secrets in the plugin, prior to first audit. This API key only needs to be set once by the project administrator, and will be shared by all content editors.

### Secrets Namespace

The secrets namespace used can be configured by passing a `secretsNamespace` option to the `Preflight` plugin.
The secrets namespace used can be, optionally, configured by passing a `secretsNamespace` option to the `Preflight` plugin.

By default the plugin will look for it under the `SANITY_SECRETS` namespace.

Expand Down Expand Up @@ -90,7 +87,6 @@ First we will configure Next.js to serve draft articles for requests coming in f
// route handler with secret and slug
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { seoAuditIPs } from '@planetary/sanity-plugin-preflight'

export async function GET(request: Request) {
// Parse query string parameters
Expand All @@ -100,13 +96,7 @@ export async function GET(request: Request) {

// Check the secret and next parameters
// This secret should only be known to this route handler and the CMS
if (
!slug ||
(
secret !== 'MY_SECRET_TOKEN' ||
!seoAuditIPs.includes(request.headers.get('x-vercel-forwarded-for')
)
) {
if (!slug || secret !== process.env.YOUR_PREVIEW_TOKEN) {
return new Response('Invalid token', { status: 401 })
}

Expand Down Expand Up @@ -138,7 +128,7 @@ Preflight({
baseUrl: 'https://example.org',
getDocumentSlug: (document, isDraft) => {
if (isDraft) {
return `/draft/route?slug=${document.slug.current}`
return `/draft/route?slug=${document.slug.current}&secret=${YOUR_PREVIEW_TOKEN}`
}

return document.slug.current
Expand Down
53 changes: 47 additions & 6 deletions src/SEOAudit/SEOAuditResults.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {RefreshIcon} from '@sanity/icons'

Check warning on line 1 in src/SEOAudit/SEOAuditResults.tsx

View workflow job for this annotation

GitHub Actions / Lint & Build

Run autofix to sort these imports!
import {Card, Code, Flex, Spinner, Text} from '@sanity/ui'
import {Button, Card, Code, Flex, Spinner, Text} from '@sanity/ui'
import {useCallback, useState} from 'react'
import {SettingsView} from '@sanity/studio-secrets'

import {SectionHeader} from '../Preflight/SectionHeader'
import {SEOAuditConfig} from '.'
Expand All @@ -9,16 +10,24 @@ import {InitialLoadMessage} from './InitialLoadMessage'
import {SEOAuditChecks} from './SEOAuditTypes'
import {getPageLiveResult} from './seoClient'
import {defaultSeoValidationRules, isRuleEnabled, isValidationRule} from './validations'
import {pluginConfigKeys} from '../utils/configKeys'

type Props = SEOAuditConfig & {
apiKey: string
publicUrl: string
}

export const SEOAuditResults = ({publicUrl, apiKey, rules}: Props): JSX.Element => {
const isLocalhost = publicUrl?.includes('localhost') || publicUrl?.includes('127.0.0')

const [isLoading, setIsLoading] = useState(true)
export const SEOAuditResults = ({
baseUrl,
publicUrl,
apiKey,
rules,
secretsNamespace,
}: Props): JSX.Element => {
const isLocalhost = !!baseUrl && (baseUrl?.includes('localhost') || baseUrl?.includes('127.0.0'))

const [showSettings, setShowSettings] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [results, setResults] = useState<Partial<SEOAuditChecks>>()
const [errorMessage, setErrorMessage] = useState<string | null>(null)

Expand All @@ -30,7 +39,11 @@ export const SEOAuditResults = ({publicUrl, apiKey, rules}: Props): JSX.Element
setIsLoading(true)

try {
const liveResults = await getPageLiveResult(apiKey)(publicUrl, isLocalhost)
const liveResults = await getPageLiveResult(apiKey)(
`${isLocalhost ? 'https://example.org' : baseUrl}/${publicUrl}`,
isLocalhost,
)

const checks: SEOAuditChecks | undefined =
liveResults?.tasks?.[0]?.result?.[0]?.items?.[0]?.checks

Expand Down Expand Up @@ -137,6 +150,34 @@ export const SEOAuditResults = ({publicUrl, apiKey, rules}: Props): JSX.Element
))}
</div>
)}

<div
style={{
display: 'flex',
justifyContent: 'flex-end',
margin: '1rem 0 0',
}}
>
<Button
onClick={() => setShowSettings(true)}

Check warning on line 162 in src/SEOAudit/SEOAuditResults.tsx

View workflow job for this annotation

GitHub Actions / Lint & Build

JSX props should not use arrow functions
style={{marginLeft: 'auto'}}
text="Update Secret"
tone="default"
mode="ghost"
fontSize={1}
/>
</div>

{showSettings && (
<SettingsView
title="Preflight: SEO Audit API Key"
namespace={secretsNamespace}
keys={pluginConfigKeys}
onClose={() => {
setShowSettings(false)
}}
/>
)}
</>
)
}
34 changes: 22 additions & 12 deletions src/SEOAudit/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export type SEOAuditConfig = {
* A function that returns the URL to check.
* Receives the active Sanity document, draft status, and baseUrl as an arguments.
*/
getDocumentSlug?: (document: Partial<SanityDocument>) => string
getDocumentSlug?: (document: Partial<SanityDocument>, isDraft?: boolean) => string

/**
* The Sanity schema field name containing the array of content blocks in the document.
Expand All @@ -46,14 +46,14 @@ type Props = UserViewComponent['defaultProps']

const Extracted = ({config, document}: {config: SEOAuditConfig} & Props) => {
const {secrets} = useSecrets<Record<string, string>>(config.secretsNamespace)
const API_KEY = secrets?.DATA_FOR_SEO_API_KEY
const API_KEY = btoa(`${secrets?.DATA_FOR_SEO_API_LOGIN}:${secrets?.DATA_FOR_SEO_API_PASSWORD}`)

if (!API_KEY) {
if (!secrets?.DATA_FOR_SEO_API_LOGIN) {
return (
<div>
<SectionHeader title="SEO Audit" />

<MissingSecretError />
<MissingSecretError secretsNamespace={config.secretsNamespace} />
</div>
)
}
Expand All @@ -67,17 +67,23 @@ const Extracted = ({config, document}: {config: SEOAuditConfig} & Props) => {
)
}

const baseUrl =
config.baseUrl ?? process.env.SANITY_STUDIO_SERVER_HOSTNAME ?? window.location.origin
const isDraft = document.displayed._id?.startsWith('drafts.')
const slug =
config.getDocumentSlug?.(document.displayed) ??
new URL(
(document.displayed.slug as unknown as {current: string}).current as unknown as string,
baseUrl,
).toString()
config.getDocumentSlug?.(document.displayed, isDraft) ??
((document.displayed.slug as unknown as {current: string})?.current as unknown as string)

if (!slug) {
return (
<div>
<SectionHeader title="SEO Audit" />
<p>No slug found</p>
</div>
)
}

return <SEOAuditResults {...config} apiKey={API_KEY} publicUrl={slug} />
}

/**
* A plugin that checks the status of all links in a document,
* ensuring that they are reachable.
Expand All @@ -89,7 +95,11 @@ const defaultConfig: SEOAuditConfig = {
rules: defaultSeoValidationRules,
}

export const SEOAudit = (config: SEOAuditConfig = defaultConfig): PreflightPlugin => {
export const SEOAudit = (
config: Pick<SEOAuditConfig, 'rules' | 'baseUrl' | 'getDocumentSlug'> & {
secretsNamespace?: string
} = defaultConfig,
): PreflightPlugin => {
const WithConfigWrapper = (props: Props) => {
return (
<SEOAuditPlugin
Expand Down
10 changes: 10 additions & 0 deletions src/utils/configKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const pluginConfigKeys = [
{
key: 'DATA_FOR_SEO_API_LOGIN',
title: 'DataForSEO API Login',
},
{
key: 'DATA_FOR_SEO_API_PASSWORD',
title: 'DataForSEO API Password',
},
]

0 comments on commit 37ca9ac

Please sign in to comment.