store profile avatars in R2
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-08 11:40:50 +09:00
parent 0112956835
commit 19b8e40b72
7 changed files with 171 additions and 7 deletions
+40 -2
View File
@@ -1,5 +1,12 @@
import { config } from '../config'
import { prisma } from '../prisma'
import {
deletePublicFile,
isR2PublicFilesConfigured,
publicFileUrl,
uploadPublicFile,
userAvatarKey
} from '../storage/r2-public-files'
import { hashPassword, verifyPassword } from '../security/password'
import { redis } from '../redis'
import { createOpaqueToken, hashToken } from '../security/tokens'
@@ -385,6 +392,24 @@ export async function changeUserPassword(userId: string, currentPassword: string
}
export async function updateUserProfileImage(userId: string, imageData: Uint8Array, mimeType: string): Promise<void> {
if (isR2PublicFilesConfigured()) {
await uploadPublicFile({
key: userAvatarKey(userId),
body: imageData,
contentType: mimeType
})
await prisma.user.update({
where: { id: userId },
data: {
profileImageData: null,
profileImageMime: mimeType,
profileImageUpdatedAt: new Date()
}
})
return
}
await prisma.user.update({
where: { id: userId },
data: {
@@ -396,6 +421,8 @@ export async function updateUserProfileImage(userId: string, imageData: Uint8Arr
}
export async function deleteUserProfileImage(userId: string): Promise<void> {
await deletePublicFile(userAvatarKey(userId)).catch(() => undefined)
await prisma.user.update({
where: { id: userId },
data: {
@@ -406,7 +433,7 @@ export async function deleteUserProfileImage(userId: string): Promise<void> {
})
}
export async function findUserProfileImage(userId: string): Promise<{ data: Buffer; mimeType: string; updatedAt: Date | null } | null> {
export async function findUserProfileImage(userId: string): Promise<{ data?: Buffer; url?: string; mimeType: string; updatedAt: Date | null } | null> {
const user = await prisma.user.findFirst({
where: { id: userId, isActive: true },
select: {
@@ -416,7 +443,18 @@ export async function findUserProfileImage(userId: string): Promise<{ data: Buff
}
})
if (!user?.profileImageData || !user.profileImageMime) return null
if (!user?.profileImageMime) return null
const storedUrl = publicFileUrl(userAvatarKey(userId))
if (!user.profileImageData && storedUrl) {
return {
url: storedUrl,
mimeType: user.profileImageMime,
updatedAt: user.profileImageUpdatedAt
}
}
if (!user.profileImageData) return null
return {
data: Buffer.from(user.profileImageData),
+6 -3
View File
@@ -653,12 +653,13 @@ authRoutes.get('/me', authRequired, (c) => {
authRoutes.get('/profile/avatar', authRequired, async (c) => {
const image = await findUserProfileImage(c.get('user').id)
if (!image) return c.body(null, 404)
if (image.url) return c.redirect(image.url, 302)
c.header('content-type', image.mimeType)
c.header('cache-control', 'no-store')
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
return c.body(new Uint8Array(image.data))
return c.body(new Uint8Array(image.data!))
})
authRoutes.get('/users/:userId/avatar', authRequired, async (c) => {
@@ -667,12 +668,13 @@ authRoutes.get('/users/:userId/avatar', authRequired, async (c) => {
const userId = c.req.param('userId')
const image = await findUserProfileImage(userId)
if (!image) return c.body(null, 404)
if (image.url) return c.redirect(image.url, 302)
c.header('content-type', image.mimeType)
c.header('cache-control', 'no-store')
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
return c.body(new Uint8Array(image.data))
return c.body(new Uint8Array(image.data!))
})
authRoutes.get('/users/avatar', authRequired, async (c) => {
@@ -686,12 +688,13 @@ authRoutes.get('/users/avatar', authRequired, async (c) => {
const image = await findUserProfileImage(user.id)
if (!image) return c.body(null, 404)
if (image.url) return c.redirect(image.url, 302)
c.header('content-type', image.mimeType)
c.header('cache-control', 'no-store')
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
return c.body(new Uint8Array(image.data))
return c.body(new Uint8Array(image.data!))
})
authRoutes.put('/profile/avatar', authRequired, async (c) => {
+10 -1
View File
@@ -39,5 +39,14 @@ export const config = {
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
googleRedirectUri: process.env.GOOGLE_REDIRECT_URI ?? 'http://localhost:4100/auth/google/callback',
launcherOAuthRedirectUri: process.env.LAUNCHER_OAUTH_REDIRECT_URI ?? 'autonomy-studio://auth/google/callback',
oauthLoginTokenTtlSeconds: toInt(process.env.OAUTH_LOGIN_TOKEN_TTL_SECONDS, 120)
oauthLoginTokenTtlSeconds: toInt(process.env.OAUTH_LOGIN_TOKEN_TTL_SECONDS, 120),
r2Bucket: process.env.R2_BUCKET?.trim() ?? '',
r2PublicBaseUrl: process.env.R2_PUBLIC_BASE_URL?.trim() ?? '',
r2AccessKeyId: process.env.R2_ACCESS_KEY_ID?.trim() ?? '',
r2SecretAccessKey: process.env.R2_SECRET_ACCESS_KEY?.trim() ?? '',
r2AccountId: process.env.R2_ACCOUNT_ID?.trim() ?? '',
r2Endpoint: process.env.R2_ENDPOINT?.trim() ?? '',
r2Region: process.env.R2_REGION?.trim() || 'auto',
r2ForcePathStyle: process.env.R2_FORCE_PATH_STYLE !== 'false',
r2PublicFilePrefix: (process.env.R2_PUBLIC_FILE_PREFIX ?? 'studio-files/public').replace(/^\/+|\/+$/g, '')
}
+1 -1
View File
@@ -18,7 +18,7 @@ app.use(
return config.corsOrigins.includes(origin) ? origin : null
},
allowHeaders: ['authorization', 'content-type'],
allowMethods: ['GET', 'POST', 'PUT', 'OPTIONS'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true
})
)
+60
View File
@@ -0,0 +1,60 @@
import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { config } from '../config'
let client: S3Client | null = null
export function isR2PublicFilesConfigured(): boolean {
return Boolean(config.r2Bucket && config.r2PublicBaseUrl && config.r2AccessKeyId && config.r2SecretAccessKey)
}
export function userAvatarKey(userId: string): string {
return `${config.r2PublicFilePrefix}/users/${userId}/avatar`
}
export function publicFileUrl(key: string): string | null {
if (!config.r2PublicBaseUrl || !key.startsWith(`${config.r2PublicFilePrefix}/`)) return null
return `${config.r2PublicBaseUrl.replace(/\/$/, '')}/${key.split('/').map(encodeURIComponent).join('/')}`
}
export async function uploadPublicFile(input: {
key: string
body: Uint8Array
contentType: string
}) {
if (!isR2PublicFilesConfigured()) return null
await getClient().send(new PutObjectCommand({
Bucket: config.r2Bucket,
Key: input.key,
Body: Buffer.from(input.body),
ContentLength: input.body.length,
ContentType: input.contentType
}))
return publicFileUrl(input.key)
}
export async function deletePublicFile(key: string) {
if (!isR2PublicFilesConfigured()) return
await getClient().send(new DeleteObjectCommand({
Bucket: config.r2Bucket,
Key: key
}))
}
function getClient() {
if (client) return client
client = new S3Client({
region: config.r2Region,
endpoint: config.r2Endpoint || `https://${config.r2AccountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.r2AccessKeyId,
secretAccessKey: config.r2SecretAccessKey
},
forcePathStyle: config.r2ForcePathStyle
})
return client
}