Skip to content

Commit

Permalink
Add basic auth for editor endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
mruwnik committed Jul 11, 2024
1 parent f0c2d19 commit 3f27b92
Show file tree
Hide file tree
Showing 9 changed files with 126 additions and 26 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ jobs:
| sed s/{MATOMO_DOMAIN}/${{ secrets.MATOMO_DOMAIN }}/ \
| sed s/{DISCORD_LOGGING_CHANNEL_ID}/${{ secrets.DISCORD_LOGGING_CHANNEL_ID }}/ \
| sed s/{DISCORD_LOGGING_TOKEN}/${{ secrets.DISCORD_LOGGING_TOKEN }}/ \
| sed s/{EDITOR_USERNAME}/${{ secrets.EDITOR_USERNAME }}/ \
| sed s/{EDITOR_PASSWORD}/${{ secrets.EDITOR_PASSWORD }}/ \
> wrangler.toml
npm ci
npm run deploy
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jobs:
| sed s/{MATOMO_DOMAIN}/${{ secrets.MATOMO_DOMAIN }}/ \
| sed s/{DISCORD_LOGGING_CHANNEL_ID}/${{ secrets.DISCORD_LOGGING_CHANNEL_ID }}/ \
| sed s/{DISCORD_LOGGING_TOKEN}/${{ secrets.DISCORD_LOGGING_TOKEN }}/ \
| sed s/{EDITOR_USERNAME}/${{ secrets.EDITOR_USERNAME }}/ \
| sed s/{EDITOR_PASSWORD}/${{ secrets.EDITOR_PASSWORD }}/ \
> wrangler.toml
npm ci
npm run deploy
Expand Down
15 changes: 13 additions & 2 deletions app/routes/cache.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {ActionFunctionArgs, json} from '@remix-run/cloudflare'
import {ActionFunctionArgs, LoaderFunctionArgs, json} from '@remix-run/cloudflare'
import {useLoaderData, useActionData, useNavigation, Form} from '@remix-run/react'
import {useEffect, useState} from 'react'
import {isAuthorized} from '~/routesMapper'
import {loadCacheKeys, loadCacheValue, cleanCache} from '~/server-utils/kv-cache'

