462 lines
13 KiB
TypeScript
462 lines
13 KiB
TypeScript
import type { DatabaseSync } from 'node:sqlite'
|
|
import { getDb } from './db'
|
|
import { LocalStorageBackend } from './storage/local'
|
|
import {
|
|
parseS3Config,
|
|
parseS3Secrets,
|
|
S3StorageBackend
|
|
} from './storage/s3'
|
|
import type {
|
|
S3BackendConfig,
|
|
ServingMode,
|
|
StorageBackendInfo,
|
|
StorageBackendRow,
|
|
StorageBackendType
|
|
} from './storage/types'
|
|
|
|
export const LOCAL_BACKEND_ID = 'local'
|
|
export const S3_BACKEND_ID = 's3-primary'
|
|
|
|
function migrateStorageTables(database: DatabaseSync): void {
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS storage_backends (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
type TEXT NOT NULL CHECK(type IN ('local', 's3')),
|
|
config_json TEXT NOT NULL DEFAULT '{}',
|
|
secret_json TEXT NOT NULL DEFAULT '{}',
|
|
serving_mode TEXT NOT NULL DEFAULT 'proxy' CHECK(serving_mode IN ('proxy', 'public')),
|
|
public_url TEXT NOT NULL DEFAULT '',
|
|
quota_bytes INTEGER,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
is_default INTEGER NOT NULL DEFAULT 0,
|
|
sort_order INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE TABLE IF NOT EXISTS images (
|
|
key TEXT PRIMARY KEY,
|
|
backend_id TEXT NOT NULL,
|
|
user_id INTEGER,
|
|
folder TEXT NOT NULL,
|
|
original_name TEXT NOT NULL,
|
|
content_type TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
uploaded_at TEXT NOT NULL,
|
|
FOREIGN KEY (backend_id) REFERENCES storage_backends(id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_images_backend_id ON images(backend_id);
|
|
CREATE INDEX IF NOT EXISTS idx_images_user_id ON images(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_images_folder ON images(folder);
|
|
`)
|
|
}
|
|
|
|
export function ensureStorageSchema(): void {
|
|
migrateStorageTables(getDb())
|
|
}
|
|
|
|
export function ensureDefaultBackends(): void {
|
|
ensureStorageSchema()
|
|
const db = getDb()
|
|
|
|
const local = db.prepare(`
|
|
SELECT id FROM storage_backends WHERE id = ?
|
|
`).get(LOCAL_BACKEND_ID)
|
|
|
|
if (!local) {
|
|
db.prepare(`
|
|
INSERT INTO storage_backends
|
|
(id, name, type, config_json, secret_json, serving_mode, public_url,
|
|
quota_bytes, enabled, is_default, sort_order)
|
|
VALUES (?, ?, 'local', '{}', '{}', 'proxy', '', NULL, 1, 1, 0)
|
|
`).run(LOCAL_BACKEND_ID, '本地磁盘')
|
|
}
|
|
|
|
cleanupPlaceholderS3Backend(db)
|
|
}
|
|
|
|
/** 移除早期版本自动种下的空 S3 占位(未配置、未启用、非默认、无图片) */
|
|
function cleanupPlaceholderS3Backend(db: DatabaseSync): void {
|
|
const row = db.prepare(`
|
|
SELECT * FROM storage_backends WHERE id = ?
|
|
`).get(S3_BACKEND_ID) as StorageBackendRow | undefined
|
|
|
|
if (!row || row.type !== 's3' || row.is_default === 1) {
|
|
return
|
|
}
|
|
|
|
const config = parseS3Config(row.config_json)
|
|
const secrets = parseS3Secrets(row.secret_json)
|
|
const configured = Boolean(
|
|
config?.bucket?.trim()
|
|
|| config?.endpoint?.trim()
|
|
|| secrets?.accessKeyId?.trim()
|
|
|| secrets?.secretAccessKey?.trim()
|
|
)
|
|
if (configured) {
|
|
return
|
|
}
|
|
|
|
const usage = getBackendUsageStats(S3_BACKEND_ID)
|
|
if (usage.count > 0) {
|
|
return
|
|
}
|
|
|
|
db.prepare('DELETE FROM storage_backends WHERE id = ?').run(S3_BACKEND_ID)
|
|
}
|
|
|
|
function rowToInfo(row: StorageBackendRow, _maskSecrets = true): StorageBackendInfo & {
|
|
secretsMasked: Record<string, string>
|
|
} {
|
|
const config = row.type === 's3'
|
|
? (parseS3Config(row.config_json) ?? {})
|
|
: {}
|
|
let secretsMasked: Record<string, string> = {}
|
|
if (row.type === 's3') {
|
|
const secrets = parseS3Secrets(row.secret_json)
|
|
secretsMasked = {
|
|
accessKeyId: secrets?.accessKeyId
|
|
? maskSecret(secrets.accessKeyId)
|
|
: '',
|
|
secretAccessKey: secrets?.secretAccessKey
|
|
? maskSecret(secrets.secretAccessKey)
|
|
: ''
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
type: row.type,
|
|
config,
|
|
servingMode: row.serving_mode,
|
|
publicUrl: row.public_url,
|
|
quotaBytes: row.quota_bytes,
|
|
enabled: row.enabled === 1,
|
|
isDefault: row.is_default === 1,
|
|
sortOrder: row.sort_order,
|
|
secretsMasked
|
|
}
|
|
}
|
|
|
|
function maskSecret(value: string): string {
|
|
if (value.length <= 4) return '****'
|
|
return `${value.slice(0, 4)}${'*'.repeat(Math.min(8, value.length - 4))}`
|
|
}
|
|
|
|
export function listStorageBackendRows(): StorageBackendRow[] {
|
|
ensureDefaultBackends()
|
|
return getDb().prepare(`
|
|
SELECT id, name, type, config_json, secret_json, serving_mode, public_url,
|
|
quota_bytes, enabled, is_default, sort_order
|
|
FROM storage_backends
|
|
ORDER BY sort_order ASC, id ASC
|
|
`).all() as unknown as StorageBackendRow[]
|
|
}
|
|
|
|
export function listStorageBackends(maskSecrets = true): Array<StorageBackendInfo & {
|
|
secretsMasked: Record<string, string>
|
|
}> {
|
|
return listStorageBackendRows().map(row => rowToInfo(row, maskSecrets))
|
|
}
|
|
|
|
export function listStorageBackendNameMap(): Map<string, {
|
|
name: string
|
|
type: StorageBackendType
|
|
}> {
|
|
ensureStorageSchema()
|
|
const rows = getDb().prepare(`
|
|
SELECT id, name, type FROM storage_backends
|
|
`).all() as Array<{ id: string, name: string, type: StorageBackendType }>
|
|
return new Map(rows.map(row => [row.id, { name: row.name, type: row.type }]))
|
|
}
|
|
|
|
export function getStorageBackendRow(id: string): StorageBackendRow | null {
|
|
ensureDefaultBackends()
|
|
const row = getDb().prepare(`
|
|
SELECT id, name, type, config_json, secret_json, serving_mode, public_url,
|
|
quota_bytes, enabled, is_default, sort_order
|
|
FROM storage_backends
|
|
WHERE id = ?
|
|
`).get(id) as StorageBackendRow | undefined
|
|
return row ?? null
|
|
}
|
|
|
|
export function getDefaultBackendRow(): StorageBackendRow | null {
|
|
ensureDefaultBackends()
|
|
const row = getDb().prepare(`
|
|
SELECT id, name, type, config_json, secret_json, serving_mode, public_url,
|
|
quota_bytes, enabled, is_default, sort_order
|
|
FROM storage_backends
|
|
WHERE is_default = 1 AND enabled = 1
|
|
LIMIT 1
|
|
`).get() as StorageBackendRow | undefined
|
|
return row ?? null
|
|
}
|
|
|
|
export function getBackendIdForKey(key: string): string | null {
|
|
ensureStorageSchema()
|
|
const row = getDb().prepare(`
|
|
SELECT backend_id FROM images WHERE key = ?
|
|
`).get(key) as { backend_id: string } | undefined
|
|
return row?.backend_id ?? null
|
|
}
|
|
|
|
export function setDefaultBackend(id: string): void {
|
|
ensureDefaultBackends()
|
|
const db = getDb()
|
|
db.exec('UPDATE storage_backends SET is_default = 0')
|
|
db.prepare(`
|
|
UPDATE storage_backends SET is_default = 1, enabled = 1 WHERE id = ?
|
|
`).run(id)
|
|
}
|
|
|
|
export interface StorageBackendPatch {
|
|
name?: string
|
|
config?: Record<string, unknown>
|
|
secrets?: { accessKeyId?: string, secretAccessKey?: string }
|
|
servingMode?: ServingMode
|
|
publicUrl?: string
|
|
quotaBytes?: number | null
|
|
enabled?: boolean
|
|
isDefault?: boolean
|
|
}
|
|
|
|
export function updateStorageBackend(id: string, patch: StorageBackendPatch): void {
|
|
const row = getStorageBackendRow(id)
|
|
if (!row) {
|
|
throw new Error(`Storage backend not found: ${id}`)
|
|
}
|
|
|
|
const name = patch.name ?? row.name
|
|
let configJson = row.config_json
|
|
let secretJson = row.secret_json
|
|
|
|
if (patch.config !== undefined) {
|
|
const current = row.type === 's3'
|
|
? (parseS3Config(row.config_json) ?? {})
|
|
: {}
|
|
configJson = JSON.stringify({ ...current, ...patch.config })
|
|
}
|
|
|
|
if (patch.secrets !== undefined && row.type === 's3') {
|
|
const current = parseS3Secrets(row.secret_json) ?? {
|
|
accessKeyId: '',
|
|
secretAccessKey: ''
|
|
}
|
|
const next = {
|
|
accessKeyId: patch.secrets.accessKeyId?.trim()
|
|
? patch.secrets.accessKeyId.trim()
|
|
: current.accessKeyId,
|
|
secretAccessKey: patch.secrets.secretAccessKey?.trim()
|
|
? patch.secrets.secretAccessKey.trim()
|
|
: current.secretAccessKey
|
|
}
|
|
secretJson = JSON.stringify(next)
|
|
}
|
|
|
|
const servingMode = patch.servingMode ?? row.serving_mode
|
|
const publicUrl = patch.publicUrl ?? row.public_url
|
|
const quotaBytes = patch.quotaBytes !== undefined
|
|
? patch.quotaBytes
|
|
: row.quota_bytes
|
|
const enabled = patch.enabled !== undefined
|
|
? (patch.enabled ? 1 : 0)
|
|
: row.enabled
|
|
|
|
getDb().prepare(`
|
|
UPDATE storage_backends
|
|
SET name = ?, config_json = ?, secret_json = ?, serving_mode = ?,
|
|
public_url = ?, quota_bytes = ?, enabled = ?
|
|
WHERE id = ?
|
|
`).run(name, configJson, secretJson, servingMode, publicUrl, quotaBytes, enabled, id)
|
|
|
|
if (patch.isDefault) {
|
|
setDefaultBackend(id)
|
|
}
|
|
}
|
|
|
|
export function isStorageEnvConfigured(): boolean {
|
|
return getStorageBackendFromEnv() !== null
|
|
}
|
|
|
|
export function getStorageBackendFromEnv(): {
|
|
type: StorageBackendType
|
|
config: Record<string, unknown>
|
|
secrets: { accessKeyId: string, secretAccessKey: string }
|
|
servingMode: ServingMode
|
|
publicUrl: string
|
|
} | null {
|
|
const backendType = process.env.STORAGE_BACKEND?.trim()
|
|
if (backendType !== 's3') return null
|
|
|
|
const endpoint = process.env.S3_ENDPOINT?.trim() ?? ''
|
|
const region = process.env.S3_REGION?.trim() ?? ''
|
|
const bucket = process.env.S3_BUCKET?.trim() ?? ''
|
|
const accessKeyId = process.env.S3_ACCESS_KEY?.trim() ?? ''
|
|
const secretAccessKey = process.env.S3_SECRET_KEY?.trim() ?? ''
|
|
|
|
if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
type: 's3',
|
|
config: {
|
|
endpoint,
|
|
region: region || 'auto',
|
|
bucket,
|
|
prefix: process.env.S3_PREFIX?.trim() || undefined,
|
|
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true'
|
|
},
|
|
secrets: { accessKeyId, secretAccessKey },
|
|
servingMode: process.env.S3_PUBLIC_URL?.trim() ? 'public' : 'proxy',
|
|
publicUrl: process.env.S3_PUBLIC_URL?.trim() ?? ''
|
|
}
|
|
}
|
|
|
|
export function createBackendInstance(row: StorageBackendRow) {
|
|
if (row.type === 'local') {
|
|
return new LocalStorageBackend(
|
|
row.id,
|
|
row.serving_mode,
|
|
row.public_url
|
|
)
|
|
}
|
|
|
|
const config = parseS3Config(row.config_json)
|
|
const secrets = parseS3Secrets(row.secret_json)
|
|
if (!config || !secrets) {
|
|
throw new Error(`Invalid S3 configuration for backend ${row.id}`)
|
|
}
|
|
|
|
return new S3StorageBackend(
|
|
row.id,
|
|
config,
|
|
secrets,
|
|
row.serving_mode,
|
|
row.public_url
|
|
)
|
|
}
|
|
|
|
export function createBackendFromEnv() {
|
|
const env = getStorageBackendFromEnv()
|
|
if (!env) return null
|
|
|
|
return new S3StorageBackend(
|
|
S3_BACKEND_ID,
|
|
env.config as unknown as S3BackendConfig,
|
|
env.secrets,
|
|
env.servingMode,
|
|
env.publicUrl
|
|
)
|
|
}
|
|
|
|
export function getBackendUsageStats(backendId: string): { count: number, bytes: number } {
|
|
ensureStorageSchema()
|
|
const row = getDb().prepare(`
|
|
SELECT COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes
|
|
FROM images
|
|
WHERE backend_id = ?
|
|
`).get(backendId) as { count: number, bytes: number }
|
|
return { count: row.count, bytes: row.bytes }
|
|
}
|
|
|
|
function getNextSortOrder(): number {
|
|
const row = getDb().prepare(`
|
|
SELECT COALESCE(MAX(sort_order), 0) AS max_order FROM storage_backends
|
|
`).get() as { max_order: number }
|
|
return row.max_order + 1
|
|
}
|
|
|
|
export type ObjectStorageIdPrefix = 'r2' | 'cos' | 'oss' | 's3'
|
|
|
|
/** 根据表单预设或 endpoint 推断新建后端的 id 前缀 */
|
|
export function resolveObjectStorageIdPrefix(
|
|
config: Pick<S3BackendConfig, 'endpoint'>,
|
|
provider?: string
|
|
): ObjectStorageIdPrefix {
|
|
const normalized = provider?.trim().toLowerCase()
|
|
if (normalized === 'r2' || normalized === 'cos' || normalized === 'oss') {
|
|
return normalized
|
|
}
|
|
if (normalized === 'aws' || normalized === 's3') {
|
|
return 's3'
|
|
}
|
|
|
|
const endpoint = config.endpoint.trim().toLowerCase()
|
|
if (endpoint.includes('r2.cloudflarestorage.com')) return 'r2'
|
|
if (endpoint.includes('myqcloud.com')) return 'cos'
|
|
if (endpoint.includes('aliyuncs.com')) return 'oss'
|
|
return 's3'
|
|
}
|
|
|
|
export function generateStorageBackendId(prefix: ObjectStorageIdPrefix = 's3'): string {
|
|
const bytes = new Uint8Array(4)
|
|
crypto.getRandomValues(bytes)
|
|
const suffix = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
|
|
return `${prefix}-${suffix}`
|
|
}
|
|
|
|
export function insertStorageBackend(input: {
|
|
id?: string
|
|
name: string
|
|
provider?: string
|
|
config: S3BackendConfig
|
|
secrets: { accessKeyId: string, secretAccessKey: string }
|
|
servingMode?: ServingMode
|
|
publicUrl?: string
|
|
quotaBytes?: number | null
|
|
enabled?: boolean
|
|
isDefault?: boolean
|
|
}): string {
|
|
ensureDefaultBackends()
|
|
const idPrefix = resolveObjectStorageIdPrefix(input.config, input.provider)
|
|
const id = input.id ?? generateStorageBackendId(idPrefix)
|
|
const sortOrder = getNextSortOrder()
|
|
|
|
getDb().prepare(`
|
|
INSERT INTO storage_backends
|
|
(id, name, type, config_json, secret_json, serving_mode, public_url,
|
|
quota_bytes, enabled, is_default, sort_order)
|
|
VALUES (?, ?, 's3', ?, ?, ?, ?, ?, ?, 0, ?)
|
|
`).run(
|
|
id,
|
|
input.name,
|
|
JSON.stringify(input.config),
|
|
JSON.stringify(input.secrets),
|
|
input.servingMode ?? 'proxy',
|
|
input.publicUrl ?? '',
|
|
input.quotaBytes ?? null,
|
|
input.enabled === false ? 0 : 1,
|
|
sortOrder
|
|
)
|
|
|
|
if (input.isDefault) {
|
|
setDefaultBackend(id)
|
|
}
|
|
|
|
return id
|
|
}
|
|
|
|
export function deleteStorageBackend(id: string): void {
|
|
if (id === LOCAL_BACKEND_ID) {
|
|
throw new Error('Cannot delete local backend')
|
|
}
|
|
|
|
const usage = getBackendUsageStats(id)
|
|
if (usage.count > 0) {
|
|
throw new Error('Cannot delete backend with stored images')
|
|
}
|
|
|
|
const row = getStorageBackendRow(id)
|
|
if (!row) {
|
|
throw new Error(`Storage backend not found: ${id}`)
|
|
}
|
|
|
|
if (row.is_default) {
|
|
throw new Error('Cannot delete the default backend')
|
|
}
|
|
|
|
getDb().prepare('DELETE FROM storage_backends WHERE id = ?').run(id)
|
|
}
|