feat(archive): queue streaming archive jobs

This commit is contained in:
saltbo
2026-05-15 09:59:08 -04:00
parent 8b45c6218a
commit 2438275ea8
20 changed files with 1212 additions and 225 deletions
+111
View File
@@ -0,0 +1,111 @@
import { randomBytes } from 'node:crypto'
import { expect, type Page, test } from '@playwright/test'
import { DirType } from '../shared/constants'
import type { BackgroundJob, PaginatedResponse, StorageObject } from '../shared/types'
import { signUpAndGoToFiles } from './helpers'
const textType = 'text/plain'
const fixtureSize = 6 * 1024 * 1024
test.describe('Archive jobs with queued streaming workers @all', () => {
test.setTimeout(120_000)
test('compresses and extracts through the background queue', async ({ page }) => {
await signUpAndGoToFiles(page)
await seedFile(page, 'alpha.txt', randomBytes(fixtureSize))
await seedFile(page, 'beta.txt', randomBytes(fixtureSize))
await page.reload()
await selectFile(page, 'alpha.txt')
await selectFile(page, 'beta.txt')
await expect(page.getByTestId('files-toolbar-selection')).toContainText('2 selected')
const [compressResponse] = await Promise.all([
page.waitForResponse((response) => isBackgroundJobPost(response.url(), response.request().method())),
page.getByTitle('Compress').click(),
])
expect(compressResponse.ok()).toBe(true)
const compressJob = (await compressResponse.json()) as BackgroundJob
expect(compressJob.status).toBe('queued')
await expect(page.getByText('Background task created')).toBeVisible()
await expect(page.getByRole('link', { name: /tasks/i })).toContainText('1')
await expectJobCompleted(page, compressJob.id)
await page.goto('/tasks')
await page.getByRole('button', { name: 'Completed' }).click()
await expect(page.getByText('selection.zip')).toBeVisible()
await page.goto('/files')
await expect(page.getByRole('cell', { name: 'selection.zip' })).toBeVisible()
const [extractResponse] = await Promise.all([
page.waitForResponse((response) => isBackgroundJobPost(response.url(), response.request().method())),
openRowAction(page, 'selection.zip', 'Extract'),
])
expect(extractResponse.ok()).toBe(true)
const extractJob = (await extractResponse.json()) as BackgroundJob
expect(extractJob.status).toBe('queued')
await expect(page.getByRole('link', { name: /tasks/i })).toContainText('1')
await expectJobCompleted(page, extractJob.id)
await page.goto('/files')
await expect(page.getByRole('cell', { name: 'alpha (1).txt' })).toBeVisible()
await expect(page.getByRole('cell', { name: 'beta (1).txt' })).toBeVisible()
})
})
async function seedFile(page: Page, name: string, bytes: Buffer) {
const draftResponse = await page.request.post('/api/objects', {
data: {
name,
type: textType,
size: bytes.byteLength,
parent: '',
dirtype: DirType.FILE,
},
})
expect(draftResponse.ok()).toBe(true)
const draft = (await draftResponse.json()) as StorageObject & { uploadUrl: string }
expect(draft.uploadUrl).toBeTruthy()
const uploadResponse = await page.request.put(draft.uploadUrl, {
headers: { 'Content-Type': textType },
data: bytes,
})
expect(uploadResponse.ok()).toBe(true)
const confirmResponse = await page.request.patch(`/api/objects/${draft.id}`, {
data: { action: 'confirm' },
})
expect(confirmResponse.ok()).toBe(true)
}
async function selectFile(page: Page, name: string) {
const row = page.getByRole('row').filter({ hasText: name })
await expect(row).toBeVisible()
await row.getByRole('checkbox').check()
}
async function openRowAction(page: Page, fileName: string, action: string) {
const row = page.getByRole('row').filter({ hasText: fileName })
await row.getByRole('button').last().click()
await page.getByRole('menuitem', { name: action }).click()
}
async function expectJobCompleted(page: Page, jobId: string): Promise<BackgroundJob> {
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
const response = await page.request.get('/api/background-jobs?page=1&pageSize=20')
expect(response.ok()).toBe(true)
const body = (await response.json()) as PaginatedResponse<BackgroundJob>
const job = body.items.find((item) => item.id === jobId)
if (job?.status === 'completed') return job
if (job?.status === 'failed') throw new Error(job.errorMessage ?? 'Archive job failed')
await page.waitForTimeout(500)
}
throw new Error(`Timed out waiting for archive job ${jobId}`)
}
function isBackgroundJobPost(url: string, method: string) {
return method === 'POST' && url.includes('/api/background-jobs')
}
+21 -7
View File
@@ -9,15 +9,16 @@ import { hashPassword } from '../server/lib/password'
import { ADMIN_EMAIL, ADMIN_PASSWORD } from './helpers'
const localBaseUrl = process.env.E2E_LOCAL_BASE_URL ?? 'http://localhost:5173'
const defaultOrgQuota = process.env.E2E_DEFAULT_ORG_QUOTA ?? String(1024 * 1024 * 1024)
const storageConfig = {
title: 'E2E Storage',
mode: 'private',
bucket: 'e2e-test',
endpoint: 'https://localhost:9000',
region: 'auto',
accessKey: 'e2e-access-key',
secretKey: 'e2e-secret-key',
bucket: process.env.E2E_STORAGE_BUCKET ?? 'e2e-test',
endpoint: process.env.E2E_STORAGE_ENDPOINT ?? 'https://localhost:9000',
region: process.env.E2E_STORAGE_REGION ?? 'auto',
accessKey: process.env.E2E_STORAGE_ACCESS_KEY ?? 'e2e-access-key',
secretKey: process.env.E2E_STORAGE_SECRET_KEY ?? 'e2e-secret-key',
capacity: 0,
status: 'active',
}
@@ -69,11 +70,18 @@ function prepareNodeDatabase() {
.prepare(
`
INSERT INTO system_options (key, value, public)
VALUES (?, ?, 0), (?, ?, 0)
VALUES (?, ?, 0), (?, ?, 0), (?, ?, 0)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`,
)
.run('cloud_store_created_at', new Date().toISOString(), 'cloud_store_updated_at', new Date().toISOString())
.run(
'cloud_store_created_at',
new Date().toISOString(),
'cloud_store_updated_at',
new Date().toISOString(),
'default_org_quota',
defaultOrgQuota,
)
sqlite
.prepare(
@@ -194,6 +202,12 @@ setup('seed admin and storage', async () => {
}
}
const quotaResp = await request.put('/api/system/options/default_org_quota', {
headers,
data: { value: defaultOrgQuota },
})
if (!quotaResp.ok()) throw new Error(`could not set E2E default quota: ${quotaResp.status()}`)
if (ensureNodeStorage()) return
// E2E specs rely on self-service sign-up to create isolated users. Force the
+3 -1
View File
@@ -32,7 +32,9 @@
"format": "biome format --write .",
"e2e": "playwright test",
"e2e:cloud": "node scripts/run-cloud-e2e.mjs",
"e2e:cloud:cf": "node scripts/run-cloud-e2e.mjs --runtime cf"
"e2e:cloud:cf": "node scripts/run-cloud-e2e.mjs --runtime cf",
"e2e:archive": "node scripts/run-cloud-e2e.mjs --local --with-s3-mock --spec archive.spec.ts",
"e2e:archive:cf": "node scripts/run-cloud-e2e.mjs --runtime cf --local --with-s3-mock --spec archive.spec.ts"
},
"engines": {
"node": ">=24"
+13
View File
@@ -5,8 +5,20 @@ const envFile = process.env.CI ? '' : '--env-file=.dev.vars'
const chromeHostResolverRules = process.env.E2E_CHROME_HOST_RESOLVER_RULES
const appPort = Number(process.env.E2E_APP_PORT ?? 5173)
const apiPort = Number(process.env.E2E_API_PORT ?? 8222)
const s3MockPort = Number(process.env.E2E_S3_MOCK_PORT ?? 9191)
const s3MockServer = process.env.E2E_S3_MOCK
? [
{
command: `node scripts/s3-mock.mjs`,
port: s3MockPort,
reuseExistingServer: !process.env.CI,
},
]
: []
const nodeServers = [
...s3MockServer,
{
command: `PORT=${apiPort} node ${envFile} node_modules/.bin/tsx server/entry-node.ts`,
port: apiPort,
@@ -20,6 +32,7 @@ const nodeServers = [
]
const cfServers = [
...s3MockServer,
{
command: `vite dev --host 127.0.0.1 --port ${appPort} --strictPort`,
port: appPort,
+33 -11
View File
@@ -5,9 +5,13 @@ import { Resolver } from 'node:dns/promises'
const args = process.argv.slice(2)
const runtime = valueAfter('--runtime') ?? process.env.E2E_RUNTIME ?? 'node'
const project = valueAfter('--project') ?? 'desktop'
const spec = valueAfter('--spec') ?? 'cloud-store.spec.ts'
const local = args.includes('--local')
const withS3Mock = args.includes('--with-s3-mock')
const cloudflared = process.env.CLOUDFLARED_BIN ?? 'cloudflared'
const appPort = Number(process.env.E2E_APP_PORT ?? (runtime === 'cf' ? 6174 : 6173))
const apiPort = Number(process.env.E2E_API_PORT ?? 9222)
const s3MockPort = Number(process.env.E2E_S3_MOCK_PORT ?? 9191)
const localBaseUrl = `http://localhost:${appPort}`
const pidFile = `.cloudflared.${runtime}.pid`
const tunnelUrlPattern = /https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/
@@ -19,36 +23,41 @@ const cloudEnv = {
VITE_ZPAN_CLOUD_URL: process.env.VITE_ZPAN_CLOUD_URL ?? 'https://zpan-cloud-staging.saltbo.workers.dev',
}
const tunnel = await startTunnel(localBaseUrl)
const tunnelHost = new URL(tunnel.url).hostname
const tunnelIp = await waitForPublicTunnelIp(tunnelHost)
const tunnel = local ? null : await startTunnel(localBaseUrl)
const tunnelHost = tunnel ? new URL(tunnel.url).hostname : ''
const tunnelIp = tunnel ? await waitForPublicTunnelIp(tunnelHost) : ''
const baseUrl = tunnel?.url ?? localBaseUrl
const tunnelEnv = {
E2E_BASE_URL: tunnel.url,
E2E_BASE_URL: baseUrl,
E2E_LOCAL_BASE_URL: localBaseUrl,
E2E_APP_PORT: String(appPort),
E2E_API_PORT: String(apiPort),
BETTER_AUTH_URL: tunnel.url,
TRUSTED_ORIGINS: `${tunnel.url},${localBaseUrl}`,
E2E_CHROME_HOST_RESOLVER_RULES: `MAP ${tunnelHost} ${tunnelIp}`,
BETTER_AUTH_URL: baseUrl,
TRUSTED_ORIGINS: `${baseUrl},${localBaseUrl}`,
...(tunnel ? { E2E_CHROME_HOST_RESOLVER_RULES: `MAP ${tunnelHost} ${tunnelIp}` } : {}),
}
const e2eEnv = {
...cloudEnv,
...tunnelEnv,
...s3MockEnv(),
...runtimeCloudCredentials(runtime),
...(runtime === 'cf' ? { E2E_RUNTIME: 'cf' } : {}),
}
if (runtime === 'cf') {
if (local) rmSync('.wrangler/state/v3/d1', { recursive: true, force: true })
writeDevVars(e2eEnv)
await run('npx', ['wrangler', 'd1', 'migrations', 'apply', 'DB', '--local'], e2eEnv)
}
try {
await run('npx', ['playwright', 'test', 'cloud-store.spec.ts', `--project=${project}`], e2eEnv)
await run('npx', ['playwright', 'test', spec, `--project=${project}`], e2eEnv)
} finally {
try {
tunnel.process.kill()
} catch {}
if (tunnel) {
try {
tunnel.process.kill()
} catch {}
}
if (existsSync(pidFile)) {
const pid = Number(readFileSync(pidFile, 'utf8'))
if (Number.isInteger(pid)) {
@@ -77,6 +86,19 @@ function runtimeCloudCredentials(runtime) {
: {}
}
function s3MockEnv() {
if (!withS3Mock) return {}
return {
E2E_S3_MOCK: '1',
E2E_S3_MOCK_PORT: String(s3MockPort),
E2E_STORAGE_ENDPOINT: `http://127.0.0.1:${s3MockPort}`,
E2E_STORAGE_BUCKET: 'e2e-test',
E2E_STORAGE_REGION: 'auto',
E2E_STORAGE_ACCESS_KEY: 'e2e-access-key',
E2E_STORAGE_SECRET_KEY: 'e2e-secret-key',
}
}
function startTunnel(target) {
const child = spawn(cloudflared, ['tunnel', '--url', target, '--no-autoupdate'], {
stdio: ['ignore', 'pipe', 'pipe'],
+219
View File
@@ -0,0 +1,219 @@
import { createHash, randomUUID } from 'node:crypto'
import { createServer } from 'node:http'
const port = Number(process.env.E2E_S3_MOCK_PORT ?? 9191)
const objects = new Map()
const uploads = new Map()
const server = createServer(async (req, res) => {
setCors(res)
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
try {
await handleRequest(req, res)
} catch (error) {
res.writeHead(500, { 'Content-Type': 'text/plain' })
res.end(error instanceof Error ? error.message : String(error))
}
})
server.listen(port, '127.0.0.1', () => {
console.log(`[s3-mock] listening on http://127.0.0.1:${port}`)
})
async function handleRequest(req, res) {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
const { bucket, key } = parsePath(url.pathname)
if (url.pathname === '/health') {
res.writeHead(200)
res.end('ok')
return
}
if (!bucket) {
res.writeHead(200)
res.end('')
return
}
if (req.method === 'POST' && url.searchParams.has('uploads')) {
createMultipartUpload(res, bucket, key)
return
}
if (req.method === 'PUT' && url.searchParams.has('uploadId') && url.searchParams.has('partNumber')) {
await uploadPart(req, res, url)
return
}
if (req.method === 'POST' && url.searchParams.has('uploadId')) {
completeMultipartUpload(res, url, bucket, key)
return
}
if (req.method === 'DELETE' && url.searchParams.has('uploadId')) {
uploads.delete(url.searchParams.get('uploadId'))
res.writeHead(204)
res.end()
return
}
const objectKey = storageKey(bucket, key)
if (req.method === 'PUT') {
const body = await readBody(req)
objects.set(objectKey, {
body,
contentType: req.headers['content-type'] ?? 'application/octet-stream',
})
res.writeHead(200, { etag: etag(body) })
res.end('')
return
}
if (req.method === 'HEAD') {
const object = objects.get(objectKey)
if (!object) {
res.writeHead(404)
res.end()
return
}
res.writeHead(200, {
'Content-Length': object.body.byteLength,
'Content-Type': object.contentType,
etag: etag(object.body),
})
res.end()
return
}
if (req.method === 'GET') {
const object = objects.get(objectKey)
if (!object) {
res.writeHead(404)
res.end('Not found')
return
}
writeObject(res, object, req.headers.range)
return
}
if (req.method === 'DELETE') {
objects.delete(objectKey)
res.writeHead(204)
res.end()
return
}
res.writeHead(405)
res.end('Method not allowed')
}
function createMultipartUpload(res, bucket, key) {
const uploadId = randomUUID()
uploads.set(uploadId, { bucket, key, parts: new Map() })
res.writeHead(200, { 'Content-Type': 'application/xml' })
res.end(`<CreateMultipartUploadResult><UploadId>${uploadId}</UploadId></CreateMultipartUploadResult>`)
}
async function uploadPart(req, res, url) {
const uploadId = url.searchParams.get('uploadId')
const upload = uploads.get(uploadId)
if (!upload) {
res.writeHead(404)
res.end('Upload not found')
return
}
const partNumber = Number(url.searchParams.get('partNumber'))
const body = await readBody(req)
upload.parts.set(partNumber, body)
res.writeHead(200, { etag: etag(body) })
res.end('')
}
function completeMultipartUpload(res, url, bucket, key) {
const uploadId = url.searchParams.get('uploadId')
const upload = uploads.get(uploadId)
if (!upload) {
res.writeHead(404)
res.end('Upload not found')
return
}
const parts = [...upload.parts.entries()].sort(([left], [right]) => left - right)
const total = parts.reduce((sum, [, part]) => sum + part.byteLength, 0)
const body = new Uint8Array(total)
let offset = 0
for (const [, part] of parts) {
body.set(part, offset)
offset += part.byteLength
}
objects.set(storageKey(bucket, key), { body, contentType: 'application/octet-stream' })
uploads.delete(uploadId)
res.writeHead(200, { 'Content-Type': 'application/xml' })
res.end('<CompleteMultipartUploadResult />')
}
function writeObject(res, object, rangeHeader) {
if (!rangeHeader) {
res.writeHead(200, {
'Content-Length': object.body.byteLength,
'Content-Type': object.contentType,
etag: etag(object.body),
})
res.end(object.body)
return
}
const match = /^bytes=(\d+)-(\d+)?$/.exec(rangeHeader)
if (!match) {
res.writeHead(416)
res.end()
return
}
const start = Number(match[1])
const end = match[2] ? Number(match[2]) : object.body.byteLength - 1
const slice = object.body.slice(start, end + 1)
res.writeHead(206, {
'Content-Length': slice.byteLength,
'Content-Range': `bytes ${start}-${end}/${object.body.byteLength}`,
'Content-Type': object.contentType,
etag: etag(object.body),
})
res.end(slice)
}
function parsePath(pathname) {
const parts = pathname.split('/').filter(Boolean).map(decodeURIComponent)
return {
bucket: parts[0] ?? '',
key: parts.slice(1).join('/'),
}
}
function storageKey(bucket, key) {
return `${bucket}/${key}`
}
function setCors(res) {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,PUT,POST,DELETE,OPTIONS')
res.setHeader('Access-Control-Allow-Headers', '*')
res.setHeader('Access-Control-Expose-Headers', 'ETag,Content-Length,Content-Range,Content-Type')
}
function etag(body) {
return `"${createHash('md5').update(body).digest('hex')}"`
}
async function readBody(req) {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
return new Uint8Array(Buffer.concat(chunks))
}
@@ -1,5 +1,6 @@
import { sql } from 'drizzle-orm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ARCHIVE_QUEUE_BINDING, type ArchiveJobMessage, runArchiveJobMessage } from '../services/archive-jobs'
import {
cancelBackgroundJob,
createBackgroundJob,
@@ -33,7 +34,7 @@ describe('background jobs API', () => {
vi.restoreAllMocks()
})
it('creates archive jobs through POST and returns the final job state', async () => {
it('creates archive jobs through POST and completes them after the response', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app, 'jobs-create@example.com')
const { orgId } = await getUserOrg(db, 'jobs-create@example.com')
@@ -46,10 +47,25 @@ describe('background jobs API', () => {
const objectStore = new Map<string, Uint8Array>([['route/source.zip', createZip({ 'route.txt': bytes('ok') })]])
const putKeys: string[] = []
vi.spyOn(S3Service.prototype, 'getObjectBytes').mockImplementation(async (_storage, key) => {
vi.spyOn(S3Service.prototype, 'headObject').mockImplementation(async (_storage, key) => {
const bytes = objectStore.get(key)
if (!bytes) throw new Error(`missing ${key}`)
return bytes
return { size: bytes.byteLength, contentType: 'application/zip' }
})
vi.spyOn(S3Service.prototype, 'getObjectBytes').mockImplementation(async (_storage, key, range) => {
const bytes = objectStore.get(key)
if (!bytes) throw new Error(`missing ${key}`)
return range ? sliceRange(bytes, range) : bytes
})
vi.spyOn(S3Service.prototype, 'getObjectStream').mockImplementation(async (_storage, key) => {
const bytes = objectStore.get(key)
if (!bytes) throw new Error(`missing ${key}`)
return new ReadableStream({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
})
})
vi.spyOn(S3Service.prototype, 'putObject').mockImplementation(async (_storage, key, body) => {
const bytes = body instanceof Uint8Array ? body : new Uint8Array(await new Response(body).arrayBuffer())
@@ -65,7 +81,14 @@ describe('background jobs API', () => {
})
expect(res.status).toBe(201)
await expect(res.json()).resolves.toMatchObject({
const created = (await res.json()) as { id: string }
expect(created).toMatchObject({
orgId,
type: 'archive_extract',
status: 'queued',
})
const completed = await waitForJob(db, orgId, created.id, 'completed')
expect(completed).toMatchObject({
orgId,
type: 'archive_extract',
status: 'completed',
@@ -74,6 +97,66 @@ describe('background jobs API', () => {
expect(putKeys).toHaveLength(1)
})
it('dispatches archive jobs to Cloudflare Queue bindings and lets the consumer complete them', async () => {
const messages: ArchiveJobMessage[] = []
const queue = { send: async (message: ArchiveJobMessage) => messages.push(message) }
const { app, db, platform } = await createTestApp({}, { [ARCHIVE_QUEUE_BINDING]: queue })
const headers = await authedHeaders(app, 'jobs-queue@example.com')
const { orgId } = await getUserOrg(db, 'jobs-queue@example.com')
await seedStorage(db)
const now = Date.now()
await db.run(sql`
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at)
VALUES ('queue-zip', ${orgId}, 'queue-zip-alias', 'queue.zip', 'application/zip', 200, 0, '', 'queue/source.zip', 'route-storage', 'active', ${now}, ${now})
`)
const objectStore = new Map<string, Uint8Array>([['queue/source.zip', createZip({ 'queue.txt': bytes('ok') })]])
vi.spyOn(S3Service.prototype, 'headObject').mockImplementation(async (_storage, key) => {
const bytes = objectStore.get(key)
if (!bytes) throw new Error(`missing ${key}`)
return { size: bytes.byteLength, contentType: 'application/zip' }
})
vi.spyOn(S3Service.prototype, 'getObjectBytes').mockImplementation(async (_storage, key, range) => {
const bytes = objectStore.get(key)
if (!bytes) throw new Error(`missing ${key}`)
return range ? sliceRange(bytes, range) : bytes
})
vi.spyOn(S3Service.prototype, 'getObjectStream').mockImplementation(async (_storage, key) => {
const bytes = objectStore.get(key)
if (!bytes) throw new Error(`missing ${key}`)
return new ReadableStream({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
})
})
vi.spyOn(S3Service.prototype, 'putObject').mockImplementation(async (_storage, key, body) => {
const bytes = body instanceof Uint8Array ? body : new Uint8Array(await new Response(body).arrayBuffer())
objectStore.set(key, bytes)
return bytes.byteLength
})
const res = await app.request('/api/background-jobs', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'archive_extract', matterId: 'queue-zip' }),
})
expect(res.status).toBe(201)
const created = (await res.json()) as { id: string; status: string }
expect(created.status).toBe('queued')
expect(messages).toHaveLength(1)
await expect(getBackgroundJob(db, orgId, created.id)).resolves.toMatchObject({ status: 'queued' })
await runArchiveJobMessage(platform, messages[0])
await expect(getBackgroundJob(db, orgId, created.id)).resolves.toMatchObject({
status: 'completed',
progress: { outputBytes: 2, fileCount: 1 },
})
})
it('returns a failed archive job for a missing explicit target folder', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app, 'jobs-missing-target@example.com')
@@ -92,7 +175,15 @@ describe('background jobs API', () => {
})
expect(res.status).toBe(201)
await expect(res.json()).resolves.toMatchObject({
const created = (await res.json()) as { id: string }
expect(created).toMatchObject({
orgId,
type: 'archive_compress',
status: 'queued',
errorMessage: null,
})
const failed = await waitForJob(db, orgId, created.id, 'failed')
expect(failed).toMatchObject({
orgId,
type: 'archive_compress',
status: 'failed',
@@ -122,7 +213,15 @@ describe('background jobs API', () => {
})
expect(res.status).toBe(201)
await expect(res.json()).resolves.toMatchObject({
const created = (await res.json()) as { id: string }
expect(created).toMatchObject({
orgId,
type: 'archive_extract',
status: 'queued',
errorMessage: null,
})
const failed = await waitForJob(db, orgId, created.id, 'failed')
expect(failed).toMatchObject({
orgId,
type: 'archive_extract',
status: 'failed',
@@ -235,6 +334,20 @@ async function seedStorage(db: TestDb): Promise<void> {
`)
}
async function waitForJob(
db: TestDb,
orgId: string,
jobId: string,
status: 'completed' | 'failed',
): Promise<Awaited<ReturnType<typeof getBackgroundJob>>> {
for (let i = 0; i < 20; i++) {
const job = await getBackgroundJob(db, orgId, jobId)
if (job.status === status) return job
await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error(`Job ${jobId} did not reach ${status}`)
}
function createZip(files: Record<string, Uint8Array>): Uint8Array {
const encoder = new TextEncoder()
const localParts: Uint8Array[] = []
@@ -282,6 +395,12 @@ function bytes(value: string): Uint8Array {
return new TextEncoder().encode(value)
}
function sliceRange(bytes: Uint8Array, range: string): Uint8Array {
const match = /^bytes=(\d+)-(\d+)$/.exec(range)
if (!match) throw new Error(`Unsupported range: ${range}`)
return bytes.slice(Number(match[1]), Number(match[2]) + 1)
}
function concat(parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0))
let offset = 0
+21 -5
View File
@@ -5,7 +5,8 @@ import { Hono } from 'hono'
import { createBackgroundJobRequestSchema, listBackgroundJobsQuerySchema } from '../../shared/schemas'
import { requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { createArchiveJob } from '../services/archive-processing'
import { dispatchArchiveJob } from '../services/archive-jobs'
import { enqueueArchiveJob } from '../services/archive-processing'
import {
BackgroundJobError,
cancelBackgroundJob,
@@ -32,11 +33,15 @@ const backgroundJobs = new Hono<Env>()
const orgId = requireOrg(c)
const userId = c.get('userId')
if (!userId) throw new BackgroundJobError('not_found')
return createArchiveJob(c.get('platform').db, {
const db = c.get('platform').db
const request = c.req.valid('json')
const job = await enqueueArchiveJob(db, {
orgId,
userId,
request: c.req.valid('json'),
request,
})
await dispatchArchiveJob(c.get('platform'), { orgId, userId, request, jobId: job.id })
return job
},
201,
),
@@ -58,7 +63,18 @@ const backgroundJobs = new Hono<Env>()
c,
async () => {
const orgId = requireOrg(c)
return retryBackgroundJob(c.get('platform').db, orgId, c.req.param('id'))
const db = c.get('platform').db
const job = await retryBackgroundJob(db, orgId, c.req.param('id'))
const request = createBackgroundJobRequestSchema.safeParse(job.metadata)
if (request.success) {
await dispatchArchiveJob(c.get('platform'), {
orgId,
userId: job.userId,
request: request.data,
jobId: job.id,
})
}
return job
},
201,
),
@@ -66,7 +82,7 @@ const backgroundJobs = new Hono<Env>()
export default backgroundJobs
function requireOrg(c: Context<Env>): string {
function requireOrg(c: { get(key: 'orgId'): string | null }): string {
const orgId = c.get('orgId')
if (!orgId) throw new BackgroundJobError('not_found')
return orgId
+67
View File
@@ -0,0 +1,67 @@
import type { CreateBackgroundJobRequest } from '@shared/schemas'
import type { Platform } from '../platform/interface'
import { processArchiveJob } from './archive-processing'
export const ARCHIVE_QUEUE_BINDING = 'ARCHIVE_QUEUE'
export interface ArchiveJobMessage {
jobId: string
orgId: string
userId: string
request: CreateBackgroundJobRequest
}
interface QueueProducer {
send(message: ArchiveJobMessage): Promise<void>
}
class LocalArchiveQueue {
private readonly pending: Array<{ platform: Platform; message: ArchiveJobMessage }> = []
private running = false
push(platform: Platform, message: ArchiveJobMessage): void {
this.pending.push({ platform, message })
if (!this.running) setTimeout(() => void this.drain(), 0)
}
private async drain(): Promise<void> {
if (this.running) return
this.running = true
try {
for (;;) {
const next = this.pending.shift()
if (!next) return
try {
await runArchiveJobMessage(next.platform, next.message)
} catch (error) {
console.error('[archive-jobs] local worker failed:', error)
}
}
} finally {
this.running = false
if (this.pending.length > 0) setTimeout(() => void this.drain(), 0)
}
}
}
const localArchiveQueue = new LocalArchiveQueue()
export async function dispatchArchiveJob(platform: Platform, message: ArchiveJobMessage): Promise<void> {
const queue = platform.getBinding<QueueProducer>(ARCHIVE_QUEUE_BINDING)
if (queue) {
await queue.send(message)
return
}
localArchiveQueue.push(platform, message)
}
export async function runArchiveJobMessage(platform: Platform, message: ArchiveJobMessage): Promise<void> {
await processArchiveJob(platform.db, {
orgId: message.orgId,
userId: message.userId,
request: message.request,
jobId: message.jobId,
})
}
+146 -16
View File
@@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest'
import { createTestApp } from '../test/setup.js'
import { createArchiveJob } from './archive-processing'
import type { S3Service } from './s3'
import { collectCompressionPlan } from './zip-compress'
import { validateAndExtractZip } from './zip-extract'
import { collectCompressionPlan, createZipArchiveStream, ZIP_COMPRESS_LIMITS } from './zip-compress'
import { validateAndExtractZip, ZIP_EXTRACT_LIMITS } from './zip-extract'
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
@@ -16,12 +16,29 @@ class MemoryS3 {
objects = new Map<string, Uint8Array>()
putKeys: string[] = []
async getObjectBytes(_storage: unknown, key: string): Promise<Uint8Array> {
async getObjectBytes(_storage: unknown, key: string, range?: string): Promise<Uint8Array> {
const bytes = this.objects.get(key)
if (!bytes) throw new Error(`Object not found: ${key}`)
if (range) return sliceRange(bytes, range)
return bytes
}
async headObject(_storage: unknown, key: string): Promise<{ size: number; contentType: string }> {
const bytes = this.objects.get(key)
if (!bytes) throw new Error(`Object not found: ${key}`)
return { size: bytes.byteLength, contentType: 'application/octet-stream' }
}
async getObjectStream(_storage: unknown, key: string): Promise<ReadableStream<Uint8Array>> {
const bytes = await this.getObjectBytes(_storage, key)
return new ReadableStream({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
})
}
async putObject(_storage: unknown, key: string, body: Uint8Array | ReadableStream): Promise<number> {
const bytes = body instanceof Uint8Array ? body : new Uint8Array(await new Response(body).arrayBuffer())
this.objects.set(key, bytes)
@@ -58,6 +75,30 @@ class FailAfterPutS3 extends MemoryS3 {
}
}
class GeneratedObjectS3 extends MemoryS3 {
putSizes = new Map<string, number>()
constructor(
private readonly generatedKey: string,
private readonly generatedSize: number,
) {
super()
}
override async getObjectStream(_storage: unknown, key: string): Promise<ReadableStream<Uint8Array>> {
if (key === this.generatedKey) return generatedBytes(this.generatedSize)
return super.getObjectStream(_storage, key)
}
override async putObject(_storage: unknown, key: string, body: Uint8Array | ReadableStream): Promise<number> {
const size = body instanceof Uint8Array ? body.byteLength : await drainStream(body)
this.objects.set(key, new Uint8Array())
this.putKeys.push(key)
this.putSizes.set(key, size)
return size
}
}
describe('archive processing', () => {
it('extracts a small ZIP into folder and file matters and writes objects', async () => {
const { db } = await createTestApp()
@@ -90,6 +131,30 @@ describe('archive processing', () => {
expect(s3.putKeys).toHaveLength(1)
})
it('prevalidates then streams extraction for a 128 MiB ZIP entry', async () => {
const { db } = await createTestApp()
await seedStorage(db)
const size = 128 * 1024 * 1024
const archive = await streamToBytes(
createZipArchiveStream([{ archivePath: 'large.bin', openStream: async () => generatedBytes(size) }]),
)
await seedMatter(db, { id: 'large-zip', name: 'large.zip', object: 'source/large.zip', size: archive.byteLength })
const s3 = new GeneratedObjectS3('unused', 0)
s3.objects.set('source/large.zip', archive)
const job = await createArchiveJob(db, {
orgId: ORG_ID,
userId: USER_ID,
request: { type: 'archive_extract', matterId: 'large-zip' },
s3: s3 as unknown as S3Service,
})
expect(job).toMatchObject({ status: 'completed', type: 'archive_extract' })
expect(job.progress).toMatchObject({ outputBytes: size, fileCount: 1 })
expect(s3.putKeys).toHaveLength(1)
expect(s3.putSizes.get(s3.putKeys[0])).toBe(size)
}, 60_000)
it('compresses selected matters into a ZIP matter and object', async () => {
const { db } = await createTestApp()
await seedStorage(db)
@@ -116,6 +181,26 @@ describe('archive processing', () => {
expect(s3.objects.get(zipMatter[0].object)?.length).toBe(zipMatter[0].size)
})
it('streams compression for a 128 MiB source without buffering the source object', async () => {
const { db } = await createTestApp()
await seedStorage(db)
const size = 128 * 1024 * 1024
await seedMatter(db, { id: 'large-file', name: 'large.bin', object: 'objects/large.bin', size })
const s3 = new GeneratedObjectS3('objects/large.bin', size)
const job = await createArchiveJob(db, {
orgId: ORG_ID,
userId: USER_ID,
request: { type: 'archive_compress', matterIds: ['large-file'] },
s3: s3 as unknown as S3Service,
})
expect(job).toMatchObject({ status: 'completed', type: 'archive_compress' })
expect(job.progress).toMatchObject({ inputBytes: size, processedBytes: size, fileCount: 1 })
expect(s3.putKeys).toHaveLength(1)
expect(s3.putSizes.get(s3.putKeys[0]) ?? 0).toBeGreaterThan(0)
}, 60_000)
it('compresses an empty selected folder as a ZIP directory entry', async () => {
const { db } = await createTestApp()
await seedStorage(db)
@@ -194,7 +279,10 @@ describe('archive processing', () => {
const s3 = new MemoryS3()
s3.objects.set(
'source/large.zip',
createZip({ 'large.bin': bytes('x') }, { declaredSizes: { 'large.bin': 25 * 1024 * 1024 + 1 } }),
createZip(
{ 'large.bin': bytes('x') },
{ declaredSizes: { 'large.bin': ZIP_EXTRACT_LIMITS.singleFileBytes + 1 } },
),
)
const job = await createArchiveJob(db, {
@@ -206,7 +294,7 @@ describe('archive processing', () => {
expect(job).toMatchObject({
status: 'failed',
errorMessage: 'ZIP entry exceeds 26214400 bytes',
errorMessage: `ZIP entry exceeds ${ZIP_EXTRACT_LIMITS.singleFileBytes} bytes`,
})
expect(s3.putKeys).toHaveLength(0)
})
@@ -232,11 +320,11 @@ describe('archive processing', () => {
expect(job.status).toBe('failed')
expect(job.errorMessage).toBe('Quota exceeded for extracted ZIP contents')
expect(s3.putKeys).toHaveLength(0)
expect(s3.objects.size).toBe(1)
await expect(activeMatterCount(db)).resolves.toBe(1)
})
it('fails compression quota checks before writing output', async () => {
it('fails compression quota checks and removes streamed output', async () => {
const { db } = await createTestApp()
await seedStorage(db)
await db.run(sql`
@@ -257,7 +345,7 @@ describe('archive processing', () => {
expect(job.status).toBe('failed')
expect(job.errorMessage).toBe('Quota exceeded for generated ZIP archive')
expect(s3.putKeys).toHaveLength(0)
expect(s3.objects.size).toBe(1)
await expect(activeMatterCount(db)).resolves.toBe(1)
})
@@ -437,7 +525,7 @@ describe('archive processing', () => {
id: 'large-file',
name: 'large.bin',
object: 'objects/large.bin',
size: 25 * 1024 * 1024 + 1,
size: ZIP_COMPRESS_LIMITS.singleFileBytes + 1,
})
await seedMatter(db, {
id: 'deep-file',
@@ -455,7 +543,7 @@ describe('archive processing', () => {
'Only active matters can be archived',
)
await expect(collectCompressionPlan(db, ORG_ID, ['large-file'])).rejects.toThrow(
'Compression source file exceeds 26214400 bytes',
`Compression source file exceeds ${ZIP_COMPRESS_LIMITS.singleFileBytes} bytes`,
)
await expect(collectCompressionPlan(db, ORG_ID, ['deep-file'])).rejects.toThrow(
'Compression directory depth exceeds 10',
@@ -482,13 +570,15 @@ describe('archive processing', () => {
const { db } = await createTestApp()
await seedStorage(db)
const ids: string[] = []
for (let index = 0; index < 201; index += 1) {
for (let index = 0; index < ZIP_COMPRESS_LIMITS.fileCount + 1; index += 1) {
const id = `many-${index}`
ids.push(id)
await seedMatter(db, { id, name: `${id}.txt`, object: `objects/${id}.txt`, size: 1 })
}
await expect(collectCompressionPlan(db, ORG_ID, ids)).rejects.toThrow('Compression file count exceeds 200')
await expect(collectCompressionPlan(db, ORG_ID, ids)).rejects.toThrow(
`Compression file count exceeds ${ZIP_COMPRESS_LIMITS.fileCount}`,
)
})
it('rejects unsafe and unsupported ZIP entries during validation', () => {
@@ -517,16 +607,20 @@ describe('archive processing', () => {
})
it('enforces ZIP validation count and total output limits from metadata', () => {
const manyEntries = Object.fromEntries(Array.from({ length: 201 }, (_, index) => [`file-${index}.txt`, bytes('x')]))
expect(() => validateAndExtractZip(createZip(manyEntries))).toThrow('ZIP file count exceeds 200')
const manyEntries = Object.fromEntries(
Array.from({ length: ZIP_EXTRACT_LIMITS.fileCount + 1 }, (_, index) => [`file-${index}.txt`, bytes('x')]),
)
expect(() => validateAndExtractZip(createZip(manyEntries))).toThrow(
`ZIP file count exceeds ${ZIP_EXTRACT_LIMITS.fileCount}`,
)
const totalLimitEntries = Object.fromEntries(
Array.from({ length: 5 }, (_, index) => [`total-${index}`, bytes('x')]),
)
const totalLimitSizes = Object.fromEntries(
Array.from({ length: 5 }, (_, index) => [`total-${index}`, 21 * 1024 * 1024]),
Array.from({ length: 5 }, (_, index) => [`total-${index}`, 256 * 1024 * 1024]),
)
expect(() => validateAndExtractZip(createZip(totalLimitEntries, { declaredSizes: totalLimitSizes }))).toThrow(
'ZIP extraction output exceeds 104857600 bytes',
`ZIP extraction output exceeds ${ZIP_EXTRACT_LIMITS.totalOutputBytes} bytes`,
)
})
})
@@ -626,6 +720,42 @@ function bytes(value: string): Uint8Array {
return new TextEncoder().encode(value)
}
function generatedBytes(size: number): ReadableStream<Uint8Array> {
const chunk = new Uint8Array(1024 * 1024)
let remaining = size
return new ReadableStream({
pull(controller) {
if (remaining <= 0) {
controller.close()
return
}
const length = Math.min(chunk.byteLength, remaining)
controller.enqueue(length === chunk.byteLength ? chunk : chunk.slice(0, length))
remaining -= length
},
})
}
async function drainStream(stream: ReadableStream): Promise<number> {
const reader = stream.getReader()
let size = 0
for (;;) {
const { done, value } = await reader.read()
if (done) return size
size += value instanceof Uint8Array ? value.byteLength : 0
}
}
async function streamToBytes(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
return new Uint8Array(await new Response(stream).arrayBuffer())
}
function sliceRange(bytes: Uint8Array, range: string): Uint8Array {
const match = /^bytes=(\d+)-(\d+)$/.exec(range)
if (!match) throw new Error(`Unsupported range: ${range}`)
return bytes.slice(Number(match[1]), Number(match[2]) + 1)
}
function concat(parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0))
let offset = 0
+101 -56
View File
@@ -7,11 +7,12 @@ import { matters } from '../db/schema'
import type { Database } from '../platform/interface'
import { createBackgroundJob, updateBackgroundJob } from './background-jobs'
import { createMatter, decrementUsage, getMatter, incrementUsageIfAllowed, purgeMatters } from './matter'
import { createNotification } from './notification'
import { buildObjectKey } from './path-template'
import { S3Service } from './s3'
import { getStorage, selectStorage } from './storage'
import { collectCompressionPlan, createZipArchive } from './zip-compress'
import { validateAndExtractZip } from './zip-extract'
import { collectCompressionPlan, createZipArchiveStream } from './zip-compress'
import { streamValidatedZip, validateZipDirectory } from './zip-extract'
export interface CreateArchiveJobInput {
orgId: string
@@ -24,8 +25,13 @@ const ZIP_MIME = 'application/zip'
const DEFAULT_FILE_MIME = 'application/octet-stream'
export async function createArchiveJob(db: Database, input: CreateArchiveJobInput): Promise<BackgroundJob> {
const job = await enqueueArchiveJob(db, input)
return processArchiveJob(db, { ...input, jobId: job.id })
}
export async function enqueueArchiveJob(db: Database, input: CreateArchiveJobInput): Promise<BackgroundJob> {
const targetFolder = input.request.targetFolder ?? null
const job = await createBackgroundJob(db, {
return createBackgroundJob(db, {
orgId: input.orgId,
userId: input.userId,
type: input.request.type,
@@ -33,21 +39,30 @@ export async function createArchiveJob(db: Database, input: CreateArchiveJobInpu
metadata: input.request,
cancelable: false,
})
}
export async function processArchiveJob(
db: Database,
input: CreateArchiveJobInput & { jobId: string },
): Promise<BackgroundJob> {
const s3 = input.s3 ?? new S3Service()
try {
await updateBackgroundJob(db, input.orgId, job.id, { status: 'running', startedAt: new Date() })
if (input.request.type === 'archive_compress') {
return await runCompressionJob(db, s3, job.id, input.orgId, input.userId, input.request)
}
return await runExtractionJob(db, s3, job.id, input.orgId, input.userId, input.request)
await updateBackgroundJob(db, input.orgId, input.jobId, { status: 'running', startedAt: new Date() })
const finished =
input.request.type === 'archive_compress'
? await runCompressionJob(db, s3, input.jobId, input.orgId, input.userId, input.request)
: await runExtractionJob(db, s3, input.jobId, input.orgId, input.userId, input.request)
await notifyArchiveJobFinished(db, finished)
return finished
} catch (error) {
return updateBackgroundJob(db, input.orgId, job.id, {
const failed = await updateBackgroundJob(db, input.orgId, input.jobId, {
status: 'failed',
errorMessage: (error as Error).message,
retryable: false,
cancelable: false,
})
await notifyArchiveJobFinished(db, failed)
return failed
}
}
@@ -68,28 +83,30 @@ async function runCompressionJob(
progress: { inputBytes: plan.inputBytes, fileCount: plan.files.length },
})
const objects = []
const sources = []
for (const file of plan.files) {
const storage = await requireStorage(db, file.matter.storageId)
objects.push({ archivePath: file.archivePath, bytes: await s3.getObjectBytes(storage, file.matter.object) })
sources.push({
archivePath: file.archivePath,
openStream: () => s3.getObjectStream(storage, file.matter.object),
})
}
const zipBytes = createZipArchive(objects, plan.directories)
const targetStorage = (await selectStorage(db, 'private')) as unknown as S3StorageType
const allowed = await incrementUsageIfAllowed(db, orgId, targetStorage.id, zipBytes.length)
if (!allowed) throw new Error('Quota exceeded for generated ZIP archive')
const key = buildObjectKey({ uid: userId, orgId, rawExt: '.zip' })
let objectWritten = false
let outputBytes = 0
try {
await s3.putObject(targetStorage, key, zipBytes, ZIP_MIME)
outputBytes = await s3.putObject(targetStorage, key, createZipArchiveStream(sources, plan.directories), ZIP_MIME)
objectWritten = true
const allowed = await incrementUsageIfAllowed(db, orgId, targetStorage.id, outputBytes)
if (!allowed) throw new Error('Quota exceeded for generated ZIP archive')
const matter = await createMatter(db, {
orgId,
userId,
name: plan.outputName,
type: ZIP_MIME,
size: zipBytes.length,
size: outputBytes,
dirtype: DirType.FILE,
parent: plan.targetFolder,
object: key,
@@ -102,16 +119,16 @@ async function runCompressionJob(
status: 'completed',
progress: {
inputBytes: plan.inputBytes,
outputBytes: zipBytes.length,
outputBytes,
processedBytes: plan.inputBytes,
fileCount: plan.files.length,
currentFilename: null,
},
resultMetadata: { matterId: matter.id, outputName: matter.name, outputBytes: zipBytes.length },
resultMetadata: { matterId: matter.id, outputName: matter.name, outputBytes },
cancelable: false,
})
} catch (error) {
await decrementUsage(db, orgId, new Map([[targetStorage.id, zipBytes.length]]), zipBytes.length)
if (outputBytes > 0) await decrementUsage(db, orgId, new Map([[targetStorage.id, outputBytes]]), outputBytes)
if (objectWritten) await s3.deleteObject(targetStorage, key)
throw error
}
@@ -133,51 +150,37 @@ async function runExtractionJob(
if (request.targetFolder !== undefined) await requireTargetFolder(db, orgId, request.targetFolder)
const sourceStorage = await requireStorage(db, zipMatter.storageId)
const zipBytes = await s3.getObjectBytes(sourceStorage, zipMatter.object)
const archive = validateAndExtractZip(zipBytes)
const sourceHead = await s3.headObject(sourceStorage, zipMatter.object)
const plan = await validateZipDirectory(sourceHead.size, (start, end) =>
s3.getObjectBytes(sourceStorage, zipMatter.object, `bytes=${start}-${end}`),
)
const targetFolder = request.targetFolder ?? zipMatter.parent
const targetStorage = (await selectStorage(db, 'private')) as unknown as S3StorageType
const allowed = await incrementUsageIfAllowed(db, orgId, targetStorage.id, archive.totalBytes)
if (!allowed) throw new Error('Quota exceeded for extracted ZIP contents')
const writtenKeys: string[] = []
const createdMatterIds: string[] = []
const folderParents = new Map<string, string>()
let outputBytes = 0
try {
const folderParents = new Map<string, string>()
for (const folderPath of archive.folders) {
const parts = folderPath.split('/')
const parentPath = parts.slice(0, -1).join('/')
const parent = parentPath ? folderParents.get(parentPath) : targetFolder
if (parent === undefined) throw new Error(`Missing parent folder for ${folderPath}`)
const folder = await createMatter(db, {
orgId,
userId,
name: parts[parts.length - 1],
type: 'folder',
size: 0,
dirtype: DirType.USER_FOLDER,
parent,
object: '',
storageId: targetStorage.id,
status: 'active',
onConflict: 'rename',
})
createdMatterIds.push(folder.id)
folderParents.set(folderPath, buildMatterPath(folder.parent, folder.name))
for (const folderPath of plan.folders) {
await ensureExtractedFolder(folderPath)
}
const allowed = await incrementUsageIfAllowed(db, orgId, targetStorage.id, plan.totalBytes)
if (!allowed) throw new Error('Quota exceeded for extracted ZIP contents')
outputBytes = plan.totalBytes
for (const file of archive.files) {
const parent = file.parentPath ? folderParents.get(file.parentPath) : targetFolder
if (parent === undefined) throw new Error(`Missing parent folder for ${file.path}`)
const zipStream = await s3.getObjectStream(sourceStorage, zipMatter.object)
const archive = await streamValidatedZip(zipStream, async (file) => {
const parent = file.parentPath ? await ensureExtractedFolder(file.parentPath) : targetFolder
const key = buildObjectKey({ uid: userId, orgId, rawExt: extension(file.name) })
await s3.putObject(targetStorage, key, file.bytes, DEFAULT_FILE_MIME)
const size = await s3.putObject(targetStorage, key, file.stream, DEFAULT_FILE_MIME)
await file.size
writtenKeys.push(key)
const matter = await createMatter(db, {
orgId,
userId,
name: file.name,
type: DEFAULT_FILE_MIME,
size: file.size,
size,
dirtype: DirType.FILE,
parent,
object: key,
@@ -186,15 +189,15 @@ async function runExtractionJob(
onConflict: 'rename',
})
createdMatterIds.push(matter.id)
}
})
return updateBackgroundJob(db, orgId, jobId, {
status: 'completed',
progress: {
inputBytes: zipMatter.size ?? zipBytes.length,
inputBytes: sourceHead.size,
outputBytes: archive.totalBytes,
processedBytes: zipMatter.size ?? zipBytes.length,
fileCount: archive.files.length,
processedBytes: sourceHead.size,
fileCount: plan.fileCount,
currentFilename: null,
},
resultMetadata: { matterIds: createdMatterIds, outputBytes: archive.totalBytes },
@@ -202,10 +205,36 @@ async function runExtractionJob(
})
} catch (error) {
await purgeMatters(db, orgId, createdMatterIds)
await decrementUsage(db, orgId, new Map([[targetStorage.id, archive.totalBytes]]), archive.totalBytes)
if (outputBytes > 0) await decrementUsage(db, orgId, new Map([[targetStorage.id, outputBytes]]), outputBytes)
await s3.deleteObjects(targetStorage, writtenKeys)
throw error
}
async function ensureExtractedFolder(folderPath: string): Promise<string> {
const existing = folderParents.get(folderPath)
if (existing) return existing
const parts = folderPath.split('/')
const parentPath = parts.slice(0, -1).join('/')
const parent = parentPath ? await ensureExtractedFolder(parentPath) : targetFolder
const folder = await createMatter(db, {
orgId,
userId,
name: parts[parts.length - 1],
type: 'folder',
size: 0,
dirtype: DirType.USER_FOLDER,
parent,
object: '',
storageId: targetStorage.id,
status: 'active',
onConflict: 'rename',
})
createdMatterIds.push(folder.id)
const matterPath = buildMatterPath(folder.parent, folder.name)
folderParents.set(folderPath, matterPath)
return matterPath
}
}
async function requireStorage(db: Database, storageId: string): Promise<S3StorageType> {
@@ -240,3 +269,19 @@ function extension(name: string): string {
const dot = name.lastIndexOf('.')
return dot >= 0 ? name.slice(dot) : ''
}
async function notifyArchiveJobFinished(db: Database, job: BackgroundJob): Promise<void> {
const completed = job.status === 'completed'
const action = job.type === 'archive_extract' ? 'extraction' : 'compression'
await createNotification(db, {
userId: job.userId,
type: completed ? 'archive_job_completed' : 'archive_job_failed',
title: completed ? `File ${action} completed` : `File ${action} failed`,
body: completed
? `Background task ${job.id} is complete.`
: (job.errorMessage ?? `Background task ${job.id} failed.`),
refType: 'background_job',
refId: job.id,
metadata: JSON.stringify({ jobId: job.id, jobType: job.type, status: job.status }),
})
}
+21 -82
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Storage } from '../../shared/types'
import { S3Service } from './s3.js'
@@ -116,6 +116,12 @@ describe('S3Service', () => {
const service = new S3Service()
const storage = makeStorage()
beforeEach(() => {
mockSend.mockReset()
vi.clearAllMocks()
vi.unstubAllGlobals()
})
describe('createClient', () => {
it('creates a client with correct config', () => {
const client = service.createClient(storage)
@@ -231,104 +237,37 @@ describe('S3Service', () => {
})
describe('getObjectBytes', () => {
it('returns bytes from Uint8Array bodies', async () => {
it('returns bytes from fetched object bodies', async () => {
const bytes = new Uint8Array([1, 2, 3])
mockSend.mockResolvedValueOnce({ Body: bytes })
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(bytes)))
await expect(service.getObjectBytes(storage, 'test.bin')).resolves.toEqual(bytes)
expect(fetch).toHaveBeenCalledWith('https://signed-url.example.com', undefined)
})
it('returns bytes from ReadableStream bodies', async () => {
it('sends Range when reading partial object bytes', async () => {
const bytes = new Uint8Array([4, 5, 6])
const stream = new ReadableStream({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(bytes, { status: 206 })))
await expect(service.getObjectBytes(storage, 'test.bin', 'bytes=0-2')).resolves.toEqual(bytes)
expect(fetch).toHaveBeenCalledWith('https://signed-url.example.com', {
headers: { Range: 'bytes=0-2' },
})
mockSend.mockResolvedValueOnce({ Body: stream })
await expect(service.getObjectBytes(storage, 'test.bin')).resolves.toEqual(bytes)
})
it('returns bytes from transformToByteArray bodies', async () => {
const bytes = new Uint8Array([1, 2, 3])
mockSend.mockResolvedValueOnce({
Body: { transformToByteArray: async () => bytes },
})
it('rejects failed object reads', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(null, { status: 404 })))
await expect(service.getObjectBytes(storage, 'test.bin')).resolves.toEqual(bytes)
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'test.bin' } }),
)
})
it('returns bytes from arrayBuffer bodies', async () => {
const bytes = new Uint8Array([7, 8, 9])
mockSend.mockResolvedValueOnce({
Body: { arrayBuffer: async () => bytes.buffer },
})
await expect(service.getObjectBytes(storage, 'test.bin')).resolves.toEqual(bytes)
})
it('rejects empty object bodies', async () => {
mockSend.mockResolvedValueOnce({})
await expect(service.getObjectBytes(storage, 'missing.bin')).rejects.toThrow('Empty body from object')
})
it('rejects unsupported object bodies', async () => {
mockSend.mockResolvedValueOnce({ Body: {} })
await expect(service.getObjectBytes(storage, 'test.bin')).rejects.toThrow('Unsupported object body')
await expect(service.getObjectBytes(storage, 'missing.bin')).rejects.toThrow('S3 object read failed: 404')
})
})
describe('getObjectBody', () => {
it('returns ReadableStream bodies without buffering', async () => {
it('returns fetched ReadableStream bodies without buffering', async () => {
const stream = new ReadableStream()
mockSend.mockResolvedValueOnce({ Body: stream })
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(stream)))
await expect(service.getObjectBody(storage, 'test.bin')).resolves.toBe(stream)
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'test.bin' } }),
)
})
it('returns Uint8Array bodies as a response body', async () => {
const bytes = new Uint8Array([1, 2, 3])
mockSend.mockResolvedValueOnce({ Body: bytes })
const body = await service.getObjectBody(storage, 'test.bin')
await expect(new Response(body).arrayBuffer()).resolves.toEqual(bytes.buffer)
})
it('uses transformToWebStream bodies without converting to bytes', async () => {
const stream = new ReadableStream()
const body = {
transformToByteArray: vi.fn(),
transformToWebStream: vi.fn(() => stream),
}
mockSend.mockResolvedValueOnce({ Body: body })
await expect(service.getObjectBody(storage, 'test.bin', 'bytes=0-4')).resolves.toBe(stream)
expect(body.transformToByteArray).not.toHaveBeenCalled()
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'test.bin', Range: 'bytes=0-4' } }),
)
})
it('rejects empty object bodies', async () => {
mockSend.mockResolvedValueOnce({})
await expect(service.getObjectBody(storage, 'missing.bin')).rejects.toThrow('Empty body from object')
})
it('rejects unsupported object bodies', async () => {
mockSend.mockResolvedValueOnce({ Body: {} })
await expect(service.getObjectBody(storage, 'test.bin')).rejects.toThrow('Unsupported object body')
})
})
+30 -24
View File
@@ -89,19 +89,23 @@ export class S3Service {
}
async getObjectBytes(storage: Storage, key: string, range?: string): Promise<Uint8Array> {
const client = this.createClient(storage)
const input = range ? { Bucket: storage.bucket, Key: key, Range: range } : { Bucket: storage.bucket, Key: key }
const result = await client.send(new GetObjectCommand(input))
if (!result.Body) throw new Error('Empty body from object')
return bodyToBytes(result.Body)
return bodyToBytes(await this.getObjectBody(storage, key, range))
}
async getObjectBody(storage: Storage, key: string, range?: string): Promise<BodyInit> {
const client = this.createClient(storage)
const input = range ? { Bucket: storage.bucket, Key: key, Range: range } : { Bucket: storage.bucket, Key: key }
const result = await client.send(new GetObjectCommand(input))
if (!result.Body) throw new Error('Empty body from object')
return bodyToResponseBody(result.Body)
const url = await getSignedUrl(client, new GetObjectCommand({ Bucket: storage.bucket, Key: key }), {
expiresIn: DEFAULT_EXPIRES_IN,
})
const response = await fetch(url, range ? { headers: { Range: range } } : undefined)
if (!response.ok) throw new Error(`S3 object read failed: ${response.status}`)
if (!response.body) return response.arrayBuffer()
return response.body
}
async getObjectStream(storage: Storage, key: string, range?: string): Promise<ReadableStream<Uint8Array>> {
const body = await this.getObjectBody(storage, key, range)
return bodyToReadableStream(body)
}
async copyObject(srcStorage: Storage, srcKey: string, dstStorage: Storage, dstKey: string): Promise<void> {
@@ -266,8 +270,7 @@ export class S3Service {
ContentLength: body.byteLength,
}),
)
if (!result.ETag) throw new Error('S3 multipart upload part did not return an ETag')
return { ETag: result.ETag, PartNumber: partNumber }
return { ETag: result.ETag ?? `"part-${partNumber}"`, PartNumber: partNumber }
}
async deleteObject(storage: Storage, key: string): Promise<void> {
@@ -284,14 +287,13 @@ export class S3Service {
}
async function bodyToBytes(body: unknown): Promise<Uint8Array> {
if (body instanceof Uint8Array) return body
if (body instanceof ReadableStream) return streamToBytes(body)
const streamBody = body as {
transformToByteArray?: () => Promise<Uint8Array>
arrayBuffer?: () => Promise<ArrayBuffer>
}
if (streamBody.transformToByteArray) return streamBody.transformToByteArray()
if (body instanceof Uint8Array) return body
if (body instanceof ReadableStream) return streamToBytes(body)
if (streamBody.arrayBuffer) return new Uint8Array(await streamBody.arrayBuffer())
throw new Error('Unsupported object body')
@@ -301,19 +303,23 @@ async function streamToBytes(body: ReadableStream): Promise<Uint8Array> {
return new Uint8Array(await new Response(body).arrayBuffer())
}
function bodyToResponseBody(body: unknown): BodyInit {
if (body instanceof Uint8Array)
return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) as ArrayBuffer
if (body instanceof ReadableStream) return body
const streamBody = body as {
transformToWebStream?: () => ReadableStream
}
if (streamBody.transformToWebStream) return streamBody.transformToWebStream()
function bodyToReadableStream(body: BodyInit): ReadableStream<Uint8Array> {
if (body instanceof ReadableStream) return body as ReadableStream<Uint8Array>
if (body instanceof Uint8Array) return bytesToStream(body)
if (body instanceof ArrayBuffer) return bytesToStream(new Uint8Array(body))
if (body instanceof Blob) return body.stream()
throw new Error('Unsupported object body')
}
function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
})
}
function concatBytes(
left: Uint8Array<ArrayBufferLike>,
right: Uint8Array<ArrayBufferLike>,
+76 -4
View File
@@ -1,14 +1,14 @@
import { and, eq, like, or } from 'drizzle-orm'
import { type Zippable, zipSync } from 'fflate'
import { Zip, ZipDeflate, ZipPassThrough, type Zippable, zipSync } from 'fflate'
import { DirType } from '../../shared/constants'
import { matters } from '../db/schema'
import type { Database } from '../platform/interface'
import type { Matter } from './matter'
export const ZIP_COMPRESS_LIMITS = {
totalInputBytes: 50 * 1024 * 1024,
singleFileBytes: 25 * 1024 * 1024,
fileCount: 200,
totalInputBytes: 512 * 1024 * 1024,
singleFileBytes: 512 * 1024 * 1024,
fileCount: 1000,
directoryDepth: 10,
} as const
@@ -26,6 +26,11 @@ export interface ZipSourceObject {
bytes: Uint8Array
}
export interface ZipSourceStream {
archivePath: string
openStream: () => Promise<ReadableStream<Uint8Array>>
}
export interface CompressionPlan {
files: CompressionSourceFile[]
directories: CompressionSourceDirectory[]
@@ -41,6 +46,9 @@ export async function collectCompressionPlan(
opts: { targetFolder?: string; outputName?: string } = {},
): Promise<CompressionPlan> {
const uniqueIds = [...new Set(matterIds)]
if (uniqueIds.length > ZIP_COMPRESS_LIMITS.fileCount) {
throw new Error(`Compression file count exceeds ${ZIP_COMPRESS_LIMITS.fileCount}`)
}
const roots = await db
.select()
.from(matters)
@@ -76,6 +84,70 @@ export function createZipArchive(
return zipSync(zippable, { level: 6 })
}
export function createZipArchiveStream(
sources: ZipSourceStream[],
directories: CompressionSourceDirectory[] = [],
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
const zip = new Zip()
zip.ondata = (error, chunk, final) => {
if (error) {
controller.error(error)
return
}
if (chunk) controller.enqueue(new Uint8Array(chunk))
if (final) controller.close()
}
void streamZipEntries(zip, sources, directories, async () => {}).catch((error) => {
zip.terminate()
controller.error(error)
})
},
})
}
async function streamZipEntries(
zip: Zip,
sources: ZipSourceStream[],
directories: CompressionSourceDirectory[],
waitForWrites: () => Promise<void>,
): Promise<void> {
for (const directory of directories) {
const entry = new ZipPassThrough(`${directory.archivePath}/`)
zip.add(entry)
entry.push(new Uint8Array(), true)
await waitForWrites()
}
for (const source of sources) {
const entry = new ZipDeflate(source.archivePath, { level: 6 })
zip.add(entry)
await pushStreamToZipEntry(await source.openStream(), entry, waitForWrites)
}
zip.end()
}
async function pushStreamToZipEntry(
stream: ReadableStream<Uint8Array>,
entry: ZipDeflate,
waitForWrites: () => Promise<void>,
): Promise<void> {
const reader = stream.getReader()
for (;;) {
const { done, value } = await reader.read()
if (done) {
entry.push(new Uint8Array(), true)
await waitForWrites()
return
}
entry.push(value, false)
await waitForWrites()
}
}
function validateCompressionEntries(files: CompressionSourceFile[], directories: CompressionSourceDirectory[]): void {
let totalBytes = 0
const paths = new Set<string>()
+167 -8
View File
@@ -1,9 +1,9 @@
import { unzipSync } from 'fflate'
import { Unzip, UnzipInflate, unzipSync } from 'fflate'
export const ZIP_EXTRACT_LIMITS = {
totalOutputBytes: 100 * 1024 * 1024,
singleFileBytes: 25 * 1024 * 1024,
fileCount: 200,
totalOutputBytes: 1024 * 1024 * 1024,
singleFileBytes: 1024 * 1024 * 1024,
fileCount: 1000,
directoryDepth: 10,
} as const
@@ -24,12 +24,31 @@ interface CentralDirectoryEntry {
externalAttributes: number
}
export interface ZipDirectoryPlan {
folders: string[]
totalBytes: number
fileCount: number
}
export interface ValidatedZip {
files: ExtractedZipEntry[]
folders: string[]
totalBytes: number
}
export interface StreamingZipFile {
path: string
name: string
parentPath: string
stream: ReadableStream<Uint8Array>
size: Promise<number>
}
export interface StreamingZipExtraction {
folders: string[]
totalBytes: number
}
const textDecoder = new TextDecoder()
export function validateAndExtractZip(data: Uint8Array): ValidatedZip {
@@ -58,6 +77,133 @@ export function validateAndExtractZip(data: Uint8Array): ValidatedZip {
return { files, folders, totalBytes }
}
export async function validateZipDirectory(
size: number,
readRange: (start: number, end: number) => Promise<Uint8Array>,
): Promise<ZipDirectoryPlan> {
const tailLength = Math.min(size, 65557)
const tailOffset = size - tailLength
const tail = await readRange(tailOffset, size - 1)
const eocd = findEndOfCentralDirectory(tail)
const entryCount = uint16(tail, eocd + 10)
const centralDirectorySize = uint32(tail, eocd + 12)
const centralDirectoryOffset = uint32(tail, eocd + 16)
if (entryCount === 0xffff || centralDirectorySize === 0xffffffff || centralDirectoryOffset === 0xffffffff) {
throw new Error('ZIP64 archives are not supported')
}
const centralDirectory = await readRange(centralDirectoryOffset, centralDirectoryOffset + centralDirectorySize - 1)
const entries = readCentralDirectoryEntries(centralDirectory, entryCount)
validateEntries(entries)
return {
folders: collectFolders(entries),
totalBytes: totalEntryBytes(entries),
fileCount: entries.filter((entry) => !isDirectoryEntry(entry)).length,
}
}
export async function streamValidatedZip(
data: ReadableStream<Uint8Array>,
onFile: (file: StreamingZipFile) => Promise<void>,
): Promise<StreamingZipExtraction> {
const folders = new Set<string>()
const tasks: Promise<void>[] = []
let fileCount = 0
let totalBytes = 0
const unzip = new Unzip((file) => {
validatePath(file.name)
if (file.compression !== 0 && file.compression !== 8) throw new Error('ZIP contains unsupported compression method')
const directory = file.name.endsWith('/')
const depth = directoryDepth(file.name, directory)
if (depth > ZIP_EXTRACT_LIMITS.directoryDepth) {
throw new Error(`ZIP directory depth exceeds ${ZIP_EXTRACT_LIMITS.directoryDepth}`)
}
collectPathFolders(file.name, directory, folders)
if (directory) {
file.start()
return
}
fileCount += 1
if (fileCount > ZIP_EXTRACT_LIMITS.fileCount) {
throw new Error(`ZIP file count exceeds ${ZIP_EXTRACT_LIMITS.fileCount}`)
}
if (file.originalSize !== undefined && file.originalSize > ZIP_EXTRACT_LIMITS.singleFileBytes) {
throw new Error(`ZIP entry exceeds ${ZIP_EXTRACT_LIMITS.singleFileBytes} bytes`)
}
const parts = pathParts(file.name)
const stream = new TransformStream<Uint8Array, Uint8Array>()
const writer = stream.writable.getWriter()
let writes = Promise.resolve()
let size = 0
let resolveSize: (value: number) => void
let rejectSize: (error: unknown) => void
const sizePromise = new Promise<number>((resolve, reject) => {
resolveSize = resolve
rejectSize = reject
})
file.ondata = (error, chunk, final) => {
if (error) {
writes = writes.then(() => writer.abort(error))
rejectSize(error)
return
}
if (chunk) {
size += chunk.byteLength
totalBytes += chunk.byteLength
if (size > ZIP_EXTRACT_LIMITS.singleFileBytes) {
const err = new Error(`ZIP entry exceeds ${ZIP_EXTRACT_LIMITS.singleFileBytes} bytes`)
writes = writes.then(() => writer.abort(err))
rejectSize(err)
return
}
if (totalBytes > ZIP_EXTRACT_LIMITS.totalOutputBytes) {
const err = new Error(`ZIP extraction output exceeds ${ZIP_EXTRACT_LIMITS.totalOutputBytes} bytes`)
writes = writes.then(() => writer.abort(err))
rejectSize(err)
return
}
writes = writes.then(() => writer.write(chunk))
}
if (final) {
writes = writes.then(() => writer.close()).then(() => resolveSize(size))
}
}
const zipFile: StreamingZipFile = {
path: file.name,
name: parts[parts.length - 1],
parentPath: parts.slice(0, -1).join('/'),
stream: stream.readable,
size: sizePromise,
}
tasks.push(onFile(zipFile))
file.start()
})
unzip.register(UnzipInflate)
const reader = data.getReader()
for (;;) {
const { done, value } = await reader.read()
if (done) break
unzip.push(value, false)
}
unzip.push(new Uint8Array(), true)
const results = await Promise.allSettled(tasks)
const failed = results.find((result) => result.status === 'rejected')
if (failed?.status === 'rejected') throw failed.reason
return {
folders: [...folders].sort((a, b) => pathParts(a).length - pathParts(b).length || a.localeCompare(b)),
totalBytes,
}
}
function validateEntries(entries: CentralDirectoryEntry[]): void {
let fileCount = 0
let totalBytes = 0
@@ -94,7 +240,12 @@ function validateEntries(entries: CentralDirectoryEntry[]): void {
function readCentralDirectory(data: Uint8Array): CentralDirectoryEntry[] {
const eocd = findEndOfCentralDirectory(data)
const entryCount = uint16(data, eocd + 10)
let offset = uint32(data, eocd + 16)
const offset = uint32(data, eocd + 16)
return readCentralDirectoryEntries(data, entryCount, offset)
}
function readCentralDirectoryEntries(data: Uint8Array, entryCount: number, startOffset = 0): CentralDirectoryEntry[] {
let offset = startOffset
const entries: CentralDirectoryEntry[] = []
for (let i = 0; i < entryCount; i += 1) {
@@ -117,6 +268,10 @@ function readCentralDirectory(data: Uint8Array): CentralDirectoryEntry[] {
return entries
}
function totalEntryBytes(entries: CentralDirectoryEntry[]): number {
return entries.reduce((sum, entry) => sum + (isDirectoryEntry(entry) ? 0 : entry.uncompressedSize), 0)
}
function findEndOfCentralDirectory(data: Uint8Array): number {
const minOffset = Math.max(0, data.length - 65557)
for (let offset = data.length - 22; offset >= minOffset; offset -= 1) {
@@ -128,13 +283,17 @@ function findEndOfCentralDirectory(data: Uint8Array): number {
function collectFolders(entries: CentralDirectoryEntry[]): string[] {
const folders = new Set<string>()
for (const entry of entries) {
const parts = pathParts(entry.name)
const max = isDirectoryEntry(entry) ? parts.length : parts.length - 1
for (let i = 1; i <= max; i += 1) folders.add(parts.slice(0, i).join('/'))
collectPathFolders(entry.name, isDirectoryEntry(entry), folders)
}
return [...folders].sort((a, b) => pathParts(a).length - pathParts(b).length || a.localeCompare(b))
}
function collectPathFolders(path: string, directory: boolean, folders: Set<string>): void {
const parts = pathParts(path)
const max = directory ? parts.length : parts.length - 1
for (let i = 1; i <= max; i += 1) folders.add(parts.slice(0, i).join('/'))
}
function validatePath(path: string): void {
if (path.length === 0) throw new Error('ZIP contains an empty path')
if (path.includes('\\')) throw new Error('ZIP paths must use forward slashes')
+4
View File
@@ -224,6 +224,9 @@ export function FileManager({
mutationFn: (
input: { type: 'archive_compress'; matterIds: string[] } | { type: 'archive_extract'; matterId: string },
) => createBackgroundJob(input),
onMutate: () => {
queryClient.setQueryData<number>(['background-jobs', 'active-count'], (count) => (count ?? 0) + 1)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['objects'] })
queryClient.invalidateQueries({ queryKey: ['background-jobs'] })
@@ -235,6 +238,7 @@ export function FileManager({
})
},
onError: (err) => {
queryClient.setQueryData<number>(['background-jobs', 'active-count'], (count) => Math.max(0, (count ?? 1) - 1))
toast.error(err.message)
},
})
+19 -1
View File
@@ -19,6 +19,7 @@ import {
import { useTranslation } from 'react-i18next'
import { useBranding } from '@/components/branding/BrandingProvider'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import {
DropdownMenu,
@@ -40,7 +41,7 @@ import {
SidebarSeparator,
} from '@/components/ui/sidebar'
import { useSiteOptions } from '@/hooks/use-site-options'
import { getIhostConfig } from '@/lib/api'
import { getIhostConfig, listBackgroundJobs } from '@/lib/api'
import { signOut, useActiveOrganization, useSession } from '@/lib/auth-client'
import { OrgSwitcher } from '../team/org-switcher'
import { FolderTree } from './folder-tree'
@@ -69,6 +70,18 @@ export function AppSidebar() {
queryFn: getIhostConfig,
enabled: !!session,
})
const { data: activeTaskCount = 0 } = useQuery({
queryKey: ['background-jobs', 'active-count'],
queryFn: async () => {
const [queued, running] = await Promise.all([
listBackgroundJobs({ status: 'queued', page: 1, pageSize: 1 }),
listBackgroundJobs({ status: 'running', page: 1, pageSize: 1 }),
])
return queued.total + running.total
},
enabled: !!session,
refetchInterval: 5000,
})
const pathname = useRouterState({ select: (s) => s.location.pathname })
const fileType = useRouterState({ select: (s) => (s.location.search as { type?: string })?.type })
const isFiles = pathname === '/files'
@@ -158,6 +171,11 @@ export function AppSidebar() {
<Link to="/tasks">
<ListChecks className="h-4 w-4" />
<span>{t('nav.tasks')}</span>
{activeTaskCount > 0 && (
<Badge className="ml-auto h-5 min-w-5 rounded-full px-1.5 text-[10px]">
{activeTaskCount > 99 ? '99+' : activeTaskCount}
</Badge>
)}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -25,6 +25,7 @@ function TasksPage() {
const jobsQuery = useQuery({
queryKey: [...QUERY_KEY, status],
queryFn: () => listBackgroundJobs({ status, page: 1, pageSize: PAGE_SIZE }),
refetchInterval: filter === 'active' ? 3000 : false,
})
const cancelMutation = useMutation({
+14 -4
View File
@@ -2,6 +2,7 @@ import { createApp } from '../server/app'
import type { Auth } from '../server/auth'
import { createAuth } from '../server/auth'
import { createCloudflarePlatform } from '../server/platform/cloudflare'
import { type ArchiveJobMessage, runArchiveJobMessage } from '../server/services/archive-jobs'
import { resolveShareByToken } from '../server/services/share'
import { DirType } from '../shared/constants'
import { handleScheduled } from './scheduled'
@@ -23,7 +24,7 @@ let cachedAuth: Auth | null = null
const SHARE_TOKEN_RE = /^\/s\/([^/?#]+)/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const { BETTER_AUTH_SECRET } = env
if (!BETTER_AUTH_SECRET) {
throw new Error('BETTER_AUTH_SECRET is not configured for this deployment.')
@@ -43,15 +44,23 @@ export default {
const shareMatch = SHARE_TOKEN_RE.exec(url.pathname)
if (shareMatch && request.method === 'GET') {
return handleShareSsr(request, env, shareMatch[1], platform, cachedAuth)
return handleShareSsr(request, env, ctx, shareMatch[1], platform, cachedAuth)
}
return createApp(platform, cachedAuth).fetch(request)
return createApp(platform, cachedAuth).fetch(request, env, ctx)
},
async scheduled(event: ScheduledEvent, env: Env): Promise<void> {
await handleScheduled(event, env)
},
async queue(batch: MessageBatch<ArchiveJobMessage>, env: Env): Promise<void> {
const platform = createCloudflarePlatform(env)
for (const message of batch.messages) {
await runArchiveJobMessage(platform, message.body)
message.ack()
}
},
}
interface ShareMeta {
@@ -112,6 +121,7 @@ function buildOgTags(meta: ShareMeta, pageUrl: string): string {
async function handleShareSsr(
request: Request,
env: Env,
ctx: ExecutionContext,
token: string,
platform: ReturnType<typeof createCloudflarePlatform>,
auth: Auth,
@@ -125,7 +135,7 @@ async function handleShareSsr(
])
if (!spaRes.ok) {
return createApp(platform, auth).fetch(request)
return createApp(platform, auth).fetch(request, env, ctx)
}
const html = await spaRes.text()
+20
View File
@@ -24,6 +24,16 @@ bucket_name = "zpan-public-images"
[[send_email]]
name = "EMAIL"
[[queues.producers]]
binding = "ARCHIVE_QUEUE"
queue = "zpan-archive-jobs"
[[queues.consumers]]
queue = "zpan-archive-jobs"
max_batch_size = 1
max_batch_timeout = 1
max_retries = 3
[observability]
enabled = true
@@ -47,3 +57,13 @@ migrations_dir = "./migrations"
[[env.staging.r2_buckets]]
binding = "PUBLIC_IMAGES"
bucket_name = "zpan-public-images-staging"
[[env.staging.queues.producers]]
binding = "ARCHIVE_QUEUE"
queue = "zpan-archive-jobs-staging"
[[env.staging.queues.consumers]]
queue = "zpan-archive-jobs-staging"
max_batch_size = 1
max_batch_timeout = 1
max_retries = 3