release: v1.1.0 — S3 multi-backend storage and admin UI
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s

This commit is contained in:
O96u committed 2026-08-26 14:49:46 +08:00
1 parent 7c45a78572
commit acc64751ef
46 files changed
+4005 -480

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
export { getActiveBackend, getBackendForKey, getActiveBackendRow, getBackendRowForKey } from './resolver'
export { LocalStorageBackend } from './local'
export { S3StorageBackend, buildObjectKey, parseS3Config, validateS3Config } from './s3'
+116
View File
@@ -0,0 +1,116 @@
import { createReadStream, promises as fs } from 'node:fs'
import type { ReadStream } from 'node:fs'
import { dirname, join } from 'node:path'
import { getDataDir } from '../data-dir'
import { validateImageKey } from '../image-key'
import type { StoredImage, StoredImageMeta, StorageBackend } from './types'
const META_SUFFIX = '.meta.json'
function keyToFilePath(key: string): string {
return join(getDataDir(), ...key.split('/'))
}
function contentTypeFromKey(key: string): string {
const ext = key.split('.').pop()?.toLowerCase()
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
case 'gif':
return 'image/gif'
case 'webp':
return 'image/webp'
case 'svg':
return 'image/svg+xml'
case 'ico':
return 'image/x-icon'
default:
return 'application/octet-stream'
}
}
async function readMeta(key: string): Promise<Partial<StoredImageMeta>> {
try {
const raw = await fs.readFile(keyToFilePath(key) + META_SUFFIX, 'utf8')
return JSON.parse(raw) as Partial<StoredImageMeta>
} catch {
return {}
}
}
export class LocalStorageBackend implements StorageBackend {
readonly id: string
readonly type = 'local' as const
readonly servingMode: 'proxy' | 'public'
readonly publicUrl: string
constructor(
id = 'local',
servingMode: 'proxy' | 'public' = 'proxy',
publicUrl = ''
) {
this.id = id
this.servingMode = servingMode
this.publicUrl = publicUrl
}
async put(key: string, bytes: Uint8Array, meta: StoredImageMeta): Promise<void> {
const filePath = keyToFilePath(key)
await fs.mkdir(dirname(filePath), { recursive: true })
await fs.writeFile(filePath, bytes)
await fs.writeFile(filePath + META_SUFFIX, JSON.stringify(meta))
}
async head(key: string): Promise<StoredImage | null> {
if (!validateImageKey(key)) return null
let stat
try {
stat = await fs.stat(keyToFilePath(key))
} catch {
return null
}
if (!stat.isFile()) return null
const meta = await readMeta(key)
return {
key,
size: stat.size,
mtimeMs: stat.mtimeMs,
contentType: meta.contentType ?? contentTypeFromKey(key),
originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
uploadedAt: meta.uploadedAt ?? stat.mtime.toISOString(),
userId: meta.userId ?? null,
backendId: this.id
}
}
async delete(key: string): Promise<void> {
const filePath = keyToFilePath(key)
await fs.unlink(filePath)
await fs.unlink(filePath + META_SUFFIX).catch(() => {})
}
async createStream(
key: string,
range?: { start: number, end: number }
): Promise<ReadStream> {
return createReadStream(keyToFilePath(key), range)
}
async testConnection(): Promise<{ ok: boolean, message: string }> {
try {
await fs.access(getDataDir())
return { ok: true, message: '本地磁盘可访问' }
} catch {
return { ok: false, message: '无法访问数据目录' }
}
}
async getUsageStats(): Promise<{ count: number, bytes: number }> {
return { count: 0, bytes: 0 }
}
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { LOCAL_BACKEND_ID } from '../storage-backends'
describe('storage resolver constants', () => {
it('uses stable local backend id', () => {
expect(LOCAL_BACKEND_ID).toBe('local')
})
})
describe('default backend routing', () => {
it('returns local when no env override and local is default', async () => {
const original = process.env.STORAGE_BACKEND
delete process.env.STORAGE_BACKEND
const { setDefaultBackend, LOCAL_BACKEND_ID } = await import('../storage-backends')
setDefaultBackend(LOCAL_BACKEND_ID)
const { getActiveBackend } = await import('./resolver')
const backend = await getActiveBackend()
expect(backend.type).toBe('local')
if (original !== undefined) {
process.env.STORAGE_BACKEND = original
}
})
})
+115
View File
@@ -0,0 +1,115 @@
import {
createBackendFromEnv,
createBackendInstance,
getBackendIdForKey,
getDefaultBackendRow,
getStorageBackendRow,
LOCAL_BACKEND_ID,
ensureDefaultBackends
} from '../storage-backends'
import type { StorageBackend, StorageBackendRow } from './types'
export async function getActiveBackend(): Promise<StorageBackend> {
const fromEnv = createBackendFromEnv()
if (fromEnv) return fromEnv
ensureDefaultBackends()
const row = getDefaultBackendRow()
if (!row) {
return createBackendInstance({
id: LOCAL_BACKEND_ID,
name: '本地磁盘',
type: 'local',
config_json: '{}',
secret_json: '{}',
serving_mode: 'proxy',
public_url: '',
quota_bytes: null,
enabled: 1,
is_default: 1,
sort_order: 0
})
}
return createBackendInstance(row)
}
export async function getBackendForKey(key: string): Promise<StorageBackend> {
const fromEnv = createBackendFromEnv()
if (fromEnv) return fromEnv
ensureDefaultBackends()
const backendId = getBackendIdForKey(key) ?? LOCAL_BACKEND_ID
const row = getStorageBackendRow(backendId)
if (!row || !row.enabled) {
return createBackendInstance(fallbackLocalRow())
}
try {
return createBackendInstance(row)
} catch {
return createBackendInstance(fallbackLocalRow())
}
}
export function getActiveBackendRow(): StorageBackendRow | null {
const fromEnv = createBackendFromEnv()
if (fromEnv) {
return {
id: fromEnv.id,
name: 'S3 (环境变量)',
type: 's3',
config_json: '{}',
secret_json: '{}',
serving_mode: fromEnv.servingMode,
public_url: fromEnv.publicUrl,
quota_bytes: null,
enabled: 1,
is_default: 1,
sort_order: 0
}
}
ensureDefaultBackends()
return getDefaultBackendRow()
}
export async function getBackendRowForKey(key: string): Promise<StorageBackendRow> {
const fromEnv = createBackendFromEnv()
if (fromEnv) {
return {
id: fromEnv.id,
name: 'S3 (环境变量)',
type: 's3',
config_json: '{}',
secret_json: '{}',
serving_mode: fromEnv.servingMode,
public_url: fromEnv.publicUrl,
quota_bytes: null,
enabled: 1,
is_default: 1,
sort_order: 0
}
}
ensureDefaultBackends()
const backendId = getBackendIdForKey(key) ?? LOCAL_BACKEND_ID
return getStorageBackendRow(backendId) ?? fallbackLocalRow()
}
function fallbackLocalRow(): StorageBackendRow {
return {
id: LOCAL_BACKEND_ID,
name: '本地磁盘',
type: 'local',
config_json: '{}',
secret_json: '{}',
serving_mode: 'proxy',
public_url: '',
quota_bytes: null,
enabled: 1,
is_default: 1,
sort_order: 0
}
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { buildObjectKey, parseS3Config, validateS3Config } from './s3'
describe('S3 backend helpers', () => {
it('buildObjectKey joins prefix and key', () => {
expect(buildObjectKey('pichost', 'images/2026/01/a.webp')).toBe(
'pichost/images/2026/01/a.webp'
)
expect(buildObjectKey(undefined, 'images/a.webp')).toBe('images/a.webp')
expect(buildObjectKey('/pichost/', 'images/a.webp')).toBe('pichost/images/a.webp')
})
it('parseS3Config requires endpoint, region and bucket', () => {
expect(parseS3Config('{}')).toBeNull()
expect(parseS3Config(JSON.stringify({
endpoint: 'https://example.r2.cloudflarestorage.com',
region: 'auto',
bucket: 'pics'
}))).toEqual({
endpoint: 'https://example.r2.cloudflarestorage.com',
region: 'auto',
bucket: 'pics',
prefix: undefined,
forcePathStyle: false
})
})
it('validateS3Config rejects invalid endpoint', () => {
expect(validateS3Config({
endpoint: 'not-a-url',
region: 'auto',
bucket: 'pics'
})).toBeTruthy()
expect(validateS3Config({
endpoint: 'https://example.com',
region: 'auto',
bucket: ''
})).toBeTruthy()
expect(validateS3Config({
endpoint: 'https://example.com',
region: 'auto',
bucket: 'pics'
})).toBeNull()
})
})
+218
View File
@@ -0,0 +1,218 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client
} from '@aws-sdk/client-s3'
import type { Readable } from 'node:stream'
import { validateImageKey } from '../image-key'
import type {
S3BackendConfig,
S3BackendSecrets,
ServingMode,
StoredImage,
StoredImageMeta,
StorageBackend
} from './types'
function contentTypeFromKey(key: string): string {
const ext = key.split('.').pop()?.toLowerCase()
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
case 'gif':
return 'image/gif'
case 'webp':
return 'image/webp'
case 'svg':
return 'image/svg+xml'
case 'ico':
return 'image/x-icon'
default:
return 'application/octet-stream'
}
}
export function buildObjectKey(prefix: string | undefined, key: string): string {
const trimmed = prefix?.replace(/^\/+|\/+$/g, '') ?? ''
return trimmed ? `${trimmed}/${key}` : key
}
export function parseS3Config(raw: string): S3BackendConfig | null {
try {
const parsed = JSON.parse(raw) as Partial<S3BackendConfig>
if (!parsed.endpoint || !parsed.region || !parsed.bucket) return null
return {
endpoint: parsed.endpoint,
region: parsed.region,
bucket: parsed.bucket,
prefix: parsed.prefix,
forcePathStyle: parsed.forcePathStyle ?? false
}
} catch {
return null
}
}
export function parseS3Secrets(raw: string): S3BackendSecrets | null {
try {
const parsed = JSON.parse(raw) as Partial<S3BackendSecrets>
if (!parsed.accessKeyId || !parsed.secretAccessKey) return null
return {
accessKeyId: parsed.accessKeyId,
secretAccessKey: parsed.secretAccessKey
}
} catch {
return null
}
}
export function validateS3Config(config: S3BackendConfig): string | null {
if (!config.endpoint.startsWith('http')) {
return 'Endpoint 需为 http(s) 地址'
}
if (!config.bucket.trim()) {
return 'Bucket 不能为空'
}
return null
}
export class S3StorageBackend implements StorageBackend {
readonly id: string
readonly type = 's3' as const
readonly servingMode: ServingMode
readonly publicUrl: string
private readonly client: S3Client
private readonly bucket: string
private readonly prefix: string | undefined
constructor(
id: string,
config: S3BackendConfig,
secrets: S3BackendSecrets,
servingMode: ServingMode = 'proxy',
publicUrl = ''
) {
this.id = id
this.servingMode = servingMode
this.publicUrl = publicUrl
this.bucket = config.bucket
this.prefix = config.prefix
this.client = new S3Client({
endpoint: config.endpoint,
region: config.region,
credentials: {
accessKeyId: secrets.accessKeyId,
secretAccessKey: secrets.secretAccessKey
},
forcePathStyle: config.forcePathStyle ?? false
})
}
private objectKey(key: string): string {
return buildObjectKey(this.prefix, key)
}
async put(key: string, bytes: Uint8Array, meta: StoredImageMeta): Promise<void> {
await this.client.send(new PutObjectCommand({
Bucket: this.bucket,
Key: this.objectKey(key),
Body: bytes,
ContentType: meta.contentType,
Metadata: {
originalName: meta.originalName,
uploadedAt: meta.uploadedAt,
userId: meta.userId != null ? String(meta.userId) : ''
}
}))
}
async head(key: string): Promise<StoredImage | null> {
if (!validateImageKey(key)) return null
try {
const response = await this.client.send(new HeadObjectCommand({
Bucket: this.bucket,
Key: this.objectKey(key)
}))
const uploadedAt = response.Metadata?.uploadedat
?? response.LastModified?.toISOString()
?? new Date().toISOString()
const originalName = response.Metadata?.originalname
?? key.split('/').pop()
?? 'image'
const userIdRaw = response.Metadata?.userid
const userId = userIdRaw ? Number(userIdRaw) : null
return {
key,
size: response.ContentLength ?? 0,
mtimeMs: response.LastModified?.getTime() ?? Date.now(),
contentType: response.ContentType ?? contentTypeFromKey(key),
originalName,
uploadedAt,
userId: Number.isFinite(userId) ? userId : null,
backendId: this.id
}
} catch {
return null
}
}
async delete(key: string): Promise<void> {
await this.client.send(new DeleteObjectCommand({
Bucket: this.bucket,
Key: this.objectKey(key)
}))
}
async createStream(
key: string,
range?: { start: number, end: number }
): Promise<Readable> {
const response = await this.client.send(new GetObjectCommand({
Bucket: this.bucket,
Key: this.objectKey(key),
Range: range ? `bytes=${range.start}-${range.end}` : undefined
}))
if (!response.Body) {
throw new Error('Empty S3 response body')
}
return response.Body as Readable
}
async testConnection(): Promise<{ ok: boolean, message: string }> {
const probeKey = `__pichost-probe-${Date.now()}.txt`
const probeBytes = new TextEncoder().encode('pichost-probe')
try {
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }))
await this.client.send(new PutObjectCommand({
Bucket: this.bucket,
Key: buildObjectKey(this.prefix, probeKey),
Body: probeBytes,
ContentType: 'text/plain'
}))
await this.client.send(new DeleteObjectCommand({
Bucket: this.bucket,
Key: buildObjectKey(this.prefix, probeKey)
}))
return { ok: true, message: '连接成功' }
} catch (error) {
const message = error instanceof Error ? error.message : '连接失败'
return { ok: false, message }
}
}
async getUsageStats(): Promise<{ count: number, bytes: number }> {
return { count: 0, bytes: 0 }
}
}
+85
View File
@@ -0,0 +1,85 @@
import type { Readable } from 'node:stream'
export interface StoredImageMeta {
originalName: string
uploadedAt: string
contentType: string
size: number
userId?: number | null
}
export interface StoredImage extends StoredImageMeta {
key: string
mtimeMs: number
backendId?: string
}
export interface PaginatedResult<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export interface FolderStorageStat {
folder: string
count: number
bytes: number
}
export type StorageBackendType = 'local' | 's3'
export type ServingMode = 'proxy' | 'public'
export interface S3BackendConfig {
endpoint: string
region: string
bucket: string
prefix?: string
forcePathStyle?: boolean
}
export interface S3BackendSecrets {
accessKeyId: string
secretAccessKey: string
}
export interface StorageBackendRow {
id: string
name: string
type: StorageBackendType
config_json: string
secret_json: string
serving_mode: ServingMode
public_url: string
quota_bytes: number | null
enabled: number
is_default: number
sort_order: number
}
export interface StorageBackendInfo {
id: string
name: string
type: StorageBackendType
config: S3BackendConfig | Record<string, never>
servingMode: ServingMode
publicUrl: string
quotaBytes: number | null
enabled: boolean
isDefault: boolean
sortOrder: number
}
export interface StorageBackend {
readonly id: string
readonly type: StorageBackendType
readonly servingMode: ServingMode
readonly publicUrl: string
put(key: string, bytes: Uint8Array, meta: StoredImageMeta): Promise<void>
head(key: string): Promise<StoredImage | null>
delete(key: string): Promise<void>
createStream(key: string, range?: { start: number, end: number }): Promise<Readable>
testConnection(): Promise<{ ok: boolean, message: string }>
getUsageStats(): Promise<{ count: number, bytes: number }>
}