This commit is contained in:
+40
-2
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user