Skip to content

Commit 83dc2c3

Browse files
authored
Merge pull request #291 from Zheaoli/manjusaka/s3-direct-upload
feat: enhance file upload functionality with S3 and R2 support
2 parents ed74cbe + eeb0aa7 commit 83dc2c3

11 files changed

Lines changed: 568 additions & 71 deletions

File tree

components/admin/upload/simple-file-upload.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ export default function SimpleFileUpload() {
229229
await uploadPreviewImage(file, album + '/preview', flag, outputBuffer)
230230
}
231231
} catch (e) {
232-
232+
console.error('Failed to upload preview image:', e)
233233
}
234234
await loadExif(file, outputBuffer, flag)
235235
setUrl(res?.data)
@@ -625,3 +625,42 @@ export default function SimpleFileUpload() {
625625
</div>
626626
)
627627
}
628+
629+
async function uploadFile(file: File, type: string, storage: string, mountPath: string) {
630+
const formData = new FormData()
631+
formData.append('file', file)
632+
formData.append('storage', storage)
633+
formData.append('type', type)
634+
if (mountPath) {
635+
formData.append('mountPath', mountPath)
636+
}
637+
638+
const res = await fetch('/api/v1/file/upload', {
639+
method: 'POST',
640+
body: formData,
641+
}).then(res => res.json())
642+
643+
if (res?.code === 200) {
644+
if (res.data.upload_url) {
645+
// 直传模式
646+
try {
647+
await fetch(res.data.upload_url, {
648+
method: 'PUT',
649+
body: file,
650+
headers: {
651+
'Content-Type': file.type,
652+
},
653+
})
654+
return {
655+
code: 200,
656+
data: res.data.key
657+
}
658+
} catch (e) {
659+
throw new Error('Upload failed')
660+
}
661+
}
662+
return res
663+
} else {
664+
throw new Error('Upload failed')
665+
}
666+
}

hono/file.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import 'server-only'
33
import { Hono } from 'hono'
44
import { HTTPException } from 'hono/http-exception'
55
import { alistUpload, r2Upload, s3Upload } from '~/server/lib/file-upload'
6+
import { fetchConfigsByKeys } from '~/server/db/query/configs'
7+
import { getClient, generatePresignedUrl as generateS3PresignedUrl } from '~/server/lib/s3'
8+
import { getR2Client, generatePresignedUrl as generateR2PresignedUrl } from '~/server/lib/r2'
69

710
const app = new Hono()
811

