Skip to content

Commit bbacdeb

Browse files
authored
Merge branch 'main' into chore/update-remix-auth-github
2 parents 85233ae + 9542505 commit bbacdeb

29 files changed

+795
-314
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"

.gitignore

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

1920
/other/cache.db

app/routes/resources+/download-user-data.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export async function loader({ request }: Route.LoaderArgs) {
1818
id: true,
1919
createdAt: true,
2020
updatedAt: true,
21-
contentType: true,
21+
objectKey: true,
2222
},
2323
},
2424
notes: {
@@ -28,7 +28,7 @@ export async function loader({ request }: Route.LoaderArgs) {
2828
id: true,
2929
createdAt: true,
3030
updatedAt: true,
31-
contentType: true,
31+
objectKey: true,
3232
},
3333
},
3434
},
Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
import { invariantResponse } from '@epic-web/invariant'
22
import { prisma } from '#app/utils/db.server.ts'
3+
import { getSignedGetRequestInfo } from '#app/utils/storage.server.ts'
34
import { type Route } from './+types/note-images.$imageId.ts'
45

56
export async function loader({ params }: Route.LoaderArgs) {
67
invariantResponse(params.imageId, 'Image ID is required', { status: 400 })
7-
const image = await prisma.noteImage.findUnique({
8+
const noteImage = await prisma.noteImage.findUnique({
89
where: { id: params.imageId },
9-
select: { contentType: true, blob: true },
10+
select: { objectKey: true },
1011
})
12+
invariantResponse(noteImage, 'Note image not found', { status: 404 })
1113

12-
invariantResponse(image, 'Not found', { status: 404 })
14+
const { url, headers } = getSignedGetRequestInfo(noteImage.objectKey)
15+
const response = await fetch(url, { headers })
1316

14-
return new Response(image.blob, {
15-
headers: {
16-
'Content-Type': image.contentType,
17-
'Content-Length': Buffer.byteLength(image.blob).toString(),
18-
'Content-Disposition': `inline; filename="${params.imageId}"`,
19-
'Cache-Control': 'public, max-age=31536000, immutable',
20-
},
17+
const cacheHeaders = new Headers(response.headers)
18+
cacheHeaders.set('Cache-Control', 'public, max-age=31536000, immutable')
19+
20+
return new Response(response.body, {
21+
status: response.status,
22+
headers: cacheHeaders,
2123
})
2224
}
Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
import { invariantResponse } from '@epic-web/invariant'
22
import { prisma } from '#app/utils/db.server.ts'
3+
import { getSignedGetRequestInfo } from '#app/utils/storage.server.ts'
34
import { type Route } from './+types/user-images.$imageId.ts'
45

56
export async function loader({ params }: Route.LoaderArgs) {
67
invariantResponse(params.imageId, 'Image ID is required', { status: 400 })
7-
const image = await prisma.userImage.findUnique({
8+
const userImage = await prisma.userImage.findUnique({
89
where: { id: params.imageId },
9-
select: { contentType: true, blob: true },
10+
select: { objectKey: true },
1011
})
12+
invariantResponse(userImage, 'User image not found', { status: 404 })
1113

12-
invariantResponse(image, 'Not found', { status: 404 })
14+
const { url, headers } = getSignedGetRequestInfo(userImage.objectKey)
15+
const response = await fetch(url, { headers })
1316

14-
return new Response(image.blob, {
15-
headers: {
16-
'Content-Type': image.contentType,
17-
'Content-Length': Buffer.byteLength(image.blob).toString(),
18-
'Content-Disposition': `inline; filename="${params.imageId}"`,
19-
'Cache-Control': 'public, max-age=31536000, immutable',
20-
},
17+
const cacheHeaders = new Headers(response.headers)
18+
cacheHeaders.set('Cache-Control', 'public, max-age=31536000, immutable')
19+
20+
return new Response(response.body, {
21+
status: response.status,
22+
headers: cacheHeaders,
2123
})
2224
}

app/routes/settings+/profile.photo.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { getFormProps, getInputProps, useForm } from '@conform-to/react'
22
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
33
import { invariantResponse } from '@epic-web/invariant'
4-
import { type FileUpload, parseFormData } from '@mjackson/form-data-parser'
4+
import { parseFormData } from '@mjackson/form-data-parser'
55
import { type SEOHandle } from '@nasa-gcn/remix-seo'
66
import { useState } from 'react'
77
import { data, redirect, Form, useNavigation } from 'react-router'
@@ -12,12 +12,12 @@ import { Icon } from '#app/components/ui/icon.tsx'
1212
import { StatusButton } from '#app/components/ui/status-button.tsx'
1313
import { requireUserId } from '#app/utils/auth.server.ts'
1414
import { prisma } from '#app/utils/db.server.ts'
15-
import { uploadHandler } from '#app/utils/file-uploads.server.ts'
1615
import {
1716
getUserImgSrc,
1817
useDoubleCheck,
1918
useIsPending,
2019
} from '#app/utils/misc.tsx'
20+
import { uploadProfileImage } from '#app/utils/storage.server.ts'
2121
import { type Route } from './+types/profile.photo.ts'
2222
import { type BreadcrumbHandle } from './profile.tsx'
2323

@@ -66,20 +66,15 @@ export async function loader({ request }: Route.LoaderArgs) {
6666
export async function action({ request }: Route.ActionArgs) {
6767
const userId = await requireUserId(request)
6868

69-
const formData = await parseFormData(
70-
request,
71-
{ maxFileSize: MAX_SIZE },
72-
async (file: FileUpload) => uploadHandler(file),
73-
)
69+
const formData = await parseFormData(request, { maxFileSize: MAX_SIZE })
7470
const submission = await parseWithZod(formData, {
7571
schema: PhotoFormSchema.transform(async (data) => {
7672
if (data.intent === 'delete') return { intent: 'delete' }
7773
if (data.photoFile.size <= 0) return z.NEVER
7874
return {
7975
intent: data.intent,
8076
image: {
81-
contentType: data.photoFile.type,
82-
blob: Buffer.from(await data.photoFile.arrayBuffer()),
77+
objectKey: await uploadProfileImage(userId, data.photoFile),
8378
},
8479
}
8580
}),

app/routes/users+/$username.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ test('The user profile when not logged in as self', async () => {
2626
path: '/users/:username',
2727
Component: UsernameRoute,
2828
loader,
29+
HydrateFallback: () => <div>Loading...</div>,
2930
},
3031
])
3132

@@ -70,6 +71,7 @@ test('The user profile when logged in as self', async () => {
7071
args.request.headers.set('cookie', cookieHeader)
7172
return rootLoader({ ...args, context: args.context })
7273
},
74+
HydrateFallback: () => <div>Loading...</div>,
7375
children: [
7476
{
7577
path: 'users/:username',
@@ -85,7 +87,7 @@ test('The user profile when logged in as self', async () => {
8587
])
8688

8789
const routeUrl = `/users/${user.username}`
88-
await render(<App initialEntries={[routeUrl]} />)
90+
render(<App initialEntries={[routeUrl]} />)
8991

9092
await screen.findByRole('heading', { level: 1, name: user.name! })
9193
await screen.findByRole('img', { name: user.name! })

app/routes/users+/$username_+/__note-editor.server.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { parseWithZod } from '@conform-to/zod'
2-
import { type FileUpload, parseFormData } from '@mjackson/form-data-parser'
2+
import { parseFormData } from '@mjackson/form-data-parser'
33
import { createId as cuid } from '@paralleldrive/cuid2'
44
import { data, redirect, type ActionFunctionArgs } from 'react-router'
55
import { z } from 'zod'
66
import { requireUserId } from '#app/utils/auth.server.ts'
77
import { prisma } from '#app/utils/db.server.ts'
8-
import { uploadHandler } from '#app/utils/file-uploads.server.ts'
8+
import { uploadNoteImage } from '#app/utils/storage.server.ts'
99
import {
1010
MAX_UPLOAD_SIZE,
1111
NoteEditorSchema,
@@ -20,18 +20,16 @@ function imageHasFile(
2020

2121
function imageHasId(
2222
image: ImageFieldset,
23-
): image is ImageFieldset & { id: NonNullable<ImageFieldset['id']> } {
24-
return image.id != null
23+
): image is ImageFieldset & { id: string } {
24+
return Boolean(image.id)
2525
}
2626

2727
export async function action({ request }: ActionFunctionArgs) {
2828
const userId = await requireUserId(request)
2929

30-
const formData = await parseFormData(
31-
request,
32-
{ maxFileSize: MAX_UPLOAD_SIZE },
33-
async (file: FileUpload) => uploadHandler(file),
34-
)
30+
const formData = await parseFormData(request, {
31+
maxFileSize: MAX_UPLOAD_SIZE,
32+
})
3533

3634
const submission = await parseWithZod(formData, {
3735
schema: NoteEditorSchema.superRefine(async (data, ctx) => {
@@ -48,16 +46,17 @@ export async function action({ request }: ActionFunctionArgs) {
4846
})
4947
}
5048
}).transform(async ({ images = [], ...data }) => {
49+
const noteId = data.id ?? cuid()
5150
return {
5251
...data,
52+
id: noteId,
5353
imageUpdates: await Promise.all(
5454
images.filter(imageHasId).map(async (i) => {
5555
if (imageHasFile(i)) {
5656
return {
5757
id: i.id,
5858
altText: i.altText,
59-
contentType: i.file.type,
60-
blob: Buffer.from(await i.file.arrayBuffer()),
59+
objectKey: await uploadNoteImage(userId, noteId, i.file),
6160
}
6261
} else {
6362
return {
@@ -74,8 +73,7 @@ export async function action({ request }: ActionFunctionArgs) {
7473
.map(async (image) => {
7574
return {
7675
altText: image.altText,
77-
contentType: image.file.type,
78-
blob: Buffer.from(await image.file.arrayBuffer()),
76+
objectKey: await uploadNoteImage(userId, noteId, image.file),
7977
}
8078
}),
8179
),
@@ -101,8 +99,9 @@ export async function action({ request }: ActionFunctionArgs) {
10199

102100
const updatedNote = await prisma.note.upsert({
103101
select: { id: true, owner: { select: { username: true } } },
104-
where: { id: noteId ?? '__new_note__' },
102+
where: { id: noteId },
105103
create: {
104+
id: noteId,
106105
ownerId: userId,
107106
title,
108107
content,
@@ -115,7 +114,11 @@ export async function action({ request }: ActionFunctionArgs) {
115114
deleteMany: { id: { notIn: imageUpdates.map((i) => i.id) } },
116115
updateMany: imageUpdates.map((updates) => ({
117116
where: { id: updates.id },
118-
data: { ...updates, id: updates.blob ? cuid() : updates.id },
117+
data: {
118+
...updates,
119+
// If the image is new, we need to generate a new ID to bust the cache.
120+
id: updates.objectKey ? cuid() : updates.id,
121+
},
119122
})),
120123
create: newImages,
121124
},

app/utils/auth.server.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { prisma } from './db.server.ts'
88
import { combineHeaders, downloadFile } from './misc.tsx'
99
import { type ProviderUser } from './providers/provider.ts'
1010
import { authSessionStorage } from './session.server.ts'
11+
import { uploadProfileImage } from './storage.server.ts'
1112

1213
export const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30
1314
export const getSessionExpirationDate = () =>
@@ -158,21 +159,36 @@ export async function signupWithConnection({
158159
providerName: Connection['providerName']
159160
imageUrl?: string
160161
}) {
161-
const session = await prisma.session.create({
162+
const user = await prisma.user.create({
162163
data: {
163-
expirationDate: getSessionExpirationDate(),
164-
user: {
165-
create: {
166-
email: email.toLowerCase(),
167-
username: username.toLowerCase(),
168-
name,
169-
roles: { connect: { name: 'user' } },
170-
connections: { create: { providerId, providerName } },
171-
image: imageUrl
172-
? { create: await downloadFile(imageUrl) }
173-
: undefined,
164+
email: email.toLowerCase(),
165+
username: username.toLowerCase(),
166+
name,
167+
roles: { connect: { name: 'user' } },
168+
connections: { create: { providerId, providerName } },
169+
},
170+
select: { id: true },
171+
})
172+
173+
if (imageUrl) {
174+
const imageFile = await downloadFile(imageUrl)
175+
await prisma.user.update({
176+
where: { id: user.id },
177+
data: {
178+
image: {
179+
create: {
180+
objectKey: await uploadProfileImage(user.id, imageFile),
181+
},
174182
},
175183
},
184+
})
185+
}
186+
187+
// Create and return the session
188+
const session = await prisma.session.create({
189+
data: {
190+
expirationDate: getSessionExpirationDate(),
191+
userId: user.id,
176192
},
177193
select: { id: true, expirationDate: true },
178194
})

app/utils/env.server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ const schema = z.object({
1717
GITHUB_CLIENT_SECRET: z.string().default('MOCK_GITHUB_CLIENT_SECRET'),
1818
GITHUB_TOKEN: z.string().default('MOCK_GITHUB_TOKEN'),
1919
ALLOW_INDEXING: z.enum(['true', 'false']).optional(),
20+
21+
// Tigris Object Storage Configuration
22+
AWS_ACCESS_KEY_ID: z.string(),
23+
AWS_SECRET_ACCESS_KEY: z.string(),
24+
AWS_REGION: z.string(),
25+
AWS_ENDPOINT_URL_S3: z.string().url(),
26+
BUCKET_NAME: z.string(),
2027
})
2128

2229
declare global {

0 commit comments

Comments
 (0)