Skip to content

Commit 63de8f4

Browse files
committed
migrate to tigris for image storage
1 parent 09e2f33 commit 63de8f4

File tree

22 files changed

+456
-212
lines changed

22 files changed

+456
-212
lines changed

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,9 @@ 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+
STORAGE_ACCESS_KEY="mock-access-key"
25+
STORAGE_SECRET_KEY="mock-secret-key"
26+
STORAGE_BUCKET="my-app-bucket"
27+
STORAGE_ENDPOINT="https://my-app-storage.example.com"

.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
Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
11
import { invariantResponse } from '@epic-web/invariant'
22
import { prisma } from '#app/utils/db.server.ts'
3+
import { getImageUrl } 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-
})
11-
12-
invariantResponse(image, 'Not found', { status: 404 })
13-
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-
},
10+
select: { storageKey: true },
2111
})
12+
invariantResponse(noteImage, 'Note image not found', { status: 404 })
13+
return fetch(getImageUrl(noteImage.storageKey))
2214
}
Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
11
import { invariantResponse } from '@epic-web/invariant'
22
import { prisma } from '#app/utils/db.server.ts'
3+
import { getImageUrl } 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-
})
11-
12-
invariantResponse(image, 'Not found', { status: 404 })
13-
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-
},
10+
select: { storageKey: true },
2111
})
12+
invariantResponse(userImage, 'User image not found', { status: 404 })
13+
return fetch(getImageUrl(userImage.storageKey))
2214
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
useDoubleCheck,
1919
useIsPending,
2020
} from '#app/utils/misc.tsx'
21+
import { uploadProfileImage } from '#app/utils/storage.server.ts'
2122
import { type Route } from './+types/profile.photo.ts'
2223
import { type BreadcrumbHandle } from './profile.tsx'
2324

@@ -79,7 +80,7 @@ export async function action({ request }: Route.ActionArgs) {
7980
intent: data.intent,
8081
image: {
8182
contentType: data.photoFile.type,
82-
blob: Buffer.from(await data.photoFile.arrayBuffer()),
83+
storageKey: await uploadProfileImage(userId, data.photoFile),
8384
},
8485
}
8586
}),

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: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { z } from 'zod'
66
import { requireUserId } from '#app/utils/auth.server.ts'
77
import { prisma } from '#app/utils/db.server.ts'
88
import { uploadHandler } from '#app/utils/file-uploads.server.ts'
9+
import { uploadNoteImage } from '#app/utils/storage.server.ts'
910
import {
1011
MAX_UPLOAD_SIZE,
1112
NoteEditorSchema,
@@ -48,16 +49,18 @@ export async function action({ request }: ActionFunctionArgs) {
4849
})
4950
}
5051
}).transform(async ({ images = [], ...data }) => {
52+
const noteId = data.id ?? cuid()
5153
return {
5254
...data,
55+
id: noteId,
5356
imageUpdates: await Promise.all(
5457
images.filter(imageHasId).map(async (i) => {
5558
if (imageHasFile(i)) {
5659
return {
5760
id: i.id,
5861
altText: i.altText,
5962
contentType: i.file.type,
60-
blob: Buffer.from(await i.file.arrayBuffer()),
63+
storageKey: await uploadNoteImage(userId, noteId, i.file),
6164
}
6265
} else {
6366
return {
@@ -75,7 +78,7 @@ export async function action({ request }: ActionFunctionArgs) {
7578
return {
7679
altText: image.altText,
7780
contentType: image.file.type,
78-
blob: Buffer.from(await image.file.arrayBuffer()),
81+
storageKey: await uploadNoteImage(userId, noteId, image.file),
7982
}
8083
}),
8184
),
@@ -101,8 +104,9 @@ export async function action({ request }: ActionFunctionArgs) {
101104

102105
const updatedNote = await prisma.note.upsert({
103106
select: { id: true, owner: { select: { username: true } } },
104-
where: { id: noteId ?? '__new_note__' },
107+
where: { id: noteId },
105108
create: {
109+
id: noteId,
106110
ownerId: userId,
107111
title,
108112
content,
@@ -115,7 +119,11 @@ export async function action({ request }: ActionFunctionArgs) {
115119
deleteMany: { id: { notIn: imageUpdates.map((i) => i.id) } },
116120
updateMany: imageUpdates.map((updates) => ({
117121
where: { id: updates.id },
118-
data: { ...updates, id: updates.blob ? cuid() : updates.id },
122+
data: {
123+
...updates,
124+
// If the image is new, we need to generate a new ID to bust the cache.
125+
id: updates.storageKey ? cuid() : updates.id,
126+
},
119127
})),
120128
create: newImages,
121129
},

app/utils/auth.server.ts

Lines changed: 29 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 = () =>
@@ -160,21 +161,37 @@ export async function signupWithConnection({
160161
providerName: Connection['providerName']
161162
imageUrl?: string
162163
}) {
163-
const session = await prisma.session.create({
164+
const user = await prisma.user.create({
164165
data: {
165-
expirationDate: getSessionExpirationDate(),
166-
user: {
167-
create: {
168-
email: email.toLowerCase(),
169-
username: username.toLowerCase(),
170-
name,
171-
roles: { connect: { name: 'user' } },
172-
connections: { create: { providerId, providerName } },
173-
image: imageUrl
174-
? { create: await downloadFile(imageUrl) }
175-
: undefined,
166+
email: email.toLowerCase(),
167+
username: username.toLowerCase(),
168+
name,
169+
roles: { connect: { name: 'user' } },
170+
connections: { create: { providerId, providerName } },
171+
},
172+
select: { id: true },
173+
})
174+
175+
if (imageUrl) {
176+
const imageFile = await downloadFile(imageUrl)
177+
await prisma.user.update({
178+
where: { id: user.id },
179+
data: {
180+
image: {
181+
create: {
182+
contentType: imageFile.type,
183+
storageKey: await uploadProfileImage(user.id, imageFile),
184+
},
176185
},
177186
},
187+
})
188+
}
189+
190+
// Create and return the session
191+
const session = await prisma.session.create({
192+
data: {
193+
expirationDate: getSessionExpirationDate(),
194+
userId: user.id,
178195
},
179196
select: { id: true, expirationDate: true },
180197
})

app/utils/env.server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ 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+
STORAGE_ACCESS_KEY: z.string(),
23+
STORAGE_SECRET_KEY: z.string(),
24+
STORAGE_BUCKET: z.string(),
25+
STORAGE_ENDPOINT: z.string().url(),
2026
})
2127

2228
declare global {

app/utils/misc.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,8 +281,11 @@ export async function downloadFile(url: string, retries: number = 0) {
281281
throw new Error(`Failed to fetch image with status ${response.status}`)
282282
}
283283
const contentType = response.headers.get('content-type') ?? 'image/jpg'
284-
const blob = Buffer.from(await response.arrayBuffer())
285-
return { contentType, blob }
284+
const arrayBuffer = await response.arrayBuffer()
285+
const file = new File([arrayBuffer], 'downloaded-file', {
286+
type: contentType,
287+
})
288+
return file
286289
} catch (e) {
287290
if (retries > MAX_RETRIES) throw e
288291
return downloadFile(url, retries + 1)

0 commit comments

Comments
 (0)