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:
Jasper Van
2026-04-08 01:22:15 -04:00
committed by GitHub
co-authored by Bob
parent e5b4493a3e
commit 892a6fc78e
11 changed files with 634 additions and 66 deletions
+10
View File
@@ -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`;
+7
View File
@@ -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
}
]
}
+1 -1
View File
@@ -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)
+3 -2
View File
@@ -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(),
})
+106 -12
View File
@@ -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)
})
})
+285 -24
View File
@@ -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')
})
})
+32 -12
View File
@@ -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
+140
View File
@@ -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]
}
+16 -3
View File
@@ -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',
+3 -12
View File
@@ -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(),
+31
View File
@@ -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>