release: PicHost v1.0.0
Publish Docker image / build-and-push (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build-and-push (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Multi-user image hosting with RBAC, upload preferences, and admin settings.
This commit is contained in:
commit
bc4cba7e52
115 files changed
+26508
No files matched your search
@@ -0,0 +1,194 @@
|
||||
import sharp from 'sharp'
|
||||
import type { H3Event } from 'h3'
|
||||
import type { ImageItem, UploadErrorItem } from '~/types/image'
|
||||
import type { AllowedMimeType } from './constants'
|
||||
import {
|
||||
ALLOWED_MIME_TYPES,
|
||||
MAX_FILE_SIZE
|
||||
} from './constants'
|
||||
import {
|
||||
detectMimeFromSignature,
|
||||
isAllowedMimeType,
|
||||
validateFileSignature
|
||||
} from './file-signature'
|
||||
import type { LogSource } from './db'
|
||||
import { generateImageKey } from './image-key'
|
||||
import { buildImageItem, sanitizeOriginalName } from './image-response'
|
||||
import { getImageBaseUrl, getWebpQuality } from './env'
|
||||
import { insertActivityLog } from './db'
|
||||
import { putImage } from './storage'
|
||||
import { logInfo, logWarn } from './logger'
|
||||
|
||||
/** 可转 WebP 的位图格式;SVG(矢量)、ICO、已是 WebP 的原样存储 */
|
||||
const WEBP_CONVERTIBLE: readonly AllowedMimeType[] = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif'
|
||||
]
|
||||
|
||||
/**
|
||||
* 服务端统一转 WebP(浏览器直传、Twikoo/EasyImage、油猴脚本一视同仁)。
|
||||
* 转换失败或转出来更大时,保留原格式存储。
|
||||
*/
|
||||
async function compressToWebp(
|
||||
bytes: Uint8Array,
|
||||
mime: AllowedMimeType,
|
||||
quality: number
|
||||
): Promise<{ bytes: Uint8Array, mime: AllowedMimeType }> {
|
||||
if (!WEBP_CONVERTIBLE.includes(mime)) {
|
||||
return { bytes, mime }
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await sharp(Buffer.from(bytes), {
|
||||
// GIF 保留动画帧
|
||||
animated: mime === 'image/gif'
|
||||
})
|
||||
.webp({ quality })
|
||||
.toBuffer()
|
||||
|
||||
if (output.byteLength >= bytes.byteLength) {
|
||||
return { bytes, mime }
|
||||
}
|
||||
|
||||
return { bytes: new Uint8Array(output), mime: 'image/webp' }
|
||||
} catch {
|
||||
return { bytes, mime }
|
||||
}
|
||||
}
|
||||
|
||||
export async function processSingleImageUpload(
|
||||
event: H3Event,
|
||||
input: {
|
||||
bytes: Uint8Array
|
||||
filename?: string
|
||||
prefix?: string
|
||||
source?: LogSource
|
||||
userId?: number | null
|
||||
}
|
||||
): Promise<{ item: ImageItem } | { error: UploadErrorItem }> {
|
||||
const originalName = sanitizeOriginalName(input.filename ?? 'image')
|
||||
const bytes = input.bytes
|
||||
|
||||
if (bytes.byteLength > MAX_FILE_SIZE) {
|
||||
return {
|
||||
error: {
|
||||
name: originalName,
|
||||
error: {
|
||||
code: 'FILE_TOO_LARGE',
|
||||
message: '图片大小不能超过 10 MB'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const detectedMime = detectMimeFromSignature(bytes)
|
||||
if (!detectedMime) {
|
||||
return {
|
||||
error: {
|
||||
name: originalName,
|
||||
error: {
|
||||
code: 'INVALID_FILE_SIGNATURE',
|
||||
message: '无法识别的图片格式'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ALLOWED_MIME_TYPES.includes(detectedMime)) {
|
||||
return {
|
||||
error: {
|
||||
name: originalName,
|
||||
error: {
|
||||
code: 'UNSUPPORTED_FILE_TYPE',
|
||||
message: '不支持的图片格式'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!validateFileSignature(detectedMime, bytes)) {
|
||||
return {
|
||||
error: {
|
||||
name: originalName,
|
||||
error: {
|
||||
code: 'INVALID_FILE_SIGNATURE',
|
||||
message: '文件签名校验失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const compressed = await compressToWebp(bytes, detectedMime, getWebpQuality(event))
|
||||
|
||||
const imageBaseUrl = getImageBaseUrl(event)
|
||||
const uploadedAt = new Date().toISOString()
|
||||
const key = generateImageKey(
|
||||
compressed.mime,
|
||||
new Date(uploadedAt),
|
||||
input.prefix ?? 'images'
|
||||
)
|
||||
|
||||
try {
|
||||
await putImage(key, compressed.bytes, {
|
||||
originalName,
|
||||
uploadedAt,
|
||||
contentType: compressed.mime,
|
||||
size: compressed.bytes.byteLength,
|
||||
userId: input.userId ?? null
|
||||
})
|
||||
} catch {
|
||||
logWarn('upload failed: storage error', {
|
||||
name: originalName,
|
||||
source: input.source ?? 'web'
|
||||
})
|
||||
return {
|
||||
error: {
|
||||
name: originalName,
|
||||
error: {
|
||||
code: 'UPLOAD_FAILED',
|
||||
message: '上传失败,请稍后重试'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
insertActivityLog({
|
||||
action: 'upload',
|
||||
key,
|
||||
originalName,
|
||||
size: compressed.bytes.byteLength,
|
||||
contentType: compressed.mime,
|
||||
source: input.source ?? 'web',
|
||||
createdAt: uploadedAt
|
||||
})
|
||||
|
||||
logInfo('upload success', {
|
||||
key,
|
||||
size: compressed.bytes.byteLength,
|
||||
contentType: compressed.mime,
|
||||
source: input.source ?? 'web'
|
||||
})
|
||||
|
||||
return {
|
||||
item: buildImageItem({
|
||||
key,
|
||||
baseUrl: imageBaseUrl,
|
||||
originalName,
|
||||
contentType: compressed.mime,
|
||||
size: compressed.bytes.byteLength,
|
||||
uploadedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 与 multipart 声明的 MIME 交叉校验(可选) */
|
||||
export function mimeMatchesDeclared(
|
||||
declaredMime: string,
|
||||
detectedMime: string
|
||||
): boolean {
|
||||
if (!declaredMime || !isAllowedMimeType(declaredMime)) {
|
||||
return true
|
||||
}
|
||||
return declaredMime === detectedMime
|
||||
}
|
||||
Reference in new issue
Block a user