From 892a6fc78e8aefd3505bddb6ca03fd7c0d0798a8 Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Wed, 8 Apr 2026 01:22:15 -0400 Subject: [PATCH] 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 --- migrations/0002_storage_pool_fields.sql | 10 + migrations/meta/_journal.json | 7 + packages/server/src/app.ts | 2 +- packages/server/src/db/schema.ts | 5 +- .../server/src/routes/storages.cf-test.ts | 118 ++++++- packages/server/src/routes/storages.test.ts | 309 ++++++++++++++++-- packages/server/src/routes/storages.ts | 44 ++- packages/server/src/services/storage.ts | 140 ++++++++ packages/server/src/test/setup.ts | 19 +- packages/shared/src/schemas/index.ts | 15 +- packages/shared/src/schemas/storage.ts | 31 ++ 11 files changed, 634 insertions(+), 66 deletions(-) create mode 100644 migrations/0002_storage_pool_fields.sql create mode 100644 packages/server/src/services/storage.ts create mode 100644 packages/shared/src/schemas/storage.ts diff --git a/migrations/0002_storage_pool_fields.sql b/migrations/0002_storage_pool_fields.sql new file mode 100644 index 00000000..09ecf701 --- /dev/null +++ b/migrations/0002_storage_pool_fields.sql @@ -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`; diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 6f15312f..1beb3c0f 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 005a17fa..155dac27 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -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) diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index fcc57006..baf793bf 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -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(), }) diff --git a/packages/server/src/routes/storages.cf-test.ts b/packages/server/src/routes/storages.cf-test.ts index fb9597b4..9c1d6e06 100644 --- a/packages/server/src/routes/storages.cf-test.ts +++ b/packages/server/src/routes/storages.cf-test.ts @@ -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) { - const email = `cf-stg-${Date.now()}@example.com` - const res = await app.request('/api/auth/sign-up/email', { +async function adminHeaders(app: ReturnType) { + 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 + 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 + 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 + 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 + expect(body.deleted).toBe(true) + }) }) diff --git a/packages/server/src/routes/storages.test.ts b/packages/server/src/routes/storages.test.ts index 46d21635..61dd7f8e 100644 --- a/packages/server/src/routes/storages.test.ts +++ b/packages/server/src/routes/storages.test.ts @@ -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 + 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>; 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 + 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 + 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 + 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['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') }) }) diff --git a/packages/server/src/routes/storages.ts b/packages/server/src/routes/storages.ts index 23cac5f2..db64dadf 100644 --- a/packages/server/src/routes/storages.ts +++ b/packages/server/src/routes/storages.ts @@ -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() - .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 diff --git a/packages/server/src/services/storage.ts b/packages/server/src/services/storage.ts new file mode 100644 index 00000000..3b804f64 --- /dev/null +++ b/packages/server/src/services/storage.ts @@ -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(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 { + const rows = await db.all(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 { + 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 { + 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 { + const rows = await db.all(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] +} diff --git a/packages/server/src/test/setup.ts b/packages/server/src/test/setup.ts index 6262cc37..c81eb51e 100644 --- a/packages/server/src/test/setup.ts +++ b/packages/server/src/test/setup.ts @@ -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) { + // 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, email = 'test@example.com', diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 1cf3395c..21ac1495 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -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(), diff --git a/packages/shared/src/schemas/storage.ts b/packages/shared/src/schemas/storage.ts new file mode 100644 index 00000000..35673813 --- /dev/null +++ b/packages/shared/src/schemas/storage.ts @@ -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 +export type UpdateStorageInput = z.infer