Skip to content

Commit 2a2f82b

Browse files
committed
Merge branch 'main' into andrelandgraf/add-openimg
2 parents 7c54dff + 8ed1c66 commit 2a2f82b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+2095
-461
lines changed

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,10 @@ GITHUB_TOKEN="MOCK_GITHUB_TOKEN"
1919
# set this to false to prevent search engines from indexing the website
2020
# default to allow indexing for seo safety
2121
ALLOW_INDEXING="true"
22+
23+
# Tigris Object Storage (S3-compatible) Configuration
24+
AWS_ACCESS_KEY_ID="mock-access-key"
25+
AWS_SECRET_ACCESS_KEY="mock-secret-key"
26+
AWS_REGION="auto"
27+
AWS_ENDPOINT_URL_S3="https://fly.storage.tigris.dev"
28+
BUCKET_NAME="mock-bucket"

.github/workflows/version.yml

Lines changed: 0 additions & 54 deletions
This file was deleted.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ node_modules
1515
/playwright-report/
1616
/playwright/.cache/
1717
/tests/fixtures/email/
18+
/tests/fixtures/uploaded/
1819
/coverage
1920

2021
/other/cache.db

app/entry.server.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { PassThrough } from 'node:stream'
22
import { styleText } from 'node:util'
3+
import { contentSecurity } from '@nichtsam/helmet/content'
34
import { createReadableStreamFromReadable } from '@react-router/node'
45
import * as Sentry from '@sentry/node'
56
import { isbot } from 'isbot'
@@ -20,6 +21,8 @@ export const streamTimeout = 5000
2021
init()
2122
global.ENV = getEnv()
2223

24+
const MODE = process.env.NODE_ENV ?? 'development'
25+
2326
type DocRequestArgs = Parameters<HandleDocumentRequestFunction>
2427