enum Actions {
Expand All @@ -9,7 +10,17 @@ enum Actions {
loadCache = 'loadCache',
}

export const loader = async () => await loadCacheKeys()
export const headers = () => ({
'WWW-Authenticate': 'Basic',
})

export const loader = async ({request}: LoaderFunctionArgs) => {
if (!isAuthorized(request)) {
return json([] as string[], {status: 401})
}

return await loadCacheKeys()
}

export const action = async ({request}: ActionFunctionArgs) => {
const data = Array.from(await request.formData()) as [Actions, string][]
Expand Down
90 changes: 67 additions & 23 deletions app/routes/editors.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import {useState} from 'react'
import {LoaderFunctionArgs, json} from '@remix-run/cloudflare'
import Page from '~/components/Page'
import Button from '~/components/Button'
import '~/components/Chatbot/widgit.css'
import {Question} from '~/server-utils/stampy'
import {Question, QuestionStatus, loadAllQuestions} from '~/server-utils/stampy'
import {downloadZip} from 'client-zip'
import {fetchAllQuestionsOnSite} from './questions.allQuestionsOnSite'
import {useLoaderData} from '@remix-run/react'
import {isAuthorized} from '~/routesMapper'

const SINGLE_FILE_HTML = 'singleFileHtml'
const SINGLE_FILE_MARKDOWN = 'singleFileMarkdown'
const MULTI_FILE_HTML = 'multipleFilesMarkdown'
const MULTI_FILE_MARKDOWN = 'multipleFileHtml'
const SINGLE_FILE_JSON = 'singleFileJson'

const downloadOptions = {
[SINGLE_FILE_HTML]: 'Single HTML file',
[MULTI_FILE_HTML]: 'Multiple HTML files',
[SINGLE_FILE_MARKDOWN]: 'Single markdown file',
[MULTI_FILE_MARKDOWN]: 'Multiple markdown files',
[SINGLE_FILE_JSON]: 'As JSON',
}

const makeZipFile = async (questions: Question[], extention: string) =>
Expand Down Expand Up @@ -44,6 +48,8 @@ const makeFilename = (selectedOption: string) => {
case MULTI_FILE_HTML:
case MULTI_FILE_MARKDOWN:
return 'questions.zip'
case SINGLE_FILE_JSON:
return 'questions.json'
default:
return 'questions'
}
Expand Down Expand Up @@ -76,42 +82,80 @@ const getData = async (questions: Question[], selectedOption: string) => {
return new Blob([questions.map(toMarkdownChunk).join('\n\n\n')], {type: 'text/markdown'})
case MULTI_FILE_MARKDOWN:
return makeZipFile(questions, 'md')
case SINGLE_FILE_JSON:
return new Blob([JSON.stringify(questions, null, 2)], {type: 'application/json'})
}
}

const DownloadQuestions = () => {
type DownloadQuestionsProps = {
title: string
questions: Question[]
}
const DownloadQuestions = ({title, questions}: DownloadQuestionsProps) => {
const [selectedOption, setSelectedOption] = useState(SINGLE_FILE_HTML)

const download = async () => {
const {data} = await fetchAllQuestionsOnSite()
const blob = await getData(data, selectedOption)
const blob = await getData(questions, selectedOption)
blob && (await downloadBlob(makeFilename(selectedOption), blob))
}
return (
<div className="page-body full-height padding-top-32">
<h4>Download all questions</h4>
{Object.entries(downloadOptions).map(([id, label]) => (
<div key={id}>
<input
type="radio"
id={id}
checked={id === selectedOption}
name="download-format"
value={id}
onChange={(e) => setSelectedOption(e.target.id)}
/>
<label htmlFor={id}>{label}</label>
</div>
))}
<Button action={download}>Download</Button>
</div>
questions &&
questions.length > 0 && (
<div className="padding-top-32">
<h4>{title}</h4>
{Object.entries(downloadOptions).map(([id, label]) => (
<div key={id}>
<input
type="radio"
id={id}
checked={id === selectedOption}
name="download-format"
value={id}
onChange={(e) => setSelectedOption(e.target.id)}
/>
<label htmlFor={id}>{label}</label>
</div>
))}
<Button action={download}>Download</Button>
</div>
)
)
}

export const headers = () => ({
'WWW-Authenticate': 'Basic',
})

export const loader = async ({request}: LoaderFunctionArgs) => {
try {
if (!isAuthorized(request)) {
return json({authorized: false, data: [] as Question[]}, {status: 401})
}

return await loadAllQuestions(request)
} catch (e) {
console.error(e)
throw new Response('Could not fetch all articles', {status: 500})
}
}

export default function EditorHelpers() {
const {data: questions} = useLoaderData<typeof loader>()

return (
<Page noFooter>
<DownloadQuestions />
<div className="page-body full-height padding-top-32">
{(!questions || !questions.length) && <div>Fetching questions...</div>}
<DownloadQuestions title="Download all questions" questions={questions} />
<DownloadQuestions
title="Download all published questions"
questions={questions?.filter((q) => q.status === QuestionStatus.LIVE_ON_SITE)}
/>
<DownloadQuestions
title="Download all non published questions"
questions={questions?.filter((q) => q.status !== QuestionStatus.LIVE_ON_SITE)}
/>
</div>
</Page>
)
}
24 changes: 24 additions & 0 deletions app/routes/questions.allQuestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {LoaderFunctionArgs} from '@remix-run/cloudflare'
import {reloadInBackgroundIfNeeded} from '~/server-utils/kv-cache'
import {loadAllQuestions} from '~/server-utils/stampy'

export const loader = async ({request}: LoaderFunctionArgs) => {
try {
return await loadAllQuestions(request)
} catch (e) {
console.error(e)
throw new Response('Could not fetch all articles', {status: 500})
}
}
type Data = ReturnType<typeof loader>

export const fetchAllQuestions = async () => {
const url = `/questions/allQuestions`
const response = await fetch(url)
const {data, timestamp}: Awaited<Data> = await response.json()
const backgroundPromiseIfReloaded: Data | Promise<void> = reloadInBackgroundIfNeeded(
url,
timestamp
)
return {data, backgroundPromiseIfReloaded}
}
12 changes: 12 additions & 0 deletions app/routesMapper.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
export const isAuthorized = (request: Request) => {
const header = request.headers.get('Authorization')

if (!header) return false

const parsed = atob(header.replace('Basic ', ''))
if (!parsed) return false

const [username, password] = parsed.split(':')
return username === EDITOR_USERNAME && password === EDITOR_PASSWORD
}

export const questionUrl = ({pageid, title}: {pageid: string; title?: string}) =>
`/questions/${pageid}/${title?.replaceAll(' ', '-') || ''}`

Expand Down
3 changes: 2 additions & 1 deletion app/server-utils/stampy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ const sendToCoda = async (
export const fetchJson = async (url: string, params?: RequestInit) => {
let json
try {
json = await ((await fetch(url, params)) as Response).json()
const res = (await fetch(url, params)) as Response
json = await res.json()
} catch (e: unknown) {
// forward debug message to HTTP Response
if (e && typeof e === 'object' && 'message' in e) {
Expand Down
2 changes: 2 additions & 0 deletions remix.env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ declare const CHATBOT_URL: string
declare const MATOMO_DOMAIN: string
declare const DISCORD_LOGGING_CHANNEL_ID: string
declare const DISCORD_LOGGING_TOKEN: string
declare const EDITOR_USERNAME: string
declare const EDITOR_PASSWORD: string
2 changes: 2 additions & 0 deletions wrangler.toml.template
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,5 @@ ALLOW_ORIGINS = "https://chat.aisafety.info"
MATOMO_DOMAIN = "{MATOMO_DOMAIN}"
DISCORD_LOGGING_CHANNEL_ID = "{DISCORD_LOGGING_CHANNEL_ID}"
DISCORD_LOGGING_TOKEN = "{DISCORD_LOGGING_TOKEN}"
EDITOR_USERNAME = "{EDITOR_USERNAME}"
EDITOR_PASSWORD = "{EDITOR_PASSWORD}"

0 comments on commit 3f27b92

Please sign in to comment.