mirror of
https://github.com/saltbo/zpan.git
synced 2026-10-30 18:31:36 +08:00
feat: implement storage CRUD API with bucket pool selection (#249)
Admin-only endpoints at /api/admin/storages for managing S3-compatible storage backends. Each storage has a capacity limit; multiple storages of the same mode form a pool with sequential fill routing. - StorageService with CRUD + selectStorage pool logic (SQL-level filter) - Zod validation on POST/PUT with shared schemas - D1 migration adding capacity/used columns, dropping uid - Node tests (23 cases) and CF tests (6 cases) all passing Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
-- Add capacity and used columns for bucket pool support
|
||||
ALTER TABLE `storages` ADD `capacity` integer NOT NULL DEFAULT 0;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `storages` ADD `used` integer NOT NULL DEFAULT 0;
|
||||
--> statement-breakpoint
|
||||
-- Convert status from integer to text values
|
||||
UPDATE `storages` SET `status` = CASE WHEN `status` = 1 THEN 'active' ELSE 'disabled' END;
|
||||
--> statement-breakpoint
|
||||
-- Drop uid column (storages are global, not per-user)
|
||||
ALTER TABLE `storages` DROP COLUMN `uid`;
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1775073310565,
|
||||
"tag": "0001_org_and_admin_plugins",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1775080000000,
|
||||
"tag": "0002_storage_pool_fields",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function createApp(platform: Platform, auth: Auth) {
|
||||
|
||||
const routes = app
|
||||
.route('/api/objects', objects)
|
||||
.route('/api/storages', storages)
|
||||
.route('/api/admin/storages', storages)
|
||||
.route('/api/admin/users', users)
|
||||
.route('/api/admin/quotas', adminQuotas)
|
||||
.route('/api/quotas', userQuotas)
|
||||
|
||||
@@ -18,7 +18,6 @@ export const matters = sqliteTable('matters', {
|
||||
|
||||
export const storages = sqliteTable('storages', {
|
||||
id: text('id').primaryKey(),
|
||||
uid: text('uid').notNull(),
|
||||
title: text('title').notNull(),
|
||||
mode: text('mode').notNull(),
|
||||
bucket: text('bucket').notNull(),
|
||||
@@ -28,7 +27,9 @@ export const storages = sqliteTable('storages', {
|
||||
secretKey: text('secret_key').notNull(),
|
||||
filePath: text('file_path').notNull().default('$UID/$RAW_NAME'),
|
||||
customHost: text('custom_host').default(''),
|
||||
status: integer('status').default(1),
|
||||
capacity: integer('capacity').notNull().default(0),
|
||||
used: integer('used').notNull().default(0),
|
||||
status: text('status').notNull().default('active'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { env } from 'cloudflare:workers'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createApp } from '../app'
|
||||
import { createAuth } from '../auth'
|
||||
import { user } from '../db/auth-schema'
|
||||
import { createCloudflarePlatform } from '../platform/cloudflare'
|
||||
|
||||
function buildApp() {
|
||||
@@ -10,30 +12,122 @@ function buildApp() {
|
||||
return createApp(platform, auth)
|
||||
}
|
||||
|
||||
async function authedHeaders(app: ReturnType<typeof buildApp>) {
|
||||
const email = `cf-stg-${Date.now()}@example.com`
|
||||
const res = await app.request('/api/auth/sign-up/email', {
|
||||
async function adminHeaders(app: ReturnType<typeof buildApp>) {
|
||||
const email = `cf-admin-${Date.now()}@example.com`
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Test', email, password: 'password123456' }),
|
||||
body: JSON.stringify({ name: 'Admin', email, password: 'password123456' }),
|
||||
})
|
||||
const cookies = res.headers.getSetCookie()
|
||||
return { Cookie: cookies.join('; ') }
|
||||
|
||||
const platform = createCloudflarePlatform(env)
|
||||
await platform.db.update(user).set({ role: 'admin' }).where(eq(user.email, email))
|
||||
|
||||
const signInRes = await app.request('/api/auth/sign-in/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password: 'password123456' }),
|
||||
})
|
||||
return { Cookie: signInRes.headers.getSetCookie().join('; ') }
|
||||
}
|
||||
|
||||
describe('[CF] Storages API', () => {
|
||||
const validStorage = {
|
||||
title: 'CF Test S3',
|
||||
mode: 'private',
|
||||
bucket: 'cf-test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
accessKey: 'AKIAIOSFODNN7EXAMPLE',
|
||||
secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
filePath: '$UID/$RAW_NAME',
|
||||
}
|
||||
|
||||
describe('[CF] Admin Storages API', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const app = buildApp()
|
||||
const res = await app.request('/api/storages')
|
||||
const res = await app.request('/api/admin/storages')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('GET /api/storages returns empty list', async () => {
|
||||
it('GET /api/admin/storages returns empty list', async () => {
|
||||
const app = buildApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const res = await app.request('/api/storages', { headers })
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/storages', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = (await res.json()) as { items: unknown[]; total: number }
|
||||
expect(body).toEqual({ items: [], total: 0 })
|
||||
})
|
||||
|
||||
it('POST /api/admin/storages creates a storage', async () => {
|
||||
const app = buildApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/storages', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validStorage),
|
||||
})
|
||||
expect(res.status).toBe(201)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.title).toBe('CF Test S3')
|
||||
expect(body.status).toBe('active')
|
||||
expect(body.id).toBeTruthy()
|
||||
})
|
||||
|
||||
it('GET /api/admin/storages/:id returns storage detail', async () => {
|
||||
const app = buildApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
const createRes = await app.request('/api/admin/storages', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validStorage),
|
||||
})
|
||||
const created = (await createRes.json()) as { id: string }
|
||||
|
||||
const res = await app.request(`/api/admin/storages/${created.id}`, { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.id).toBe(created.id)
|
||||
})
|
||||
|
||||
it('PUT /api/admin/storages/:id updates a storage', async () => {
|
||||
const app = buildApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
const createRes = await app.request('/api/admin/storages', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validStorage),
|
||||
})
|
||||
const created = (await createRes.json()) as { id: string }
|
||||
|
||||
const res = await app.request(`/api/admin/storages/${created.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'Updated CF S3' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.title).toBe('Updated CF S3')
|
||||
})
|
||||
|
||||
it('DELETE /api/admin/storages/:id deletes a storage', async () => {
|
||||
const app = buildApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
const createRes = await app.request('/api/admin/storages', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validStorage),
|
||||
})
|
||||
const created = (await createRes.json()) as { id: string }
|
||||
|
||||
const res = await app.request(`/api/admin/storages/${created.id}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.deleted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,51 +1,312 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { authedHeaders, createTestApp } from '../test/setup.js'
|
||||
import { selectStorage } from '../services/storage.js'
|
||||
import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js'
|
||||
|
||||
describe('Storages API', () => {
|
||||
const validStorage = {
|
||||
title: 'Test S3',
|
||||
mode: 'private',
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
accessKey: 'AKIAIOSFODNN7EXAMPLE',
|
||||
secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
filePath: '$UID/$RAW_NAME',
|
||||
}
|
||||
|
||||
describe('Admin Storages API', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const { app } = createTestApp()
|
||||
const res = await app.request('/api/storages')
|
||||
const res = await app.request('/api/admin/storages')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('GET /api/storages returns empty list', async () => {
|
||||
it('returns 403 for non-admin user', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const res = await app.request('/api/storages', { headers })
|
||||
// First user becomes admin
|
||||
await authedHeaders(app, 'admin@example.com')
|
||||
// Second user is non-admin
|
||||
await authedHeaders(app, 'regular@example.com')
|
||||
const signInRes = await app.request('/api/auth/sign-in/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'regular@example.com', password: 'password123456' }),
|
||||
})
|
||||
const freshHeaders = { Cookie: signInRes.headers.getSetCookie().join('; ') }
|
||||
const res = await app.request('/api/admin/storages', { headers: freshHeaders })
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('GET / returns empty list', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/storages', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
const body = (await res.json()) as { items: unknown[]; total: number }
|
||||
expect(body).toEqual({ items: [], total: 0 })
|
||||
})
|
||||
|
||||
it('POST /api/storages returns 501', async () => {
|
||||
it('POST / creates a storage', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const res = await app.request('/api/storages', {
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/storages', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'test' }),
|
||||
body: JSON.stringify(validStorage),
|
||||
})
|
||||
expect(res.status).toBe(501)
|
||||
expect(res.status).toBe(201)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.title).toBe('Test S3')
|
||||
expect(body.mode).toBe('private')
|
||||
expect(body.bucket).toBe('test-bucket')
|
||||
expect(body.status).toBe('active')
|
||||
expect(body.capacity).toBe(0)
|
||||
expect(body.used).toBe(0)
|
||||
expect(body.id).toBeTruthy()
|
||||
})
|
||||
|
||||
it('GET /api/storages/:id returns 501', async () => {
|
||||
it('GET / lists created storages', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const res = await app.request('/api/storages/abc', { headers })
|
||||
expect(res.status).toBe(501)
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
await app.request('/api/admin/storages', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validStorage),
|
||||
})
|
||||
|
||||
const res = await app.request('/api/admin/storages', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: Array<Record<string, unknown>>; total: number }
|
||||
expect(body.total).toBe(1)
|
||||
expect(body.items).toHaveLength(1)
|
||||
expect(body.items[0].title).toBe('Test S3')
|
||||
})
|
||||
|
||||
it('PUT /api/storages/:id returns 501', async () => {
|
||||
it('GET /:id returns storage detail', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const res = await app.request('/api/storages/abc', { method: 'PUT', headers })
|
||||
expect(res.status).toBe(501)
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
const createRes = await app.request('/api/admin/storages', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validStorage),
|
||||
})
|
||||
const created = (await createRes.json()) as { id: string }
|
||||
|
||||
const res = await app.request(`/api/admin/storages/${created.id}`, { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.id).toBe(created.id)
|
||||
expect(body.title).toBe('Test S3')
|
||||
})
|
||||
|
||||
it('DELETE /api/storages/:id returns 501', async () => {
|
||||
it('GET /:id returns 404 for missing storage', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const res = await app.request('/api/storages/abc', { method: 'DELETE', headers })
|
||||
expect(res.status).toBe(501)
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/storages/nonexistent', { headers })
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('PUT /:id updates a storage', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
const createRes = await app.request('/api/admin/storages', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validStorage),
|
||||
})
|
||||
const created = (await createRes.json()) as { id: string }
|
||||
|
||||
const res = await app.request(`/api/admin/storages/${created.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'Updated S3', status: 'disabled' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.title).toBe('Updated S3')
|
||||
expect(body.status).toBe('disabled')
|
||||
})
|
||||
|
||||
it('PUT /:id returns 404 for missing storage', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/storages/nonexistent', {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'Nope' }),
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('DELETE /:id deletes a storage', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
const createRes = await app.request('/api/admin/storages', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validStorage),
|
||||
})
|
||||
const created = (await createRes.json()) as { id: string }
|
||||
|
||||
const res = await app.request(`/api/admin/storages/${created.id}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.deleted).toBe(true)
|
||||
})
|
||||
|
||||
it('DELETE /:id returns 404 for missing storage', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/storages/nonexistent', {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('DELETE /:id returns 409 when matters reference the storage', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
const createRes = await app.request('/api/admin/storages', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validStorage),
|
||||
})
|
||||
const created = (await createRes.json()) as { id: string }
|
||||
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO matters (id, org_id, alias, name, type, storage_id, created_at, updated_at)
|
||||
VALUES ('m1', 'org-1', 'test-alias', 'test.txt', 'text/plain', ${created.id}, ${now}, ${now})
|
||||
`)
|
||||
|
||||
const res = await app.request(`/api/admin/storages/${created.id}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
expect(res.status).toBe(409)
|
||||
})
|
||||
})
|
||||
|
||||
// Helper to insert a storage row directly into the DB for service-level tests
|
||||
async function insertStorage(
|
||||
db: ReturnType<typeof createTestApp>['db'],
|
||||
opts: {
|
||||
id: string
|
||||
mode: 'private' | 'public'
|
||||
status?: string
|
||||
capacity?: number
|
||||
used?: number
|
||||
createdAt?: number
|
||||
},
|
||||
) {
|
||||
const now = opts.createdAt ?? Date.now()
|
||||
const capacity = opts.capacity ?? 0
|
||||
const used = opts.used ?? 0
|
||||
const status = opts.status ?? 'active'
|
||||
const title = `Storage ${opts.id}`
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${opts.id}, ${title}, ${opts.mode}, 'bucket', 'https://s3.example.com', 'us-east-1', 'key', 'secret', '$UID/$RAW_NAME', '', ${capacity}, ${used}, ${status}, ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
describe('selectStorage service', () => {
|
||||
it('returns the single active storage when capacity is unlimited (0)', async () => {
|
||||
const { db } = createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, used: 0 })
|
||||
|
||||
const storage = await selectStorage(db, 'private')
|
||||
expect(storage.id).toBe('s1')
|
||||
})
|
||||
|
||||
it('returns storage when used is below capacity', async () => {
|
||||
const { db } = createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 50 })
|
||||
|
||||
const storage = await selectStorage(db, 'private')
|
||||
expect(storage.id).toBe('s1')
|
||||
})
|
||||
|
||||
it('skips storage where used equals capacity', async () => {
|
||||
const { db } = createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 100, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', capacity: 200, used: 50, createdAt: 2 })
|
||||
|
||||
const storage = await selectStorage(db, 'private')
|
||||
expect(storage.id).toBe('s2')
|
||||
})
|
||||
|
||||
it('skips storage where used exceeds capacity', async () => {
|
||||
const { db } = createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 110, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', capacity: 0, used: 0, createdAt: 2 })
|
||||
|
||||
const storage = await selectStorage(db, 'private')
|
||||
expect(storage.id).toBe('s2')
|
||||
})
|
||||
|
||||
it('picks the oldest active storage first (sequential fill order)', async () => {
|
||||
const { db } = createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, used: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', capacity: 0, used: 0, createdAt: 2 })
|
||||
|
||||
const storage = await selectStorage(db, 'private')
|
||||
expect(storage.id).toBe('s1')
|
||||
})
|
||||
|
||||
it('ignores disabled storages', async () => {
|
||||
const { db } = createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', status: 'disabled', capacity: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', status: 'active', capacity: 0, createdAt: 2 })
|
||||
|
||||
const storage = await selectStorage(db, 'private')
|
||||
expect(storage.id).toBe('s2')
|
||||
})
|
||||
|
||||
it('ignores storages of a different mode', async () => {
|
||||
const { db } = createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'public', capacity: 0 })
|
||||
|
||||
await expect(selectStorage(db, 'private')).rejects.toThrow('No available storage')
|
||||
})
|
||||
|
||||
it('throws when no active storage exists for the requested mode', async () => {
|
||||
const { db } = createTestApp()
|
||||
|
||||
await expect(selectStorage(db, 'private')).rejects.toThrow('No available storage')
|
||||
})
|
||||
|
||||
it('throws when all storages of the mode are at full capacity', async () => {
|
||||
const { db } = createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 50, used: 50 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', capacity: 100, used: 100 })
|
||||
|
||||
await expect(selectStorage(db, 'private')).rejects.toThrow('No available storage')
|
||||
})
|
||||
|
||||
it('returns a public storage when mode is public', async () => {
|
||||
const { db } = createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'public', capacity: 0 })
|
||||
|
||||
const storage = await selectStorage(db, 'public')
|
||||
expect(storage.id).toBe('s1')
|
||||
})
|
||||
|
||||
it('does not return a public storage when private mode is requested', async () => {
|
||||
const { db } = createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'public', capacity: 0, createdAt: 2 })
|
||||
|
||||
const storage = await selectStorage(db, 'private')
|
||||
expect(storage.id).toBe('s1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,48 @@
|
||||
import { createStorageSchema, updateStorageSchema } from '@zpan/shared/schemas'
|
||||
import { Hono } from 'hono'
|
||||
import { requireAuth } from '../middleware/auth'
|
||||
import { requireAdmin } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { createStorage, deleteStorage, getStorage, listStorages, updateStorage } from '../services/storage'
|
||||
|
||||
const app = new Hono<Env>()
|
||||
.use(requireAuth)
|
||||
.use(requireAdmin)
|
||||
.get('/', async (c) => {
|
||||
// TODO: list storages
|
||||
return c.json({ items: [], total: 0 })
|
||||
const db = c.get('platform').db
|
||||
const result = await listStorages(db)
|
||||
return c.json(result)
|
||||
})
|
||||
.post('/', async (c) => {
|
||||
// TODO: create storage
|
||||
return c.json({ message: 'not implemented' }, 501)
|
||||
const db = c.get('platform').db
|
||||
const raw = await c.req.json()
|
||||
const parsed = createStorageSchema.safeParse(raw)
|
||||
if (!parsed.success) return c.json({ error: parsed.error.issues[0].message }, 400)
|
||||
const storage = await createStorage(db, parsed.data)
|
||||
return c.json(storage, 201)
|
||||
})
|
||||
.get('/:id', async (c) => {
|
||||
// TODO: get storage detail
|
||||
return c.json({ message: 'not implemented' }, 501)
|
||||
const db = c.get('platform').db
|
||||
const id = c.req.param('id')
|
||||
const storage = await getStorage(db, id)
|
||||
if (!storage) return c.json({ error: 'Storage not found' }, 404)
|
||||
return c.json(storage)
|
||||
})
|
||||
.put('/:id', async (c) => {
|
||||
// TODO: update storage
|
||||
return c.json({ message: 'not implemented' }, 501)
|
||||
const db = c.get('platform').db
|
||||
const id = c.req.param('id')
|
||||
const raw = await c.req.json()
|
||||
const parsed = updateStorageSchema.safeParse(raw)
|
||||
if (!parsed.success) return c.json({ error: parsed.error.issues[0].message }, 400)
|
||||
const storage = await updateStorage(db, id, parsed.data)
|
||||
if (!storage) return c.json({ error: 'Storage not found' }, 404)
|
||||
return c.json(storage)
|
||||
})
|
||||
.delete('/:id', async (c) => {
|
||||
// TODO: delete storage
|
||||
return c.json({ message: 'not implemented' }, 501)
|
||||
const db = c.get('platform').db
|
||||
const id = c.req.param('id')
|
||||
const result = await deleteStorage(db, id)
|
||||
if (result === 'not_found') return c.json({ error: 'Storage not found' }, 404)
|
||||
if (result === 'in_use') return c.json({ error: 'Storage is referenced by existing files' }, 409)
|
||||
return c.json({ id, deleted: true })
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { CreateStorageInput, UpdateStorageInput } from '@zpan/shared/schemas'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { Database } from '../platform/interface'
|
||||
|
||||
export interface Storage {
|
||||
id: string
|
||||
title: string
|
||||
mode: string
|
||||
bucket: string
|
||||
endpoint: string
|
||||
region: string
|
||||
accessKey: string
|
||||
secretKey: string
|
||||
filePath: string
|
||||
customHost: string
|
||||
capacity: number
|
||||
used: number
|
||||
status: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export async function listStorages(db: Database): Promise<{ items: Storage[]; total: number }> {
|
||||
const items = await db.all<Storage>(sql`
|
||||
SELECT id, title, mode, bucket, endpoint, region,
|
||||
access_key AS accessKey, secret_key AS secretKey,
|
||||
file_path AS filePath, custom_host AS customHost,
|
||||
capacity, used, status,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM storages
|
||||
ORDER BY created_at ASC
|
||||
`)
|
||||
|
||||
return { items, total: items.length }
|
||||
}
|
||||
|
||||
export async function getStorage(db: Database, id: string): Promise<Storage | null> {
|
||||
const rows = await db.all<Storage>(sql`
|
||||
SELECT id, title, mode, bucket, endpoint, region,
|
||||
access_key AS accessKey, secret_key AS secretKey,
|
||||
file_path AS filePath, custom_host AS customHost,
|
||||
capacity, used, status,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM storages WHERE id = ${id}
|
||||
`)
|
||||
return rows[0] ?? null
|
||||
}
|
||||
|
||||
export async function createStorage(db: Database, input: CreateStorageInput): Promise<Storage> {
|
||||
const id = nanoid()
|
||||
const now = Date.now()
|
||||
const capacity = input.capacity ?? 0
|
||||
const customHost = input.customHost ?? ''
|
||||
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${id}, ${input.title}, ${input.mode}, ${input.bucket}, ${input.endpoint}, ${input.region}, ${input.accessKey}, ${input.secretKey}, ${input.filePath}, ${customHost}, ${capacity}, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
|
||||
return {
|
||||
id,
|
||||
title: input.title,
|
||||
mode: input.mode,
|
||||
bucket: input.bucket,
|
||||
endpoint: input.endpoint,
|
||||
region: input.region,
|
||||
accessKey: input.accessKey,
|
||||
secretKey: input.secretKey,
|
||||
filePath: input.filePath,
|
||||
customHost,
|
||||
capacity,
|
||||
used: 0,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateStorage(db: Database, id: string, input: UpdateStorageInput): Promise<Storage | null> {
|
||||
const existing = await getStorage(db, id)
|
||||
if (!existing) return null
|
||||
|
||||
const now = Date.now()
|
||||
const updated = {
|
||||
title: input.title ?? existing.title,
|
||||
mode: input.mode ?? existing.mode,
|
||||
bucket: input.bucket ?? existing.bucket,
|
||||
endpoint: input.endpoint ?? existing.endpoint,
|
||||
region: input.region ?? existing.region,
|
||||
accessKey: input.accessKey ?? existing.accessKey,
|
||||
secretKey: input.secretKey ?? existing.secretKey,
|
||||
filePath: input.filePath ?? existing.filePath,
|
||||
customHost: input.customHost ?? existing.customHost,
|
||||
capacity: input.capacity ?? existing.capacity,
|
||||
status: input.status ?? existing.status,
|
||||
}
|
||||
|
||||
await db.run(sql`
|
||||
UPDATE storages SET
|
||||
title = ${updated.title}, mode = ${updated.mode}, bucket = ${updated.bucket},
|
||||
endpoint = ${updated.endpoint}, region = ${updated.region},
|
||||
access_key = ${updated.accessKey}, secret_key = ${updated.secretKey},
|
||||
file_path = ${updated.filePath}, custom_host = ${updated.customHost},
|
||||
capacity = ${updated.capacity}, status = ${updated.status},
|
||||
updated_at = ${now}
|
||||
WHERE id = ${id}
|
||||
`)
|
||||
|
||||
return { ...existing, ...updated, updatedAt: now }
|
||||
}
|
||||
|
||||
export async function deleteStorage(db: Database, id: string): Promise<'ok' | 'not_found' | 'in_use'> {
|
||||
const existing = await getStorage(db, id)
|
||||
if (!existing) return 'not_found'
|
||||
|
||||
const refs = await db.all<{ count: number }>(sql`SELECT COUNT(*) AS count FROM matters WHERE storage_id = ${id}`)
|
||||
if ((refs[0]?.count ?? 0) > 0) return 'in_use'
|
||||
|
||||
await db.run(sql`DELETE FROM storages WHERE id = ${id}`)
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
export async function selectStorage(db: Database, mode: 'private' | 'public'): Promise<Storage> {
|
||||
const rows = await db.all<Storage>(sql`
|
||||
SELECT id, title, mode, bucket, endpoint, region,
|
||||
access_key AS accessKey, secret_key AS secretKey,
|
||||
file_path AS filePath, custom_host AS customHost,
|
||||
capacity, used, status,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM storages
|
||||
WHERE mode = ${mode} AND status = 'active'
|
||||
AND (capacity = 0 OR used < capacity)
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
if (rows.length === 0) throw new Error('No available storage')
|
||||
return rows[0]
|
||||
}
|
||||
@@ -108,7 +108,6 @@ const APP_SCHEMA_SQL = `
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS storages (
|
||||
id TEXT PRIMARY KEY,
|
||||
uid TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
bucket TEXT NOT NULL,
|
||||
@@ -116,9 +115,11 @@ const APP_SCHEMA_SQL = `
|
||||
region TEXT NOT NULL DEFAULT 'auto',
|
||||
access_key TEXT NOT NULL,
|
||||
secret_key TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
file_path TEXT NOT NULL DEFAULT '$UID/$RAW_NAME',
|
||||
custom_host TEXT DEFAULT '',
|
||||
status INTEGER DEFAULT 1,
|
||||
capacity INTEGER NOT NULL DEFAULT 0,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -151,6 +152,18 @@ export function createTestApp() {
|
||||
return { app, db, auth }
|
||||
}
|
||||
|
||||
export async function adminHeaders(app: ReturnType<typeof createApp>) {
|
||||
// Sign up first user (gets promoted to admin via hook)
|
||||
await authedHeaders(app, 'admin@example.com', 'password123456')
|
||||
// Sign in again to get a session that reflects the admin role
|
||||
const signInRes = await app.request('/api/auth/sign-in/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'admin@example.com', password: 'password123456' }),
|
||||
})
|
||||
return { Cookie: signInRes.headers.getSetCookie().join('; ') }
|
||||
}
|
||||
|
||||
export async function authedHeaders(
|
||||
app: ReturnType<typeof createApp>,
|
||||
email = 'test@example.com',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export type { CreateStorageInput, UpdateStorageInput } from './storage'
|
||||
export { createStorageSchema, updateStorageSchema } from './storage'
|
||||
|
||||
export const signInSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
@@ -11,18 +14,6 @@ export const signUpSchema = z.object({
|
||||
password: z.string().min(6),
|
||||
})
|
||||
|
||||
export const createStorageSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
mode: z.enum(['private', 'public']),
|
||||
bucket: z.string().min(1),
|
||||
endpoint: z.string().url(),
|
||||
region: z.string().default('auto'),
|
||||
accessKey: z.string().min(1),
|
||||
secretKey: z.string().min(1),
|
||||
filePath: z.string().default('$UID/$RAW_NAME'),
|
||||
customHost: z.string().optional(),
|
||||
})
|
||||
|
||||
export const createMatterSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.string(),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const createStorageSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
mode: z.enum(['private', 'public']),
|
||||
bucket: z.string().min(1),
|
||||
endpoint: z.string().url(),
|
||||
region: z.string().default('auto'),
|
||||
accessKey: z.string().min(1),
|
||||
secretKey: z.string().min(1),
|
||||
filePath: z.string().default('$UID/$RAW_NAME'),
|
||||
customHost: z.string().optional(),
|
||||
capacity: z.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
export const updateStorageSchema = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
mode: z.enum(['private', 'public']).optional(),
|
||||
bucket: z.string().min(1).optional(),
|
||||
endpoint: z.string().url().optional(),
|
||||
region: z.string().optional(),
|
||||
accessKey: z.string().min(1).optional(),
|
||||
secretKey: z.string().min(1).optional(),
|
||||
filePath: z.string().optional(),
|
||||
customHost: z.string().optional(),
|
||||
capacity: z.number().int().min(0).optional(),
|
||||
status: z.enum(['active', 'disabled']).optional(),
|
||||
})
|
||||
|
||||
export type CreateStorageInput = z.infer<typeof createStorageSchema>
|
||||
export type UpdateStorageInput = z.infer<typeof updateStorageSchema>
|
||||
Reference in New Issue
Block a user