Adding working board

This commit is contained in:
2026-08-17 09:24:53 +02:00
parent 35685f765a
commit 0237da74cf
18 changed files with 1365 additions and 738 deletions
+44 -197
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
import pg from 'pg'
import jwt from 'jsonwebtoken'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { CaseState } from '../src/types.js'
import type { CaseState, DocumentExhibit, EventExhibit, FolderExhibit, NoteExhibit, PartyExhibit, TimelineView } from '../src/types.js'
import { runMigrations } from './migrations.js'
const { Client } = pg
@@ -36,7 +36,9 @@ async function availablePort() {
})
}
suite('level persistence API', () => {
const placed = (x: number, y: number, width: number, height: number, zIndex = 1) => ({ x, y, width, height, rotation: 0, zIndex, hidden: false })
suite('normalized level persistence API', () => {
beforeAll(async () => {
const adminUrl = new URL(baseDatabaseUrl!)
adminUrl.pathname = '/postgres'
@@ -46,13 +48,13 @@ suite('level persistence API', () => {
const testUrl = new URL(baseDatabaseUrl!)
testUrl.pathname = `/${databaseName}`
const databaseUrl = testUrl.toString()
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
await runMigrations(databaseUrl, migrationsDir, () => undefined)
await runMigrations(databaseUrl, path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'), () => undefined)
const port = await availablePort()
process.env.DATABASE_URL = databaseUrl
process.env.LEVEL_EDITING_ENABLED = 'true'
process.env.JWT_SECRET = 'osint-integration-jwt-secret'
process.env.ASSET_STORAGE_DRIVER = 'memory'
process.env.PORT = String(port)
const serverModule = await import('./index.js')
appServer = serverModule.server
@@ -69,217 +71,62 @@ suite('level persistence API', () => {
await adminClient.end()
})
it('persists one normalized level across authoring and play views', async () => {
it('round-trips exhibits, relations, board views, private objects, and template clones', async () => {
expect(await (await fetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: false, isAdmin: false })
expect(await (await adminFetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: true, isAdmin: true })
expect((await fetch(`${baseUrl}/api/levels`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status).toBe(403)
const createResponse = await adminFetch(`${baseUrl}/api/levels`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
})
expect(createResponse.status).toBe(201)
const state = await createResponse.json() as CaseState
const timeline = state.views.find((view): view is TimelineView => view.type === 'timeline')!
timeline.rangeMode = 'fixed'
timeline.range = { start: '2021-04-01', end: '2021-04-30' }
state.viewport = { x: 91, y: -42, zoom: 0.85 }
state.timelineRange = { start: '2021-04-01', end: '2021-04-30' }
const documentId = randomUUID()
const folderId = randomUUID()
const noteId = randomUUID()
const eventId = randomUUID()
const personConceptId = randomUUID()
const organizationConceptId = randomUUID()
state.brief = { body: 'Identify Ada Lovelace and Analytical Engines Ltd in the source material.', concepts: [
{ id: personConceptId, label: 'Ada Lovelace', context: 'Named as the correspondent.', expectedPartyKind: 'person' },
{ id: organizationConceptId, label: 'Analytical Engines Ltd', context: 'Issued the filing.', expectedPartyKind: 'organization' },
] }
state.documents = [{ id: documentId, title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {} }]
state.evidence = [
{ id: folderId, type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: [documentId] },
{ id: noteId, type: 'note', title: 'Extract', content: 'Date matters', sourceDocumentId: documentId, sourceRegionId: 'stamp', x: 420, y: 300, width: 108 },
{ id: eventId, type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', supportingEvidenceIds: [documentId, noteId], x: 520, y: 610, width: 270 },
const document: DocumentExhibit = { id: randomUUID(), type: 'document', title: 'Evidence', publishedAt: '2021-04-17T12:00:00Z', body: ['Extracted body'], regions: [{ id: 'stamp', label: 'Date stamp', excerpt: '17 April 2021', date: '2021-04-17T09:00:00Z' }], fileType: 'image', metadata: {}, ...placed(1051, 417, 174, 145, 2) }
const folder: FolderExhibit = { id: randomUUID(), type: 'folder', title: 'Folder', content: 'Evidence folder', isOpen: true, ...placed(685, 417, 260, 166) }
const note: NoteExhibit = { id: randomUUID(), type: 'note', title: 'Extract', content: 'Date matters', ...placed(420, 300, 108, 154) }
const event: EventExhibit = { id: randomUUID(), type: 'event', title: 'The meeting occurred', content: 'The evidence places the meeting on 18 April.', eventDate: '2021-04-18T14:30:00Z', ...placed(520, 610, 270, 174) }
const party: PartyExhibit = { id: randomUUID(), type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as correspondent.', aliases: ['A. A. L.'], ...placed(720, 250, 280, 190) }
state.exhibits = [document, folder, note, event, party]
state.relations = [
{ id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, type: 'contains', sortOrder: 0 },
{ id: randomUUID(), fromExhibitId: note.id, toExhibitId: document.id, type: 'source', sourceRegionId: 'stamp', sortOrder: 0 },
{ id: randomUUID(), fromExhibitId: event.id, toExhibitId: document.id, type: 'supports', sortOrder: 0 },
{ id: randomUUID(), fromExhibitId: event.id, toExhibitId: note.id, type: 'supports', sortOrder: 1 },
{ id: randomUUID(), fromExhibitId: party.id, toExhibitId: document.id, type: 'concerns', sortOrder: 0 },
]
state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }]
state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }]
const saveResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(state),
})
expect(await saveResponse.json()).toEqual({ ok: true, mode: 'author' })
state.connections = [{ id: randomUUID(), fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }]
state.brief = { body: 'Identify Ada Lovelace.', concepts: [{ id: randomUUID(), label: 'Ada Lovelace', context: 'Named in evidence.', expectedPartyKind: 'person', resolvedPartyExhibitId: party.id }] }
const save = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(state) })
expect(await save.json()).toEqual({ ok: true, mode: 'author' })
const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(loaded.viewport).toEqual(state.viewport)
expect(loaded.timelineRange).toEqual(state.timelineRange)
expect(loaded.evidence[0]).toMatchObject({ id: folderId, x: 685, y: 417, config: { open: true } })
expect(loaded.relations[0]).toMatchObject({ id: `contains:${folderId}:${documentId}`, config: { x: 1051, y: 417 } })
expect(loaded.connections).toContainEqual(expect.objectContaining({ fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }))
expect(loaded.brief.concepts).toEqual(expect.arrayContaining([
expect.objectContaining({ label: 'Ada Lovelace', expectedPartyKind: 'person' }),
expect.objectContaining({ label: 'Analytical Engines Ltd', expectedPartyKind: 'organization' }),
]))
const legacyClientState = structuredClone(loaded)
delete legacyClientState.timelineRange
const legacySave = await fetch(`${baseUrl}/api/levels/${state.id}`, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(legacyClientState),
})
expect(legacySave.ok).toBe(true)
expect((await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState).timelineRange).toEqual(state.timelineRange)
expect(loaded.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
expect(loaded.exhibits.find(item => item.id === folder.id)).toMatchObject({ x: 685, y: 417, isOpen: true })
expect(loaded.relations).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'supports', fromExhibitId: event.id, toExhibitId: note.id })]))
expect(loaded.connections[0]).toMatchObject({ fromExhibitId: folder.id, toExhibitId: document.id, label: 'Primary source' })
const upload = new FormData()
upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt')
const uploadResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload })
expect(uploadResponse.status).toBe(201)
const uploaded = await uploadResponse.json() as CaseState['documents'][number]
expect(uploaded).toMatchObject({ title: 'smoke-evidence.txt', fileName: 'smoke-evidence.txt', mimeType: 'text/plain', fileType: 'text' })
expect(uploaded.assetId).toBeTruthy()
const uploaded = await uploadResponse.json() as DocumentExhibit
expect(uploaded).toMatchObject({ type: 'document', fileName: 'smoke-evidence.txt', fileType: 'text' })
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence')
const assetRow = await appPool.query<{ storage_provider: string; content: Buffer | null; object_key: string | null }>('SELECT storage_provider,content,object_key FROM osint.assets WHERE id=$1', [uploaded.assetId])
expect(assetRow.rows[0]).toMatchObject({ storage_provider: 's3', content: null, object_key: expect.stringMatching(/^assets\//) })
const withUpload = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
const uploadedDocument = withUpload.documents.find(document => document.id === uploaded.id)!
uploadedDocument.title = 'Renamed smoke evidence'
uploadedDocument.publishedAt = '2022-06-15T10:30:00.000Z'
uploadedDocument.metadata = { witness: 'Integration test', confidence: 'high' }
const metadataSave = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(withUpload),
})
expect(metadataSave.ok).toBe(true)
const afterMetadataSave = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(afterMetadataSave.documents.find(document => document.id === uploaded.id)).toMatchObject({
title: 'Renamed smoke evidence',
publishedAt: '2022-06-15T10:30:00.000Z',
metadata: { witness: 'Integration test', confidence: 'high' },
})
const undatedState = structuredClone(afterMetadataSave)
const undatedEvent = undatedState.evidence.find(exhibit => exhibit.id === eventId)!
delete undatedEvent.eventDate
const undatedSave = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(undatedState),
})
expect(undatedSave.ok).toBe(true)
const loadedUndated = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(loadedUndated.evidence.find(exhibit => exhibit.id === eventId)?.eventDate).toBeUndefined()
expect((await appPool.query<{ occurred_at: Date | null }>('SELECT occurred_at FROM osint.event_exhibits WHERE exhibit_id=$1', [eventId])).rows[0].occurred_at).toBeNull()
loadedUndated.evidence.find(exhibit => exhibit.id === eventId)!.eventDate = '2021-04-18T14:30:00Z'
expect((await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(loadedUndated),
})).ok).toBe(true)
const playerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
expect(playerState.brief.concepts.every(concept => concept.expectedPartyKind === undefined)).toBe(true)
const personPartyId = randomUUID()
const organizationPartyId = randomUUID()
playerState.evidence.push(
{ id: personPartyId, type: 'party', partyKind: 'person', title: 'Ada Lovelace', content: 'Named as the correspondent.', aliases: ['A. A. L.'], relatedEvidenceIds: [documentId, noteId], x: 720, y: 250, width: 280 },
{ id: organizationPartyId, type: 'party', partyKind: 'organization', organizationKind: 'business', title: 'Analytical Engines Ltd', content: 'Issued the filing.', aliases: ['AEL'], relatedEvidenceIds: [documentId], x: 1020, y: 250, width: 280 },
)
playerState.brief.concepts = playerState.brief.concepts.map(concept => ({ ...concept,
resolvedPartyExhibitId: concept.id === personConceptId ? personPartyId : organizationPartyId }))
playerState.viewport = { x: -150, y: 88, zoom: 1.1 }
playerState.evidence[0] = { ...playerState.evidence[0], x: 812, y: 533 }
const playerSave = await fetch(`${baseUrl}/api/levels/${state.id}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(playerState),
})
expect(await playerSave.json()).toEqual({ ok: true, mode: 'play' })
const savedPlayerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
expect(savedPlayerState.viewport).toEqual(playerState.viewport)
expect(savedPlayerState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
const sameLevelInEditView = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(sameLevelInEditView.viewport).toEqual(playerState.viewport)
expect(sameLevelInEditView.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
const normalized = await appPool.query<{ exhibits: string; documents: string; folders: string; memberships: string; metadata: string; sources: string; connections: string; events: string; event_evidence: string; parties: string; people: string; organizations: string; party_evidence: string; concepts: string; hidden_answers: string }>(`SELECT
(SELECT COUNT(*) FROM osint.exhibits)::text AS exhibits,
(SELECT COUNT(*) FROM osint.document_exhibits)::text AS documents,
(SELECT COUNT(*) FROM osint.folder_exhibits)::text AS folders,
(SELECT COUNT(*) FROM osint.folder_memberships)::text AS memberships,
(SELECT COUNT(*) FROM osint.exhibit_metadata_text_values)::text AS metadata,
(SELECT COUNT(*) FROM osint.exhibit_sources)::text AS sources,
(SELECT COUNT(*) FROM osint.exhibit_connections)::text AS connections,
(SELECT COUNT(*) FROM osint.event_exhibits)::text AS events,
(SELECT COUNT(*) FROM osint.event_evidence)::text AS event_evidence,
(SELECT COUNT(*) FROM osint.party_exhibits)::text AS parties,
(SELECT COUNT(*) FROM osint.person_parties)::text AS people,
(SELECT COUNT(*) FROM osint.organization_parties)::text AS organizations,
(SELECT COUNT(*) FROM osint.party_evidence)::text AS party_evidence,
(SELECT COUNT(*) FROM osint.brief_concepts)::text AS concepts,
(SELECT COUNT(*) FROM osint.brief_concepts WHERE expected_party_kind IS NOT NULL)::text AS hidden_answers`)
expect(normalized.rows[0]).toEqual({ exhibits: '7', documents: '2', folders: '1', memberships: '1', metadata: '2', sources: '1', connections: '1', events: '1', event_evidence: '2', parties: '2', people: '1', organizations: '1', party_evidence: '3', concepts: '2', hidden_answers: '2' })
const resetResponse = await fetch(`${baseUrl}/api/levels/${state.id}/reset`, { method: 'POST' })
expect(resetResponse.ok).toBe(true)
const resetState = await resetResponse.json() as CaseState
expect(resetState.viewport).toEqual(playerState.viewport)
expect(resetState.timelineRange).toEqual(state.timelineRange)
expect(resetState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }),
})
const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }) })
expect(templateResponse.status).toBe(201)
expect(await templateResponse.json()).toMatchObject({ slug: 'smoke-template', currentVersion: 1, versionCount: 1 })
expect(await (await fetch(`${baseUrl}/api/templates`)).json()).toEqual([
expect.objectContaining({ slug: 'smoke-template', currentVersion: 1, versionCount: 1 }),
])
const changedSource = structuredClone(savedPlayerState)
changedSource.title = 'Changed after template freeze'
changedSource.evidence[0].x = 999
await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(changedSource),
})
const cloneResponse = await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'smoke-template-copy', title: 'Playable copy' }),
})
const cloneResponse = await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'smoke-template-copy', title: 'Playable copy' }) })
expect(cloneResponse.status).toBe(201)
const clone = await cloneResponse.json() as CaseState
expect(clone).toMatchObject({ id: 'smoke-template-copy', title: 'Playable copy', sourceTemplateVersionId: expect.any(String) })
expect(clone.timelineRange).toEqual(state.timelineRange)
expect(clone.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 })
expect(clone.documents.find(item => item.title === 'Renamed smoke evidence')?.assetId).toBe(uploaded.assetId)
expect(clone.documents[0].id).not.toBe(savedPlayerState.documents[0].id)
expect(clone.evidence.map(item => item.id)).not.toContain(folderId)
expect(clone.connections).toHaveLength(1)
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12, toEvidenceId: clone.documents[0].id })
expect(clone.evidence.find(item => item.type === 'note')).toMatchObject({ sourceRegionId: 'stamp' })
const clonedEvent = clone.evidence.find(item => item.type === 'event')!
expect(clonedEvent).toMatchObject({ title: 'The meeting occurred', eventDate: '2021-04-18T14:30:00.000Z' })
expect(clonedEvent.supportingEvidenceIds).toHaveLength(2)
expect(clonedEvent.supportingEvidenceIds).not.toContain(documentId)
expect(clonedEvent.supportingEvidenceIds).not.toContain(noteId)
expect(clone.evidence.filter(item => item.type === 'party')).toHaveLength(2)
expect(clone.brief.concepts.every(concept => Boolean(concept.resolvedPartyExhibitId))).toBe(true)
expect(clone.brief.concepts.map(concept => concept.resolvedPartyExhibitId)).not.toContain(personPartyId)
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
expect(authoredClone.brief.concepts.map(concept => concept.expectedPartyKind).sort()).toEqual(['organization', 'person'])
const clonedFolder = clone.evidence.find(item => item.type === 'folder')!
clonedFolder.x = 1234
clone.viewport = { x: 333, y: 222, zoom: 1.2 }
await fetch(`${baseUrl}/api/levels/${clone.id}`, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(clone),
})
const cloneReset = await (await fetch(`${baseUrl}/api/levels/${clone.id}/reset`, { method: 'POST' })).json() as CaseState
expect(cloneReset).toMatchObject({ title: 'API Smoke Level', viewport: { x: 0, y: 28, zoom: 0.7 } })
expect(cloneReset.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 })
expect(cloneReset.evidence.map(item => item.id)).not.toContain(clonedFolder.id)
const versionTwoResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }),
})
expect(await versionTwoResponse.json()).toMatchObject({ currentVersion: 2, versionCount: 2 })
const oldVersion = await (await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'old-version-copy', version: 1 }),
})).json() as CaseState
const currentVersion = await (await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'current-version-copy' }),
})).json() as CaseState
expect(oldVersion.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812 })
expect(currentVersion.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 999 })
expect(clone.views[0]).toMatchObject({ type: 'timeline', rangeMode: 'fixed', range: timeline.range })
expect(clone.exhibits.map(item => item.id)).not.toContain(folder.id)
expect(clone.exhibits.find(item => item.type === 'folder')).toMatchObject({ x: 685, y: 417 })
expect(clone.relations.filter(relation => relation.type === 'supports')).toHaveLength(2)
expect(clone.connections[0]).toMatchObject({ label: 'Primary source', tightness: 85 })
expect(clone.brief.concepts[0].resolvedPartyExhibitId).not.toBe(party.id)
})
})
+17 -6
View File
@@ -10,7 +10,7 @@ function mapped(ids: IdMap, sourceId: string, label: string) {
}
export async function clearBoard(client: PoolClient, boardId: string) {
await client.query('DELETE FROM osint.board_timeline_settings WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [boardId])
await client.query('DELETE FROM osint.metadata_fields WHERE board_id=$1', [boardId])
@@ -24,11 +24,22 @@ export async function cloneBoard(client: PoolClient, sourceBoardId: string, targ
const regionIds: IdMap = new Map()
const fieldIds: IdMap = new Map()
const timeline = await client.query<{ range_start: string; range_end: string }>(
'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [sourceBoardId])
if (timeline.rows[0]) await client.query(
'INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)',
[targetBoardId, timeline.rows[0].range_start, timeline.rows[0].range_end])
const views = await client.query<{
id: string; view_type_id: string; placement_mode: string; dock_edge: string | null; xpos: number | null; ypos: number | null
width: number | null; height: number; z_index: number; visible: boolean; range_mode: string | null; range_start: string | null; range_end: string | null
}>(`SELECT v.id,v.view_type_id,v.placement_mode,v.dock_edge,v.xpos,v.ypos,v.width,v.height,v.z_index,v.visible,
t.range_mode,t.range_start::text,t.range_end::text FROM osint.board_views v
LEFT JOIN osint.timeline_views t ON t.view_id=v.id WHERE v.board_id=$1 ORDER BY v.z_index,v.created_at`, [sourceBoardId])
for (const row of views.rows) {
const viewId = randomUUID()
await client.query(`INSERT INTO osint.board_views
(id,board_id,view_type_id,origin_view_id,placement_mode,dock_edge,xpos,ypos,width,height,z_index,visible)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, [viewId,targetBoardId,row.view_type_id,row.id,row.placement_mode,row.dock_edge,
row.xpos,row.ypos,row.width,row.height,row.z_index,row.visible])
if (row.view_type_id === 'timeline') await client.query(
'INSERT INTO osint.timeline_views (view_id,range_mode,range_start,range_end) VALUES ($1,$2,$3,$4)',
[viewId,row.range_mode || 'auto',row.range_start,row.range_end])
}
const exhibits = await client.query<{
id: string; exhibit_type_id: string; xpos: number; ypos: number; width: number; height: number
+7 -8
View File
@@ -30,6 +30,7 @@ process.env.LEVEL_EDITING_ENABLED = 'true'
process.env.JWT_SECRET = 'osint-e2e-jwt-secret'
process.env.PORT = String(port)
process.env.OSINT_MANAGED_SERVER = 'true'
process.env.ASSET_STORAGE_DRIVER = 'memory'
const { server, pool } = await import('./index.js')
if (!server.listening) await once(server, 'listening')
const baseUrl = `http://127.0.0.1:${port}`
@@ -49,17 +50,15 @@ state.brief = { body: 'Classify the named people and organizations in this inves
{ id: '44444444-4444-4444-8444-444444444444', label: 'Ada Lovelace', context: 'Named as the correspondent.', expectedPartyKind: 'person' },
{ id: '55555555-5555-4555-8555-555555555555', label: 'Difference Engine Bureau', context: 'Issued the archive notice.', expectedPartyKind: 'organization' },
] }
state.documents = [{
id: documentId, title: 'Dated source image', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00.000Z',
body: [], regions: [], fileType: 'image', metadata: {},
}]
state.evidence = [{
state.exhibits = [{
id: documentId, type: 'document', title: 'Dated source image', publishedAt: '2021-04-17T12:00:00.000Z',
body: [], regions: [], fileType: 'image', metadata: {}, x: 980, y: 360, width: 174, height: 145, rotation: 0, zIndex: 2, hidden: false,
}, {
id: folderId, type: 'folder', title: 'BROWSER TEST FOLDER', content: 'Disposable evidence',
x: 600, y: 360, width: 260, config: { open: false }, containedDocumentIds: [documentId],
x: 600, y: 360, width: 260, height: 166, rotation: 0, zIndex: 1, hidden: false, isOpen: false,
}]
state.relations = [{
id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0,
config: { x: 980, y: 360 },
id: `contains:${folderId}:${documentId}`, fromExhibitId: folderId, toExhibitId: documentId, type: 'contains', sortOrder: 0,
}]
state.connections = []
state.viewport = { x: 0, y: 28, zoom: 0.7 }
+12 -8
View File
@@ -10,6 +10,7 @@ import pg from 'pg'
import type { CaseState } from '../src/types.js'
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin } from './auth.js'
import { createLevelRepository } from './levelRepository.js'
import { createObjectStorageFromEnv } from './objectStorage.js'
const { Pool } = pg
const databaseUrl = process.env.DATABASE_URL
@@ -20,7 +21,9 @@ if (!databaseUrl) {
export const pool = new Pool({ connectionString: databaseUrl })
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
const levels = createLevelRepository(pool, editingEnabled)
const objectStorage = createObjectStorageFromEnv()
await objectStorage.initialize()
const levels = createLevelRepository(pool, editingEnabled, objectStorage)
function wantsEdit(req: express.Request) {
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
@@ -41,7 +44,7 @@ const upload = multer({
})
app.get('/api/health', async (_req, res) => {
try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', schema: 'osint', editingEnabled }) }
try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', objectStorage: objectStorage.provider, schema: 'osint', editingEnabled }) }
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
})
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req) }))
@@ -89,12 +92,13 @@ app.get('/api/assets/:id', async (req, res, next) => {
try {
const asset = await levels.getAsset(req.params.id)
if (!asset) return res.status(404).json({ error: 'Asset not found' })
const inline = asset.mime_type === 'application/pdf' || asset.mime_type.startsWith('image/') || asset.mime_type.startsWith('text/')
res.setHeader('Content-Type', asset.mime_type || 'application/octet-stream')
res.setHeader('Content-Length', asset.byte_size)
res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.original_name)}`)
const inline = asset.mimeType === 'application/pdf' || asset.mimeType.startsWith('image/') || asset.mimeType.startsWith('text/')
res.setHeader('Content-Type', asset.mimeType || 'application/octet-stream')
res.setHeader('Content-Length', asset.byteSize)
res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.originalName)}`)
res.setHeader('X-Content-Type-Options', 'nosniff')
res.send(asset.content)
asset.stream.on('error', next)
asset.stream.pipe(res)
} catch (error) { next(error) }
})
app.post('/api/levels/:id/documents', requireAdmin, upload.single('file'), async (req, res, next) => {
@@ -113,7 +117,7 @@ app.get('/api/levels/:id', async (req, res, next) => {
})
app.put('/api/levels/:id', async (req, res, next) => {
const state = req.body as CaseState
if (!state || state.id !== req.params.id || !Array.isArray(state.evidence) || !Array.isArray(state.connections)) return res.status(400).json({ error: 'Invalid level state' })
if (!state || state.id !== req.params.id || !Array.isArray(state.exhibits) || !Array.isArray(state.views) || !Array.isArray(state.connections)) return res.status(400).json({ error: 'Invalid level state' })
try {
const authorMode = wantsEdit(req)
await levels.saveLevel(state, authorMode)
+147 -133
View File
@@ -1,10 +1,14 @@
import { createHash, randomUUID } from 'node:crypto'
import { Readable } from 'node:stream'
import type { Pool, PoolClient } from 'pg'
import type { BriefConcept, CaseDocument, CaseState, Evidence, OrganizationKind, PartyKind, SourceFileType, WidgetRelation } from '../src/types.js'
import type { BoardView, BriefConcept, CaseDocument, CaseState, Evidence, Exhibit, ExhibitRelation, OrganizationKind, PartyKind, SourceFileType } from '../src/types.js'
import { isDocumentExhibit, isEventExhibit, isFolderExhibit, isPartyExhibit } from '../src/types.js'
import { clearBoard, cloneBoard } from './boardClone.js'
import type { ObjectStorage } from './objectStorage.js'
export type UploadedDocument = { buffer: Buffer; originalname: string; mimetype: string; size: number }
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer }
export type AssetRecord = { original_name: string; mime_type: string; byte_size: string; content: Buffer | null; storage_provider: 'postgres' | 's3'; object_key: string | null }
export type AssetResponse = { originalName: string; mimeType: string; byteSize: number; stream: NodeJS.ReadableStream }
export type TemplateSummary = { id: string; slug: string; name: string; currentVersion: number; versionCount: number; updatedAt: string }
export interface LevelRepository {
@@ -16,17 +20,17 @@ export interface LevelRepository {
getLevel(levelId: string, authorMode?: boolean): Promise<CaseState | null>
saveLevel(state: CaseState, authorMode: boolean): Promise<void>
resetLevel(levelId: string): Promise<CaseState | null>
getAsset(assetId: string): Promise<AssetRecord | null>
getAsset(assetId: string): Promise<AssetResponse | null>
uploadDocument(levelId: string, file: UploadedDocument): Promise<CaseDocument | null>
}
type LevelRow = {
id: string; slug: string; board_id: string; title: string; subtitle: string; status: string
viewport_x: number; viewport_y: number; viewport_zoom: number; updated_at: Date
viewport_x: number; viewport_y: number; viewport_zoom: number; updated_at: Date; revision: string
source_template_version_id: string | null
}
type ExhibitRow = {
id: string; exhibit_type_id: 'folder' | 'document' | 'note' | 'event' | 'party'; xpos: number; ypos: number; width: number; hidden: boolean
id: string; exhibit_type_id: Exhibit['type']; xpos: number; ypos: number; width: number; height: number; rotation: number; z_index: number; hidden: boolean
title: string; content: string; is_open: boolean | null; document_type_id: SourceFileType | null
asset_id: string | null; published_at: Date | null; occurred_at: Date | null
original_name: string | null; mime_type: string | null; byte_size: string | null
@@ -48,24 +52,27 @@ function documentType(document: CaseDocument): SourceFileType {
const allowed: SourceFileType[] = ['image', 'pdf', 'web_capture', 'email', 'article', 'filing', 'price_list', 'text', 'file']
return allowed.includes(document.fileType) ? document.fileType : 'file'
}
function documentKind(type: SourceFileType) {
return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase()
}
export function createLevelRepository(pool: Pool, editingEnabled: boolean): LevelRepository {
export function createLevelRepository(pool: Pool, editingEnabled: boolean, objectStorage: ObjectStorage): LevelRepository {
async function findLevel(client: Pool | PoolClient, slug: string, lock = false) {
const result = await client.query<LevelRow>(`SELECT id, slug, board_id, title, subtitle, status,
viewport_x, viewport_y, viewport_zoom, updated_at, source_template_version_id
FROM osint.levels WHERE slug = $1${lock ? ' FOR UPDATE' : ''}`, [slug])
const result = await client.query<LevelRow>(`SELECT l.id,l.slug,l.board_id,l.title,l.subtitle,l.status,
l.viewport_x,l.viewport_y,l.viewport_zoom,l.updated_at,l.source_template_version_id,b.revision::text
FROM osint.levels l JOIN osint.boards b ON b.id=l.board_id WHERE l.slug = $1${lock ? ' FOR UPDATE OF l,b' : ''}`, [slug])
return result.rows[0] || null
}
async function createDefaultBoardViews(client: PoolClient, boardId: string) {
const viewId = randomUUID()
await client.query(`INSERT INTO osint.board_views (id,board_id,view_type_id,placement_mode,dock_edge,height)
VALUES ($1,$2,'timeline','docked','bottom',112)`, [viewId, boardId])
await client.query("INSERT INTO osint.timeline_views (view_id,range_mode) VALUES ($1,'auto')", [viewId])
}
async function assembleLevel(slug: string, authorMode = false): Promise<CaseState | null> {
const level = await findLevel(pool, slug)
if (!level) return null
const [exhibitsResult, blocksResult, regionsResult, membershipsResult, connectionsResult, metadataResult, eventEvidenceResult,
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, timelineResult] = await Promise.all([
pool.query<ExhibitRow>(`SELECT e.id, e.exhibit_type_id, e.xpos, e.ypos, e.width, e.hidden,
aliasesResult, partyEvidenceResult, briefResult, conceptsResult, viewsResult] = await Promise.all([
pool.query<ExhibitRow>(`SELECT e.id,e.exhibit_type_id,e.xpos,e.ypos,e.width,e.height,e.rotation,e.z_index,e.hidden,
COALESCE(f.title, d.title, n.title, ev.title, p.display_name, '') AS title,
COALESCE(f.label_text, n.note_text, ev.narrative_text, p.summary, '') AS content,
f.is_open, d.document_type_id, d.asset_id, d.published_at, ev.occurred_at,
@@ -98,21 +105,23 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
pool.query<{ exhibit_id: string; field_key: string; value: string }>(
`SELECT v.exhibit_id, f.field_key, v.value FROM osint.exhibit_metadata_text_values v
JOIN osint.metadata_fields f ON f.id = v.field_id WHERE f.board_id = $1 ORDER BY f.field_key`, [level.board_id]),
pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string }>(
`SELECT event_exhibit_id,evidence_exhibit_id FROM osint.event_evidence WHERE board_id=$1
pool.query<{ event_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
`SELECT event_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.event_evidence WHERE board_id=$1
ORDER BY event_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
pool.query<{ party_exhibit_id: string; alias: string }>(
`SELECT a.party_exhibit_id,a.alias FROM osint.party_aliases a JOIN osint.exhibits e ON e.id=a.party_exhibit_id
WHERE e.board_id=$1 ORDER BY a.party_exhibit_id,a.sort_order,a.id`, [level.board_id]),
pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string }>(
`SELECT party_exhibit_id,evidence_exhibit_id FROM osint.party_evidence WHERE board_id=$1
pool.query<{ party_exhibit_id: string; evidence_exhibit_id: string; sort_order: number; note: string | null }>(
`SELECT party_exhibit_id,evidence_exhibit_id,sort_order,note FROM osint.party_evidence WHERE board_id=$1
ORDER BY party_exhibit_id,sort_order,evidence_exhibit_id`, [level.board_id]),
pool.query<{ body: string }>('SELECT body FROM osint.level_briefs WHERE board_id=$1', [level.board_id]),
pool.query<{ id: string; label: string; context_text: string; expected_party_kind: PartyKind | null; resolved_party_exhibit_id: string | null }>(
`SELECT id,label,context_text,expected_party_kind,resolved_party_exhibit_id FROM osint.brief_concepts
WHERE board_id=$1 ORDER BY sort_order,id`, [level.board_id]),
pool.query<{ range_start: string; range_end: string }>(
'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id]),
pool.query<{ id: string; view_type_id: 'timeline'; placement_mode: 'docked' | 'canvas' | 'window'; dock_edge: 'top' | 'right' | 'bottom' | 'left' | null; xpos: number | null; ypos: number | null; width: number | null; height: number; z_index: number; visible: boolean; range_mode: 'auto' | 'fixed'; range_start: string | null; range_end: string | null }>(
`SELECT v.id,v.view_type_id,v.placement_mode,v.dock_edge,v.xpos,v.ypos,v.width,v.height,v.z_index,v.visible,
t.range_mode,t.range_start::text,t.range_end::text FROM osint.board_views v
JOIN osint.timeline_views t ON t.view_id=v.id WHERE v.board_id=$1 ORDER BY v.z_index,v.created_at`, [level.board_id]),
])
const blocks = new Map<string, string[]>()
@@ -123,43 +132,50 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
}])
const metadata = new Map<string, Record<string, string>>()
for (const row of metadataResult.rows) metadata.set(row.exhibit_id, { ...(metadata.get(row.exhibit_id) || {}), [row.field_key]: row.value })
const contained = new Map<string, string[]>()
const eventEvidence = new Map<string, string[]>()
for (const row of eventEvidenceResult.rows) eventEvidence.set(row.event_exhibit_id, [...(eventEvidence.get(row.event_exhibit_id) || []), row.evidence_exhibit_id])
const aliases = new Map<string, string[]>()
for (const row of aliasesResult.rows) aliases.set(row.party_exhibit_id, [...(aliases.get(row.party_exhibit_id) || []), row.alias])
const partyEvidence = new Map<string, string[]>()
for (const row of partyEvidenceResult.rows) partyEvidence.set(row.party_exhibit_id, [...(partyEvidence.get(row.party_exhibit_id) || []), row.evidence_exhibit_id])
const relations: WidgetRelation[] = membershipsResult.rows.map(row => {
contained.set(row.folder_exhibit_id, [...(contained.get(row.folder_exhibit_id) || []), row.child_exhibit_id])
return { id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromWidgetId: row.folder_exhibit_id,
toWidgetId: row.child_exhibit_id, type: 'contains', sortOrder: row.sort_order, config: { x: row.xpos, y: row.ypos } }
})
const relations: ExhibitRelation[] = [
...membershipsResult.rows.map(row => ({ id: `contains:${row.folder_exhibit_id}:${row.child_exhibit_id}`, fromExhibitId: row.folder_exhibit_id,
toExhibitId: row.child_exhibit_id, type: 'contains' as const, sortOrder: row.sort_order })),
...eventEvidenceResult.rows.map(row => ({ id: `supports:${row.event_exhibit_id}:${row.evidence_exhibit_id}`, fromExhibitId: row.event_exhibit_id,
toExhibitId: row.evidence_exhibit_id, type: 'supports' as const, sortOrder: row.sort_order, note: row.note || undefined })),
...partyEvidenceResult.rows.map(row => ({ id: `concerns:${row.party_exhibit_id}:${row.evidence_exhibit_id}`, fromExhibitId: row.party_exhibit_id,
toExhibitId: row.evidence_exhibit_id, type: 'concerns' as const, sortOrder: row.sort_order, note: row.note || undefined })),
...exhibitsResult.rows.flatMap(row => row.source_document_id ? [{ id: `source:${row.id}`, fromExhibitId: row.id, toExhibitId: row.source_document_id,
type: 'source' as const, sourceRegionId: row.source_region_key || undefined, sortOrder: 0 }] : []),
]
const base = (row: ExhibitRow) => ({ id: row.id, title: row.title, x: row.xpos, y: row.ypos, width: row.width, height: row.height,
rotation: row.rotation, zIndex: row.z_index, hidden: row.hidden })
const documents: CaseDocument[] = exhibitsResult.rows.filter(row => row.exhibit_type_id === 'document').map(row => {
const type = row.document_type_id || 'file'
const publishedAt = row.published_at?.toISOString()
return { id: row.id, title: row.title, kind: documentKind(type), date: publishedAt?.slice(0, 10) || '', publishedAt,
return { ...base(row), type: 'document', publishedAt,
body: blocks.get(row.id) || [], regions: regions.get(row.id) || [], assetId: row.asset_id || undefined,
fileName: row.original_name || undefined, mimeType: row.mime_type || undefined,
fileSize: row.byte_size === null ? undefined : Number(row.byte_size), fileType: type, metadata: metadata.get(row.id) || {} }
})
const evidence: Evidence[] = exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden).map(row => ({
id: row.id, type: row.exhibit_type_id as Evidence['type'], title: row.title, content: row.content,
sourceDocumentId: row.source_document_id || undefined, sourceRegionId: row.source_region_key || undefined,
eventDate: row.occurred_at?.toISOString(), x: row.xpos, y: row.ypos, width: row.width,
supportingEvidenceIds: eventEvidence.get(row.id) || [],
partyKind: row.party_kind || undefined, organizationKind: row.organization_kind || undefined,
aliases: aliases.get(row.id) || [], relatedEvidenceIds: partyEvidence.get(row.id) || [],
config: row.exhibit_type_id === 'folder' ? { open: Boolean(row.is_open) } : {}, containedDocumentIds: contained.get(row.id) || [],
}))
const evidence: Evidence[] = []
for (const row of exhibitsResult.rows.filter(row => row.exhibit_type_id !== 'document' && !row.hidden)) {
const common = { ...base(row), title: row.title, content: row.content }
if (row.exhibit_type_id === 'folder') evidence.push({ ...common, type:'folder', isOpen:Boolean(row.is_open) })
else if (row.exhibit_type_id === 'event') evidence.push({ ...common, type:'event', eventDate:row.occurred_at?.toISOString() })
else if (row.exhibit_type_id === 'party') evidence.push({ ...common, type:'party', partyKind:row.party_kind || 'person', organizationKind:row.organization_kind || undefined, aliases:aliases.get(row.id) || [] })
else if (row.exhibit_type_id === 'note') evidence.push({ ...common, type:'note' })
else throw new Error(`Unsupported exhibit type ${row.exhibit_type_id}`)
}
const views: BoardView[] = viewsResult.rows.map(row => ({ id: row.id, type: 'timeline', visible: row.visible, zIndex: row.z_index,
placement: row.placement_mode === 'docked'
? { mode: 'docked', dockEdge: row.dock_edge || 'bottom', size: row.height }
: { mode: row.placement_mode, x: row.xpos || 0, y: row.ypos || 0, width: row.width || 900, height: row.height },
rangeMode: row.range_mode, range: row.range_start && row.range_end ? { start: row.range_start, end: row.range_end } : undefined }))
const concepts: BriefConcept[] = conceptsResult.rows.map(row => ({ id: row.id, label: row.label, context: row.context_text,
...(authorMode && row.expected_party_kind ? { expectedPartyKind: row.expected_party_kind } : {}), resolvedPartyExhibitId: row.resolved_party_exhibit_id || undefined }))
return { id: level.slug, title: level.title, subtitle: level.subtitle, documents, evidence, relations,
connections: connectionsResult.rows.map(row => ({ id: row.id, fromEvidenceId: row.from_exhibit_id, toEvidenceId: row.to_exhibit_id,
return { id: level.slug, title: level.title, subtitle: level.subtitle, exhibits: [...documents, ...evidence], relations,
connections: connectionsResult.rows.map(row => ({ id: row.id, fromExhibitId: row.from_exhibit_id, toExhibitId: row.to_exhibit_id,
label: row.label || undefined, tightness: row.tightness, tagStyle: row.tag_style,
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
timelineRange: timelineResult.rows[0] ? { start: timelineResult.rows[0].range_start, end: timelineResult.rows[0].range_end } : undefined,
views, revision: Number(level.revision),
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
sourceTemplateVersionId: level.source_template_version_id || undefined }
}
@@ -178,27 +194,15 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
}
async function replaceBoard(client: PoolClient, level: LevelRow, state: CaseState) {
const documentIds = new Set(state.documents.map(document => requireUuid(document.id, 'Document id')))
const evidenceIds = new Set(state.evidence.map(exhibit => requireUuid(exhibit.id, 'Exhibit id')))
const allIds = [...documentIds, ...evidenceIds]
if (new Set(allIds).size !== allIds.length) throw new Error('An id cannot identify both a document and another exhibit')
const relationList = state.relations || state.evidence.flatMap(exhibit => (exhibit.containedDocumentIds || []).map((documentId, index) => ({
id: `contains:${exhibit.id}:${documentId}`, fromWidgetId: exhibit.id, toWidgetId: documentId, type: 'contains', sortOrder: index,
})))
const positions = new Map<string, { x: number; y: number }>()
for (const relation of relationList.filter(item => item.type === 'contains')) {
positions.set(relation.toWidgetId, { x: Number(relation.config?.x ?? 100), y: Number(relation.config?.y ?? 100) })
}
const existing = await client.query<{ id: string; xpos: number; ypos: number }>('SELECT id, xpos, ypos FROM osint.exhibits WHERE board_id = $1', [level.board_id])
for (const row of existing.rows) if (!positions.has(row.id)) positions.set(row.id, { x: row.xpos, y: row.ypos })
if (!Array.isArray(state.exhibits) || !Array.isArray(state.views)) throw new Error('Level state must contain exhibits and views')
const documents = state.exhibits.filter(isDocumentExhibit)
const evidence = state.exhibits.filter((exhibit): exhibit is Evidence => !isDocumentExhibit(exhibit))
const documentIds = new Set(documents.map(document => requireUuid(document.id, 'Document id')))
const evidenceIds = new Set(evidence.map(exhibit => requireUuid(exhibit.id, 'Exhibit id')))
const allIds = state.exhibits.map(exhibit => exhibit.id)
if (new Set(allIds).size !== allIds.length) throw new Error('Exhibit ids must be unique within a board')
const expectedConceptKinds = new Map((await client.query<{ id: string; expected_party_kind: PartyKind | null }>(
'SELECT id,expected_party_kind FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])).rows.map(row => [row.id, row.expected_party_kind]))
const existingTimelineResult = await client.query<{ range_start: string; range_end: string }>(
'SELECT range_start::text,range_end::text FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id])
const existingTimeline = existingTimelineResult.rows[0]
? { start: existingTimelineResult.rows[0].range_start, end: existingTimelineResult.rows[0].range_end }
: null
await client.query(`UPDATE osint.levels SET title=$2, subtitle=$3, viewport_x=$4, viewport_y=$5, viewport_zoom=$6,
updated_at=NOW() WHERE id=$1`, [level.id, state.title, state.subtitle, state.viewport.x, state.viewport.y, state.viewport.zoom])
@@ -208,7 +212,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
await client.query('DELETE FROM osint.event_evidence WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.party_evidence WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.party_relationships WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.board_timeline_settings WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.board_views WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.brief_concepts WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.level_briefs WHERE board_id=$1', [level.board_id])
await client.query('DELETE FROM osint.exhibit_sources WHERE exhibit_id IN (SELECT id FROM osint.exhibits WHERE board_id=$1)', [level.board_id])
@@ -219,14 +223,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
if (allIds.length) await client.query('DELETE FROM osint.exhibits WHERE board_id=$1 AND NOT (id = ANY($2::uuid[]))', [level.board_id, allIds])
else await client.query('DELETE FROM osint.exhibits WHERE board_id=$1', [level.board_id])
for (const [index, document] of state.documents.entries()) {
const position = positions.get(document.id) || { x: 100, y: 100 }
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
VALUES ($1,$2,'document',$3,$4,174,145,$5,FALSE)
ON CONFLICT (id) DO UPDATE SET exhibit_type_id='document',xpos=$3,ypos=$4,width=174,height=145,z_index=$5,hidden=FALSE,updated_at=NOW()`,
[document.id, level.board_id, position.x, position.y, index])
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at)
VALUES ($1,$2,$3,$4,$5)`, [document.id, documentType(document), document.assetId || null, document.title, timestamp(document.publishedAt || document.date)])
for (const exhibit of state.exhibits) {
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,rotation,z_index,hidden)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=$7,rotation=$8,z_index=$9,hidden=$10,updated_at=NOW()`,
[exhibit.id, level.board_id, exhibit.type, exhibit.x, exhibit.y, exhibit.width, exhibit.height, exhibit.rotation, exhibit.zIndex, exhibit.hidden])
}
for (const document of documents) {
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title,published_at,captured_at,source_uri)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [document.id, documentType(document), document.assetId || null, document.title,
timestamp(document.publishedAt), timestamp(document.capturedAt), document.sourceUri || null])
if (document.fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [document.id])
for (const [sortOrder, content] of document.body.entries()) await client.query(
'INSERT INTO osint.document_content_blocks (id,document_exhibit_id,sort_order,content) VALUES ($1,$2,$3,$4)', [randomUUID(), document.id, sortOrder, content])
@@ -234,22 +240,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
`INSERT INTO osint.document_regions (id,document_exhibit_id,region_key,label,excerpt,occurred_at,sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, [randomUUID(), document.id, region.id, region.label, region.excerpt, timestamp(region.date), sortOrder])
}
for (const [index, exhibit] of state.evidence.entries()) {
const type = exhibit.type === 'evidence' ? 'folder' : exhibit.type
const canonicalType = type === 'folder' || type === 'note' || type === 'event' || type === 'party' ? type : 'note'
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
VALUES ($1,$2,$3,$4,$5,$6,160,$7,FALSE)
ON CONFLICT (id) DO UPDATE SET exhibit_type_id=$3,xpos=$4,ypos=$5,width=$6,height=160,z_index=$7,hidden=FALSE,updated_at=NOW()`,
[exhibit.id, level.board_id, canonicalType, exhibit.x, exhibit.y, exhibit.width, state.documents.length + index])
if (canonicalType === 'folder') await client.query(
for (const exhibit of evidence) {
if (isFolderExhibit(exhibit)) await client.query(
'INSERT INTO osint.folder_exhibits (exhibit_id,title,label_text,is_open) VALUES ($1,$2,$3,$4)',
[exhibit.id, exhibit.title, exhibit.content, Boolean(exhibit.config?.open)])
if (canonicalType === 'note') await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [exhibit.id, exhibit.title, exhibit.content])
if (canonicalType === 'event') await client.query(
[exhibit.id, exhibit.title, exhibit.content, exhibit.isOpen])
if (exhibit.type === 'note') await client.query('INSERT INTO osint.note_exhibits (exhibit_id,title,note_text) VALUES ($1,$2,$3)', [exhibit.id, exhibit.title, exhibit.content])
if (isEventExhibit(exhibit)) await client.query(
'INSERT INTO osint.event_exhibits (exhibit_id,title,narrative_text,occurred_at) VALUES ($1,$2,$3,$4)',
[exhibit.id, exhibit.title, exhibit.content, timestamp(exhibit.eventDate)])
if (canonicalType === 'party') {
const partyKind: PartyKind = exhibit.partyKind === 'organization' ? 'organization' : 'person'
if (isPartyExhibit(exhibit)) {
const partyKind = exhibit.partyKind
await client.query('INSERT INTO osint.party_exhibits (exhibit_id,party_kind,display_name,summary) VALUES ($1,$2,$3,$4)',
[exhibit.id, partyKind, exhibit.title, exhibit.content])
if (partyKind === 'person') await client.query('INSERT INTO osint.person_parties (exhibit_id) VALUES ($1)', [exhibit.id])
@@ -260,51 +260,57 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
}
}
for (const relation of relationList.filter(item => item.type === 'contains')) {
if (!evidenceIds.has(relation.fromWidgetId) || !allIds.includes(relation.toWidgetId)) throw new Error('Folder membership references an unknown exhibit')
await client.query(`INSERT INTO osint.folder_memberships (board_id,folder_exhibit_id,child_exhibit_id,sort_order)
VALUES ($1,$2,$3,$4)`, [level.board_id, relation.fromWidgetId, relation.toWidgetId, relation.sortOrder || 0])
}
for (const event of state.evidence.filter(item => item.type === 'event')) {
for (const [sortOrder, evidenceId] of (event.supportingEvidenceIds || []).entries()) {
if (evidenceId === event.id || !allIds.includes(evidenceId)) throw new Error('Event evidence references an unknown or identical exhibit')
await client.query(`INSERT INTO osint.event_evidence
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
[level.board_id, event.id, evidenceId, sortOrder])
}
}
for (const party of state.evidence.filter(item => item.type === 'party')) {
for (const [sortOrder, evidenceId] of (party.relatedEvidenceIds || []).entries()) {
if (evidenceId === party.id || !allIds.includes(evidenceId)) throw new Error('Party evidence references an unknown or identical exhibit')
await client.query(`INSERT INTO osint.party_evidence
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
[level.board_id, party.id, evidenceId, sortOrder])
for (const relation of state.relations) {
if (!allIds.includes(relation.fromExhibitId) || !allIds.includes(relation.toExhibitId) || relation.fromExhibitId === relation.toExhibitId) throw new Error('Relation references an unknown or identical exhibit')
if (relation.type === 'contains') await client.query(`INSERT INTO osint.folder_memberships
(board_id,folder_exhibit_id,child_exhibit_id,sort_order) VALUES ($1,$2,$3,$4)`,
[level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder])
if (relation.type === 'supports') await client.query(`INSERT INTO osint.event_evidence
(board_id,event_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
[level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder, relation.note || null])
if (relation.type === 'concerns') await client.query(`INSERT INTO osint.party_evidence
(board_id,party_exhibit_id,evidence_exhibit_id,sort_order,note) VALUES ($1,$2,$3,$4,$5)`,
[level.board_id, relation.fromExhibitId, relation.toExhibitId, relation.sortOrder, relation.note || null])
if (relation.type === 'source') {
if (!documentIds.has(relation.toExhibitId)) throw new Error('Exhibit source must reference a document')
let regionId: string | null = null
if (relation.sourceRegionId) {
const region = await client.query<{ id: string }>(
'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [relation.toExhibitId, relation.sourceRegionId])
regionId = region.rows[0]?.id || null
}
await client.query('INSERT INTO osint.exhibit_sources (exhibit_id,source_document_exhibit_id,source_region_id) VALUES ($1,$2,$3)',
[relation.fromExhibitId, relation.toExhibitId, regionId])
}
}
for (const connection of state.connections) {
requireUuid(connection.id, 'Connection id')
if (!allIds.includes(connection.fromEvidenceId) || !allIds.includes(connection.toEvidenceId) || connection.fromEvidenceId === connection.toEvidenceId) throw new Error('Connection references an unknown or identical exhibit')
if (!allIds.includes(connection.fromExhibitId) || !allIds.includes(connection.toExhibitId) || connection.fromExhibitId === connection.toExhibitId) throw new Error('Connection references an unknown or identical exhibit')
const tightness = Math.max(0, Math.min(100, Math.round(Number(connection.tightness ?? 65))))
const tagStyle = connection.tagStyle === 'compact' ? 'compact' : 'luggage'
const tagPosition = Math.max(0, Math.min(100, Math.round(Number(connection.tagPosition ?? 50))))
const lateralLimit = Math.round(10 + (100 - tightness) * .6)
const tagOffset = Math.max(-lateralLimit, Math.min(lateralLimit, Math.round(Number(connection.tagOffset ?? 0))))
await client.query(`INSERT INTO osint.exhibit_connections (id,board_id,connection_type_id,from_exhibit_id,to_exhibit_id,label,tightness,tag_style,tag_position_percent,tag_lateral_offset)
VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)`, [connection.id, level.board_id, connection.fromEvidenceId, connection.toEvidenceId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset])
VALUES ($1,$2,'thread',$3,$4,$5,$6,$7,$8,$9)`, [connection.id, level.board_id, connection.fromExhibitId, connection.toExhibitId, connection.label?.trim() || null, tightness, tagStyle, tagPosition, tagOffset])
}
for (const exhibit of state.evidence.filter(item => item.sourceDocumentId)) {
if (!documentIds.has(exhibit.sourceDocumentId!)) throw new Error('Exhibit source references an unknown document')
let regionId: string | null = null
if (exhibit.sourceRegionId) {
const region = await client.query<{ id: string }>(
'SELECT id FROM osint.document_regions WHERE document_exhibit_id=$1 AND region_key=$2', [exhibit.sourceDocumentId, exhibit.sourceRegionId])
regionId = region.rows[0]?.id || null
for (const view of state.views) {
requireUuid(view.id, 'Board view id')
const placement = view.placement
const docked = placement.mode === 'docked'
await client.query(`INSERT INTO osint.board_views
(id,board_id,view_type_id,placement_mode,dock_edge,xpos,ypos,width,height,z_index,visible)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, [view.id, level.board_id, view.type, placement.mode,
placement.mode === 'docked' ? placement.dockEdge : null, placement.mode === 'docked' ? null : placement.x, placement.mode === 'docked' ? null : placement.y,
placement.mode === 'docked' ? null : placement.width, placement.mode === 'docked' ? placement.size : placement.height, view.zIndex, view.visible])
if (view.type === 'timeline') {
if (view.rangeMode === 'fixed' && (!view.range || !timestamp(view.range.start) || !timestamp(view.range.end) || Date.parse(view.range.end) <= Date.parse(view.range.start))) throw new Error('Timeline end must be after timeline start')
await client.query(`INSERT INTO osint.timeline_views (view_id,range_mode,range_start,range_end) VALUES ($1,$2,$3,$4)`,
[view.id, view.rangeMode, view.rangeMode === 'fixed' ? view.range!.start : null, view.rangeMode === 'fixed' ? view.range!.end : null])
}
await client.query('INSERT INTO osint.exhibit_sources (exhibit_id,source_document_exhibit_id,source_region_id) VALUES ($1,$2,$3)',
[exhibit.id, exhibit.sourceDocumentId, regionId])
}
const fields = new Map<string, string>()
for (const document of state.documents) for (const key of Object.keys(document.metadata || {})) {
for (const document of documents) for (const key of Object.keys(document.metadata || {})) {
if (!fields.has(key)) {
const fieldId = randomUUID(); fields.set(key, fieldId)
await client.query(`INSERT INTO osint.metadata_fields (id,board_id,field_key,label,value_type) VALUES ($1,$2,$3,$3,'text')`, [fieldId, level.board_id, key])
@@ -313,14 +319,6 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
[document.id, fields.get(key), document.metadata[key]])
}
const brief = state.brief || { body: '', concepts: [] }
const savedTimeline = state.timelineRange === undefined ? existingTimeline : state.timelineRange
if (savedTimeline) {
const start = timestamp(savedTimeline.start)
const end = timestamp(savedTimeline.end)
if (!start || !end || Date.parse(end) <= Date.parse(start)) throw new Error('Timeline end must be after timeline start')
await client.query('INSERT INTO osint.board_timeline_settings (board_id,range_start,range_end) VALUES ($1,$2,$3)',
[level.board_id, savedTimeline.start, savedTimeline.end])
}
await client.query('INSERT INTO osint.level_briefs (board_id,body) VALUES ($1,$2)', [level.board_id, brief.body || ''])
for (const [sortOrder, concept] of brief.concepts.entries()) {
requireUuid(concept.id, 'Brief concept id')
@@ -346,6 +344,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
await client.query(`INSERT INTO osint.boards (id,board_kind) VALUES ($1,'level')`, [boardId])
await client.query(`INSERT INTO osint.levels (id,slug,board_id,title,subtitle) VALUES ($1,$2,$3,$4,$5)`,
[levelId, input.id, boardId, input.title, input.subtitle])
await createDefaultBoardViews(client, boardId)
await client.query('COMMIT')
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
return (await assembleLevel(input.id))!
@@ -444,8 +443,16 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
},
async getAsset(assetId) {
if (!uuidPattern.test(assetId)) return null
const result = await pool.query<AssetRecord>('SELECT original_name,mime_type,byte_size,content FROM osint.assets WHERE id=$1', [assetId])
return result.rows[0] || null
const result = await pool.query<AssetRecord>('SELECT original_name,mime_type,byte_size,content,storage_provider,object_key FROM osint.assets WHERE id=$1', [assetId])
const asset = result.rows[0]
if (!asset) return null
if (asset.storage_provider === 'postgres') {
if (!asset.content) throw new Error(`PostgreSQL asset ${assetId} has no content`)
return { originalName: asset.original_name, mimeType: asset.mime_type, byteSize: Number(asset.byte_size), stream: Readable.from(asset.content) }
}
if (!asset.object_key) throw new Error(`Object asset ${assetId} has no object key`)
const object = await objectStorage.getObject(asset.object_key)
return object ? { originalName: asset.original_name, mimeType: asset.mime_type, byteSize: Number(asset.byte_size), stream: object.stream } : null
},
async uploadDocument(levelId, file) {
const client = await pool.connect()
@@ -455,21 +462,28 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
if (!level) { await client.query('ROLLBACK'); return null }
const candidateAssetId = randomUUID(); const exhibitId = randomUUID()
const checksum = createHash('sha256').update(file.buffer).digest('hex')
const asset = await client.query<{ id: string }>(`INSERT INTO osint.assets
(id,original_name,mime_type,byte_size,content,checksum_sha256) VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`,
[candidateAssetId, file.originalname, file.mimetype || 'application/octet-stream', file.size, file.buffer, checksum])
let assetId = (await client.query<{ id: string }>('SELECT id FROM osint.assets WHERE checksum_sha256=$1 AND byte_size=$2 FOR SHARE', [checksum,file.size])).rows[0]?.id
if (!assetId) {
const objectKey = `assets/${checksum.slice(0,2)}/${checksum}`
const stored = await objectStorage.putObject(objectKey,file.buffer,file.mimetype || 'application/octet-stream')
const asset = await client.query<{ id: string }>(`INSERT INTO osint.assets
(id,original_name,mime_type,byte_size,content,checksum_sha256,storage_provider,storage_bucket,object_key,etag)
VALUES ($1,$2,$3,$4,NULL,$5,'s3',$6,$7,$8)
ON CONFLICT (checksum_sha256,byte_size) DO UPDATE SET checksum_sha256=EXCLUDED.checksum_sha256 RETURNING id`,
[candidateAssetId,file.originalname,file.mimetype || 'application/octet-stream',file.size,checksum,objectStorage.bucket,objectKey,stored.etag || null])
assetId = asset.rows[0].id
}
const fileType: SourceFileType = file.mimetype.startsWith('image/') ? 'image' : file.mimetype === 'application/pdf' ? 'pdf' : file.mimetype.startsWith('text/') ? 'text' : 'file'
await client.query(`INSERT INTO osint.exhibits (id,board_id,exhibit_type_id,xpos,ypos,width,height,z_index,hidden)
VALUES ($1,$2,'document',100,100,174,145,(SELECT COUNT(*) FROM osint.exhibits WHERE board_id=$2),FALSE)`, [exhibitId, level.board_id])
await client.query(`INSERT INTO osint.document_exhibits (exhibit_id,document_type_id,asset_id,title) VALUES ($1,$2,$3,$4)`,
[exhibitId, fileType, asset.rows[0].id, file.originalname])
[exhibitId, fileType, assetId, file.originalname])
if (fileType === 'image') await client.query('INSERT INTO osint.image_documents (exhibit_id) VALUES ($1)', [exhibitId])
await client.query('UPDATE osint.levels SET updated_at=NOW() WHERE id=$1', [level.id])
await client.query('UPDATE osint.boards SET revision=revision+1,updated_at=NOW() WHERE id=$1', [level.board_id])
await client.query('COMMIT')
return { id: exhibitId, title: file.originalname, kind: documentKind(fileType), fileType, metadata: {}, date: '', body: [], regions: [],
assetId: asset.rows[0].id, fileName: file.originalname, mimeType: file.mimetype, fileSize: file.size }
return { id: exhibitId,type:'document',title:file.originalname,x:100,y:100,width:174,height:145,rotation:0,zIndex:0,hidden:false,
fileType,metadata:{},body:[],regions:[],assetId,fileName:file.originalname,mimeType:file.mimetype,fileSize:file.size }
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
},
}
+88
View File
@@ -0,0 +1,88 @@
import { CreateBucketCommand, GetObjectCommand, HeadBucketCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { Readable } from 'node:stream'
export type ObjectBody = { stream: Readable; contentLength?: number }
export interface ObjectStorage {
readonly bucket: string
readonly provider: 's3' | 'memory'
initialize(): Promise<void>
putObject(key: string, body: Buffer, contentType: string): Promise<{ etag?: string }>
getObject(key: string): Promise<ObjectBody | null>
}
export class MemoryObjectStorage implements ObjectStorage {
readonly bucket: string
readonly provider = 'memory' as const
private readonly objects = new Map<string, Buffer>()
constructor(bucket = 'osint-test-assets') { this.bucket = bucket }
async initialize() { /* Nothing to initialize. */ }
async putObject(key: string, body: Buffer) { this.objects.set(key, Buffer.from(body)); return {} }
async getObject(key: string) {
const body = this.objects.get(key)
return body ? { stream: Readable.from(body), contentLength: body.byteLength } : null
}
}
export class S3ObjectStorage implements ObjectStorage {
readonly provider = 's3' as const
readonly bucket: string
private readonly client: S3Client
constructor(options: { endpoint: string; region: string; accessKey: string; secretKey: string; bucket: string; forcePathStyle: boolean }) {
this.bucket = options.bucket
this.client = new S3Client({
endpoint: options.endpoint,
region: options.region,
forcePathStyle: options.forcePathStyle,
credentials: { accessKeyId: options.accessKey, secretAccessKey: options.secretKey },
})
}
async initialize() {
try {
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }))
} catch (error) {
const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode
if (status !== 404) throw error
try { await this.client.send(new CreateBucketCommand({ Bucket: this.bucket })) }
catch (createError) {
const createStatus = (createError as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode
if (createStatus !== 409) throw createError
}
}
}
async putObject(key: string, body: Buffer, contentType: string) {
const result = await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: body, ContentType: contentType }))
return { etag: result.ETag?.replaceAll('"', '') }
}
async getObject(key: string) {
try {
const result = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }))
if (!result.Body || typeof (result.Body as NodeJS.ReadableStream).pipe !== 'function') throw new Error(`Object ${key} did not return a Node stream`)
return { stream: result.Body as Readable, contentLength: result.ContentLength }
} catch (error) {
const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode
if (status === 404) return null
throw error
}
}
}
export function createObjectStorageFromEnv() {
if (process.env.ASSET_STORAGE_DRIVER === 'memory' || process.env.NODE_ENV === 'test') return new MemoryObjectStorage(process.env.S3_BUCKET)
const accessKey = process.env.S3_ACCESS_KEY
const secretKey = process.env.S3_SECRET_KEY
if (!accessKey || !secretKey) throw new Error('S3_ACCESS_KEY and S3_SECRET_KEY are required for MinIO asset storage')
return new S3ObjectStorage({
endpoint: process.env.S3_ENDPOINT || 'http://127.0.0.1:9000',
region: process.env.S3_REGION || 'us-east-1',
accessKey,
secretKey,
bucket: process.env.S3_BUCKET || 'osint-evidence',
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== 'false',
})
}