2528
export default async function handleRequest(...args: DocRequestArgs) {
@@ -64,6 +67,33 @@ export default async function handleRequest(...args: DocRequestArgs) {
6467
const body = new PassThrough()
6568
responseHeaders.set('Content-Type', 'text/html')
6669
responseHeaders.append('Server-Timing', timings.toString())
70+
71+
contentSecurity(responseHeaders, {
72+
crossOriginEmbedderPolicy: false,
73+
contentSecurityPolicy: {
74+
// NOTE: Remove reportOnly when you're ready to enforce this CSP
75+
reportOnly: true,
76+
directives: {
77+
fetch: {
78+
'connect-src': [
79+
MODE === 'development' ? 'ws:' : undefined,
80+
process.env.SENTRY_DSN ? '*.sentry.io' : undefined,
81+
"'self'",
82+
],
83+
'font-src': ["'self'"],
84+
'frame-src': ["'self'"],
85+
'img-src': ["'self'", 'data:'],
86+
'script-src': [
87+
"'strict-dynamic'",
88+
"'self'",
89+
`'nonce-${nonce}'`,
90+
],
91+
'script-src-attr': [`'nonce-${nonce}'`],
92+
},
93+
},
94+
},
95+
})
96+
6797
resolve(
6898
new Response(createReadableStreamFromReadable(body), {
6999
headers: responseHeaders,

app/routes/_auth+/login.tsx

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
import { getFormProps, getInputProps, useForm } from '@conform-to/react'
22
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
33
import { type SEOHandle } from '@nasa-gcn/remix-seo'
4-
import { data, Form, Link, useSearchParams } from 'react-router'
4+
import { startAuthentication } from '@simplewebauthn/browser'
5+
import { useOptimistic, useState, useTransition } from 'react'
6+
import { data, Form, Link, useNavigate, useSearchParams } from 'react-router'
57
import { HoneypotInputs } from 'remix-utils/honeypot/react'
68
import { z } from 'zod'
79
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
810
import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx'
911
import { Spacer } from '#app/components/spacer.tsx'
12+
import { Icon } from '#app/components/ui/icon.tsx'
1013
import { StatusButton } from '#app/components/ui/status-button.tsx'
1114
import { login, requireAnonymous } from '#app/utils/auth.server.ts'
1215
import {
1316
ProviderConnectionForm,
1417
providerNames,
1518
} from '#app/utils/connections.tsx'
1619
import { checkHoneypot } from '#app/utils/honeypot.server.ts'
17-
import { useIsPending } from '#app/utils/misc.tsx'
20+
import { getErrorMessage, useIsPending } from '#app/utils/misc.tsx'
1821
import { PasswordSchema, UsernameSchema } from '#app/utils/user-validation.ts'
1922
import { type Route } from './+types/login.ts'
2023
import { handleNewSession } from './login.server.ts'
@@ -30,6 +33,10 @@ const LoginFormSchema = z.object({
3033
remember: z.boolean().optional(),
3134
})
3235

36+
const AuthenticationOptionsSchema = z.object({
37+
options: z.object({ challenge: z.string() }),
38+
}) satisfies z.ZodType<{ options: PublicKeyCredentialRequestOptionsJSON }>
39+
3340
export async function loader({ request }: Route.LoaderArgs) {
3441
await requireAnonymous(request)
3542
return {}
@@ -165,7 +172,15 @@ export default function LoginPage({ actionData }: Route.ComponentProps) {
165172
</StatusButton>
166173
</div>
167174
</Form>
168-
<ul className="mt-5 flex flex-col gap-5 border-b-2 border-t-2 border-border py-3">
175+
<hr className="my-4" />
176+
<div className="flex flex-col gap-5">
177+
<PasskeyLogin
178+
redirectTo={redirectTo}
179+
remember={fields.remember.value === 'on'}
180+
/>
181+
</div>
182+
<hr className="my-4" />
183+
<ul className="flex flex-col gap-5">
169184
{providerNames.map((providerName) => (
170185
<li key={providerName}>
171186
<ProviderConnectionForm
@@ -195,6 +210,94 @@ export default function LoginPage({ actionData }: Route.ComponentProps) {
195210
)
196211
}
197212

213+
const VerificationResponseSchema = z.discriminatedUnion('status', [
214+
z.object({
215+
status: z.literal('success'),
216+
location: z.string(),
217+
}),
218+
z.object({
219+
status: z.literal('error'),
220+
error: z.string(),
221+
}),
222+
])
223+
224+
function PasskeyLogin({
225+
redirectTo,
226+
remember,
227+
}: {
228+
redirectTo: string | null
229+
remember: boolean
230+
}) {
231+
const [isPending] = useTransition()
232+
const [error, setError] = useState<string | null>(null)
233+
const [passkeyMessage, setPasskeyMessage] = useOptimistic<string | null>(
234+
'Login with a passkey',
235+
)
236+
const navigate = useNavigate()
237+
238+
async function handlePasskeyLogin() {
239+
try {
240+
setPasskeyMessage('Generating Authentication Options')
241+
// Get authentication options from the server
242+
const optionsResponse = await fetch('/webauthn/authentication')
243+
const json = await optionsResponse.json()
244+
const { options } = AuthenticationOptionsSchema.parse(json)
245+
246+
setPasskeyMessage('Requesting your authorization')
247+
const authResponse = await startAuthentication({ optionsJSON: options })
248+
setPasskeyMessage('Verifying your passkey')
249+
250+
// Verify the authentication with the server
251+
const verificationResponse = await fetch('/webauthn/authentication', {
252+
method: 'POST',
253+
headers: { 'Content-Type': 'application/json' },
254+
body: JSON.stringify({ authResponse, remember, redirectTo }),
255+
})
256+
257+
const verificationJson = await verificationResponse.json().catch(() => ({
258+
status: 'error',
259+
error: 'Unknown error',
260+
}))
261+
262+
const parsedResult =
263+
VerificationResponseSchema.safeParse(verificationJson)
264+
if (!parsedResult.success) {
265+
throw new Error(parsedResult.error.message)
266+
} else if (parsedResult.data.status === 'error') {
267+
throw new Error(parsedResult.data.error)
268+
}
269+
const { location } = parsedResult.data
270+
271+
setPasskeyMessage("You're logged in! Navigating...")
272+
await navigate(location ?? '/')
273+
} catch (e) {
274+
const errorMessage = getErrorMessage(e)
275+
setError(`Failed to authenticate with passkey: ${errorMessage}`)
276+
}
277+
}
278+
279+
return (
280+
<form action={handlePasskeyLogin}>
281+
<StatusButton
282+
id="passkey-login-button"
283+
aria-describedby="passkey-login-button-error"
284+
className="w-full"
285+
status={isPending ? 'pending' : error ? 'error' : 'idle'}
286+
type="submit"
287+
disabled={isPending}
288+
>
289+
<span className="inline-flex items-center gap-1.5">
290+
<Icon name="passkey" />
291+
<span>{passkeyMessage}</span>
292+
</span>
293+
</StatusButton>
294+
<div className="mt-2">
295+
<ErrorList errors={[error]} id="passkey-login-button-error" />
296+
</div>
297+
</form>
298+
)
299+
}
300+
198301
export const meta: Route.MetaFunction = () => {
199302
return [{ title: 'Login to Epic Notes' }]
200303
}

app/routes/_auth+/signup.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { z } from 'zod'
88
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
99
import { ErrorList, Field } from '#app/components/forms.tsx'
1010
import { StatusButton } from '#app/components/ui/status-button.tsx'
11+
import { requireAnonymous } from '#app/utils/auth.server.ts'
1112
import {
1213
ProviderConnectionForm,
1314
providerNames,
@@ -28,6 +29,11 @@ const SignupSchema = z.object({
2829
email: EmailSchema,
2930
})
3031

32+
export async function loader({ request }: Route.LoaderArgs) {
33+
await requireAnonymous(request)
34+
return null
35+
}
36+
3137
export async function action({ request }: Route.ActionArgs) {
3238
const formData = await request.formData()
3339

0 commit comments

Comments
 (0)