@@ -16,7 +19,37 @@ app.post('/upload', async (c) => {
1619

1720
if (storage) {
1821
switch (storage.toString()) {
19-
case 's3':
22+
case 's3': {
23+
const configs = await fetchConfigsByKeys([
24+
'accesskey_id',
25+
'accesskey_secret',
26+
'region',
27+
'endpoint',
28+
'bucket',
29+
'storage_folder',
30+
'force_path_style',
31+
's3_cdn',
32+
's3_cdn_url',
33+
's3_direct_upload'
34+
])
35+
const directUpload = configs.find((item: any) => item.config_key === 's3_direct_upload')?.config_value === 'true'
36+
const bucket = configs.find((item: any) => item.config_key === 'bucket')?.config_value || ''
37+
const storageFolder = configs.find((item: any) => item.config_key === 'storage_folder')?.config_value || ''
38+
39+
if (directUpload) {
40+
const filePath = storageFolder && storageFolder !== '/'
41+
? type && type !== '/' ? `${storageFolder}${type}/${file?.name}` : `${storageFolder}/${file?.name}`
42+
: type && type !== '/' ? `${type.slice(1)}/${file?.name}` : `${file?.name}`
43+
const client = getClient(configs)
44+
const presignedUrl = await generateS3PresignedUrl(client, bucket, filePath)
45+
return Response.json({
46+
code: 200,
47+
data: {
48+
upload_url: presignedUrl,
49+
key: filePath
50+
}
51+
})
52+
}
2053
return await s3Upload(file, type)
2154
.then((result: string) => {
2255
return Response.json({
@@ -26,7 +59,35 @@ app.post('/upload', async (c) => {
2659
.catch(e => {
2760
throw new HTTPException(500, { message: 'Failed', cause: e })
2861
})
29-
case 'r2':
62+
}
63+
case 'r2': {
64+
const configs = await fetchConfigsByKeys([
65+
'r2_accesskey_id',
66+
'r2_accesskey_secret',
67+
'r2_endpoint',
68+
'r2_bucket',
69+
'r2_storage_folder',
70+
'r2_public_domain',
71+
'r2_direct_upload'
72+
])
73+
const directUpload = configs.find((item: any) => item.config_key === 'r2_direct_upload')?.config_value === 'true'
74+
const bucket = configs.find((item: any) => item.config_key === 'r2_bucket')?.config_value || ''
75+
const storageFolder = configs.find((item: any) => item.config_key === 'r2_storage_folder')?.config_value || ''
76+
77+
if (directUpload) {
78+
const filePath = storageFolder && storageFolder !== '/'
79+
? type && type !== '/' ? `${storageFolder}${type}/${file?.name}` : `${storageFolder}/${file?.name}`
80+
: type && type !== '/' ? `${type.slice(1)}/${file?.name}` : `${file?.name}`
81+
const client = getR2Client(configs)
82+
const presignedUrl = await generateR2PresignedUrl(client, bucket, filePath)
83+
return Response.json({
84+
code: 200,
85+
data: {
86+
upload_url: presignedUrl,
87+
key: filePath
88+
}
89+
})
90+
}
3091
return await r2Upload(file, type)
3192
.then((result: string) => {
3293
return Response.json({
@@ -36,6 +97,7 @@ app.post('/upload', async (c) => {
3697
.catch(e => {
3798
throw new HTTPException(500, { message: 'Failed', cause: e })
3899
})
100+
}
39101
case 'alist':
40102
return await alistUpload(file, type, mountPath)
41103
.then((result: string) => {

hono/images.ts

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,93 @@ import {
99
} from '~/server/db/operate/images'
1010
import { Hono } from 'hono'
1111
import { HTTPException } from 'hono/http-exception'
12+
import { fetchConfigsByKeys } from '~/server/db/query/configs'
13+
import { getClient } from '~/server/lib/s3'
14+
import { getR2Client } from '~/server/lib/r2'
15+
import { uploadSimpleObject } from '~/server/lib/s3api'
16+
import { GetObjectCommand } from '@aws-sdk/client-s3'
17+
import sharp from 'sharp'
1218

1319
const app = new Hono()
1420

1521
app.post('/add', async (c) => {
16-
const image = await c.req.json()
17-
if (!image.url) {
22+
const body = await c.req.json()
23+
if (!body) {
24+
throw new HTTPException(400, { message: 'Missing body' })
25+
}
26+
27+
// 验证基本图片信息
28+
if (!body.url) {
1829
throw new HTTPException(500, { message: 'Image link cannot be empty' })
1930
}
20-
if (!image.height || image.height <= 0) {
31+
if (!body.height || body.height <= 0) {
2132
throw new HTTPException(500, { message: 'Image height cannot be empty and must be greater than 0' })
2233
}
23-
if (!image.width || image.width <= 0) {
34+
if (!body.width || body.width <= 0) {
2435
throw new HTTPException(500, { message: 'Image width cannot be empty and must be greater than 0' })
2536
}
37+
2638
try {
27-
await insertImage(image)
28-
return c.json({ code: 200, message: 'Success' })
39+
// 获取存储配置
40+
const configs = await fetchConfigsByKeys([
41+
's3_cdn',
42+
's3_cdn_url',
43+
's3_direct_upload',
44+
'r2_public_domain',
45+
'r2_direct_upload'
46+
])
47+
48+
// 检查是否是直传模式
49+
const s3DirectUpload = configs.find((item: any) => item.config_key === 's3_direct_upload')?.config_value === 'true'
50+
const r2DirectUpload = configs.find((item: any) => item.config_key === 'r2_direct_upload')?.config_value === 'true'
51+
52+
// 如果是直传模式,需要处理文件
53+
if (s3DirectUpload || r2DirectUpload) {
54+
const url = body.url
55+
const previewUrl = body.preview_url
56+
if (!url) {
57+
throw new HTTPException(400, { message: 'Missing file URL' })
58+
}
59+
60+
// 确保 URL 是完整的
61+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
62+
if (s3DirectUpload) {
63+
const s3Cdn = configs.find((item: any) => item.config_key === 's3_cdn')?.config_value
64+
const s3CdnUrl = configs.find((item: any) => item.config_key === 's3_cdn_url')?.config_value || ''
65+
if (s3Cdn && s3Cdn === 'true') {
66+
body.url = `https://${s3CdnUrl.includes('https://') ? s3CdnUrl.split('//')[1] : s3CdnUrl}/${url}`
67+
}
68+
} else if (r2DirectUpload) {
69+
const publicDomain = configs.find((item: any) => item.config_key === 'r2_public_domain')?.config_value || ''
70+
if (publicDomain) {
71+
body.url = `https://${publicDomain}/${url}`
72+
}
73+
}
74+
}
75+
76+
// 处理预览图片 URL
77+
if (previewUrl && !previewUrl.startsWith('http://') && !previewUrl.startsWith('https://')) {
78+
if (s3DirectUpload) {
79+
const s3Cdn = configs.find((item: any) => item.config_key === 's3_cdn')?.config_value
80+
const s3CdnUrl = configs.find((item: any) => item.config_key === 's3_cdn_url')?.config_value || ''
81+
if (s3Cdn && s3Cdn === 'true') {
82+
body.preview_url = `https://${s3CdnUrl.includes('https://') ? s3CdnUrl.split('//')[1] : s3CdnUrl}/${previewUrl}`
83+
}
84+
} else if (r2DirectUpload) {
85+
const publicDomain = configs.find((item: any) => item.config_key === 'r2_public_domain')?.config_value || ''
86+
if (publicDomain) {
87+
body.preview_url = `https://${publicDomain}/${previewUrl}`
88+
}
89+
}
90+
}
91+
}
92+
93+
// 保存图片信息
94+
const res = await insertImage(body)
95+
return Response.json({
96+
code: 200,
97+
data: res
98+
})
2999
} catch (e) {
30100
throw new HTTPException(500, { message: 'Failed', cause: e })
31101
}

hono/settings.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ app.get('/r2-info', async (c) => {
4242
'r2_endpoint',
4343
'r2_bucket',
4444
'r2_storage_folder',
45-
'r2_public_domain'
45+
'r2_public_domain',
46+
'r2_direct_upload'
4647
])
4748
return c.json(data)
4849
})
@@ -68,7 +69,8 @@ app.get('/s3-info', async (c) => {
6869
'storage_folder',
6970
'force_path_style',
7071
's3_cdn',
71-
's3_cdn_url'
72+
's3_cdn_url',
73+
's3_direct_upload'
7274
])
7375
return c.json(data)
7476
})
@@ -92,8 +94,9 @@ app.put('/update-r2-info', async (c) => {
9294
const r2Bucket = query?.find((item: Config) => item.config_key === 'r2_bucket').config_value
9395
const r2StorageFolder = query?.find((item: Config) => item.config_key === 'r2_storage_folder').config_value
9496
const r2PublicDomain = query?.find((item: Config) => item.config_key === 'r2_public_domain').config_value
97+
const r2DirectUpload = query?.find((item: Config) => item.config_key === 'r2_direct_upload').config_value
9598

96-
const data = await updateR2Config({ r2AccesskeyId, r2AccesskeySecret, r2Endpoint, r2Bucket, r2StorageFolder, r2PublicDomain })
99+
const data = await updateR2Config({ r2AccesskeyId, r2AccesskeySecret, r2Endpoint, r2Bucket, r2StorageFolder, r2PublicDomain, r2DirectUpload })
97100
return c.json(data)
98101
})
99102

@@ -109,8 +112,9 @@ app.put('/update-s3-info', async (c) => {
109112
const forcePathStyle = query?.find((item: Config) => item.config_key === 'force_path_style').config_value
110113
const s3Cdn = query?.find((item: Config) => item.config_key === 's3_cdn').config_value
111114
const s3CdnUrl = query?.find((item: Config) => item.config_key === 's3_cdn_url').config_value
115+
const s3DirectUpload = query?.find((item: Config) => item.config_key === 's3_direct_upload').config_value
112116

113-
const data = await updateS3Config({ accesskeyId, accesskeySecret, region, endpoint, bucket, storageFolder, forcePathStyle, s3Cdn, s3CdnUrl })
117+
const data = await updateS3Config({ accesskeyId, accesskeySecret, region, endpoint, bucket, storageFolder, forcePathStyle, s3Cdn, s3CdnUrl, s3DirectUpload })
114118
return c.json(data)
115119
})
116120

instrumentation.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export async function register() {
2727
{ config_key: 'force_path_style', config_value: 'false', detail: '是否强制客户端对桶使用路径式寻址,默认 false。' },
2828
{ config_key: 's3_cdn', config_value: 'false', detail: '是否启用 S3 CDN 模式,路径将返回 cdn 地址,默认 false。' },
2929
{ config_key: 's3_cdn_url', config_value: '', detail: 'cdn 地址,如:https://cdn.example.com' },
30+
{ config_key: 's3_direct_upload', config_value: 'false', detail: '是否启用 S3 直传模式,默认 false。' },
3031
{ config_key: 'alist_token', config_value: '', detail: 'alist 令牌' },
3132
{ config_key: 'alist_url', config_value: '', detail: 'AList 地址,如:https://alist.besscroft.com' },
3233
{ config_key: 'secret_key', config_value: 'pic-impact', detail: 'SECRET_KEY' },
@@ -36,6 +37,7 @@ export async function register() {
3637
{ config_key: 'r2_bucket', config_value: '', detail: 'Cloudflare Bucket 存储桶名称,如:picimpact' },
3738
{ config_key: 'r2_storage_folder', config_value: '', detail: '存储文件夹(Cloudflare R2),严格格式,如:picimpact 或 picimpact/images ,填 / 或者不填表示根路径' },
3839
{ config_key: 'r2_public_domain', config_value: '', detail: 'Cloudflare R2 自定义域(公开访问)' },
40+
{ config_key: 'r2_direct_upload', config_value: 'false', detail: '是否启用 R2 直传模式,默认 false。' },
3941
{ config_key: 'custom_title', config_value: 'PicImpact', detail: '网站标题' },
4042
{ config_key: 'auth_enable', config_value: 'false', detail: '是否启用双因素验证' },
4143
{ config_key: 'auth_temp_secret', config_value: '', detail: '双因素验证临时种子密钥' },

lib/utils/file.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,49 @@ export async function exifReader(file: ArrayBuffer | SharedArrayBuffer | Buffer)
5656
* @param storage storage 存储类型
5757
* @param mountPath 文件挂载路径(目前只有 alist 用得到
5858
*/
59-
export async function uploadFile(file: any, type: string, storage: string, mountPath: string) {
59+
export async function uploadFile(file: File, type: string, storage: string, mountPath: string) {
6060
const formData = new FormData()
6161
formData.append('file', file)
6262
formData.append('storage', storage)
6363
formData.append('type', type)
64-
formData.append('mountPath', mountPath)
65-
return await fetch('/api/v1/file/upload', {
64+
if (mountPath) {
65+
formData.append('mountPath', mountPath)
66+
}
67+
68+
const res = await fetch('/api/v1/file/upload', {
6669
method: 'POST',
67-
body: formData
68-
}).then((res) => res.json())
70+
body: formData,
71+
credentials: 'include',
72+
headers: {
73+
'Accept': 'application/json',
74+
}
75+
}).then(res => res.json())
76+
77+
if (res?.code === 200) {
78+
if (res.data.upload_url) {
79+
// 直传模式
80+
try {
81+
const response = await fetch(res.data.upload_url, {
82+
method: 'PUT',
83+
body: file,
84+
headers: {
85+
'Content-Type': file.type,
86+
},
87+
credentials: 'omit'
88+
})
89+
if (!response.ok) {
90+
throw new Error(`Upload failed with status: ${response.status}`)
91+
}
92+
return {
93+
code: 200,
94+
data: res.data.key
95+
}
96+
} catch (error) {
97+
console.error('Direct upload failed:', error)
98+
throw new Error('Upload failed')
99+
}
100+
}
101+
return res
102+
}
103+
throw new Error('Upload failed')
69104
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"dependencies": {
2525
"@auth/prisma-adapter": "^2.7.4",
2626
"@aws-sdk/client-s3": "^3.750.0",
27+
"@aws-sdk/s3-request-presigner": "^3.810.0",
2728
"@hono/node-server": "^1.13.8",
2829
"@hookform/resolvers": "^3.10.0",
2930
"@prisma/client": "6.4.1",

0 commit comments

Comments
 (0)