161 Commits

Author SHA1 Message Date
dextmorgn
9fcc60ca63 fix(deps): upgrade Python dependencies to patch 27 known vulnerabilities
pip-audit reported 27 vulnerabilities across 12 packages, including
cryptography PYSEC-2026-36 (CVSS 9.8) used by the vault, a starlette
Host-header path bypass, and python-multipart arbitrary file write.

- cryptography 45.0.7 -> 46.0.7 (pin was capped at <46)
- fastapi 0.115 -> 0.136, pulling starlette 0.46 -> 1.2
- python-multipart 0.0.20 -> 0.0.32
- pyjwt, urllib3, aiohttp, lxml, idna, mako and transitive bumps
- dev tools: pytest 8 -> 9 (requires pytest-httpx >=0.36), black 25 -> 26

All four module test suites pass (458 tests). pip-audit now reports
no known vulnerabilities.
2026-06-05 08:57:19 +02:00
dextmorgn
259019f23c fix(build): force LF on container files via .gitattributes
Windows checkouts with core.autocrlf=true convert entrypoint.sh to
CRLF, which Docker copies into the image. The shebang then reads
#!/bin/sh\r and exec fails with 'no such file or directory'.
Also covers .env.example (trailing \r in env values) and Makefile.
2026-06-04 18:23:53 +02:00
dextmorgn
7c082d733e docs(readme): make Windows install commands cmd-compatible
PowerShell foreach/Test-Path loop fails when pasted into cmd.exe.
Plain copy commands work in both shells.
2026-06-04 18:05:19 +02:00
dextmorgn
de93ae3059 docs(readme): add Windows install path without Make
Windows users hit failures with make-based install. Replicate
check-env + build + up targets as plain PowerShell/docker compose
commands for prod and dev.
2026-06-04 18:00:35 +02:00
dextmorgn
c6eaf08b66 Merge pull request #163 from Mubashirrrr/fix/json-import-apostrophe-corruption
fix(import): preserve apostrophes in valid JSON imports
2026-06-03 20:34:42 +02:00
dextmorgn
f380674955 Merge pull request #165 from reconurge/fix/sse-auth-header
fix(sse): auth header
2026-06-03 13:42:17 +02:00
Mubashir Rahim
1fdbdbd094 fix(import): preserve apostrophes in valid JSON imports
parse_json unconditionally replaced every single quote with a double
quote (`.replace("'", '"')`) before calling json.loads. This corrupted
any valid JSON string value containing an apostrophe, e.g. a person's
surname "Sarah O'Brien" became "Sarah O"Brien", causing the parse to
fail with "Invalid JSON" and the whole import to be rejected.

Parse strict JSON first so well-formed payloads (including apostrophes
in string values) import correctly, and only fall back to the lenient
single-quote replacement for Python-dict-style payloads that are not
valid JSON on their own.

Adds regression tests covering an apostrophe-bearing JSON value and the
retained single-quoted fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:34:22 +05:00
dextmorgn
b317e08e57 feat(api): authenticate SSE via header, drop query-param auth and dead route 2026-06-03 11:28:41 +02:00
dextmorgn
9db1e675d6 test(api): SSE auth-gate and endpoint-removal tests (red) 2026-06-03 11:22:58 +02:00
dextmorgn
4a75d6a924 refactor(app): stream graph status via connectSSE (token out of URL) 2026-06-03 11:08:11 +02:00
dextmorgn
d7f21e9384 refactor(app): stream logs via connectSSE (token out of URL) 2026-06-03 10:56:32 +02:00
dextmorgn
0b60c81361 Merge pull request #155 from Ghraven/fix-investigation-aware-timestamp
fix: use aware timestamp for investigation updates
2026-06-03 09:30:35 +02:00
dextmorgn
6e5f26aaf0 feat(app): add connectSSE helper with header auth and backoff 2026-06-03 09:28:49 +02:00
Ghraven
60cfa16b8d fix: use aware timestamp for investigation updates 2026-06-03 08:37:45 +08:00
dextmorgn
078ac77eee build(app): add fetch-event-source + vitest test runner 2026-06-02 22:45:36 +02:00
dextmorgn
ed8bfc5248 Merge pull request #149 from Ghraven/fix/timezone-aware-service-timestamps
Use timezone-aware UTC timestamps in core services
2026-06-02 21:11:45 +02:00
dextmorgn
438bbe18c4 Merge pull request #150 from Ghraven/fix-social-enricher-output-encoding
fix: read social enricher outputs as utf-8
2026-06-02 21:10:12 +02:00
Ghraven
4e14df6c61 fix: read social enricher outputs as utf-8 2026-06-01 00:32:48 +08:00
dextmorgn
249374e6d7 chore: update .gitignore 2026-05-31 14:53:34 +02:00
dextmorgn
a15ee54af0 chore: update skill name 2026-05-31 14:52:37 +02:00
Ghraven
b707961e68 fix: use timezone-aware service timestamps 2026-05-31 02:35:11 +08:00
dextmorgn
cc829704e5 Merge pull request #148 from reconurge/fix/phase1-critical
fix: security issues
2026-05-30 19:47:55 +02:00
dextmorgn
e8ca7b39bf fix(types): apply extra=allow via Pydantic v2 model_config
FlowsintType declared an inner `class ConfigDict: extra = "allow"`, which
Pydantic v2 treats as an ordinary nested class and ignores entirely. The
base therefore fell back to the default extra="ignore", silently dropping
user-supplied extra properties on every entity type. Replaced with
model_config = ConfigDict(extra="allow"). Extra keys are now retained.

flowsint-types (44), flowsint-enrichers (12) and flowsint-core (390) suites pass.
2026-05-30 19:03:30 +02:00
dextmorgn
1d409d7929 fix(scan): persist results to existing details column, errors to error column
The Scan model exposes details (JSON) and error (Text) columns, but the
enricher/flow tasks assigned to scan.results — a non-mapped attribute.
SQLAlchemy accepted the assignment silently, so commit() never persisted
it and the data was lost. Success paths now write scan.details; failure
paths write scan.error. ScanCreate.results renamed to details to match.

Scan repository tests pass.
2026-05-30 19:02:14 +02:00
dextmorgn
ee06d6cf3e fix(api): restrict CORS origins via ALLOWED_ORIGINS env
allow_origins was ["*"] together with allow_credentials=True, which is
both insecure and rejected by browsers for credentialed requests. Read a
comma-separated ALLOWED_ORIGINS env var instead, defaulting to the Vite
dev origin http://localhost:5173. Documented in .env.example.
2026-05-30 18:49:44 +02:00
dextmorgn
dd00bae9ce fix(api): run production container as non-root flowsint user
The production stage created the flowsint user and chowned all app
files to it, but the USER directive was commented out, so the API
ran as root. Uncommented it. Verified: image builds, container reports
whoami=flowsint and /health returns 200.
2026-05-30 18:49:43 +02:00
dextmorgn
c9d594a8e7 fix(api): load AUTH_SECRET from validated core.auth source
deps.py read AUTH_SECRET via os.getenv() before load_dotenv() ran,
yielding None when the secret was only present in .env. Import the
already-validated AUTH_SECRET from core.auth (loads dotenv first and
raises if unset) instead of re-reading it. Removes the dup + ordering bug.
2026-05-30 18:44:07 +02:00
dextmorgn
de4f4ea87f Update README with new image links
Added additional image links to the README.
2026-05-28 00:00:13 +02:00
dextmorgn
67ba8cfcc6 feat(chore): update SKILL.md 2026-05-21 11:09:28 +02:00
dextmorgn
8fbcf0c025 chore(skills): add flowsint-transform-builder claude skill
Project-scoped Claude Code skill guiding enricher and type creation.
Whitelists .claude/skills/flowsint-transform-builder/ in .gitignore so
the skill ships with the repo while keeping the rest of .claude/ ignored.
2026-05-15 18:55:56 +02:00
dextmorgn
d431ef4fae docs: sync with flowsint-types and flowsint-enrichers (v1.2.8)
- available-enrichers.mdx: add 17 missing enrichers, drop phantom entries
- managing-enrichers.mdx: fix stale Troubleshooting referencing EnricherRegistry.register()
- managing-types.mdx: note 8 registered-but-uncategorized types
- bump all frontmatter to version 1.2.8 / 2026-05-15
2026-05-15 18:55:25 +02:00
dextmorgn
5be480d4f5 Merge pull request #138 from z3rodaycve/feature/collection-enrichers
Release of my collection of Flowsint enrichers
2026-05-10 17:58:55 +02:00
dextmorgn
431d3997f4 Merge pull request #142 from harmsolo13/fix/celery-healthcheck
fix: replace celery container's inherited curl-based healthcheck
2026-04-29 11:35:17 +02:00
harmsolo13
d4dbe8d0fe fix: replace celery container's inherited curl-based healthcheck
The celery container builds from flowsint-api/Dockerfile which carries
a HEALTHCHECK directive that does `curl -f http://localhost:5001/health`.
The API container has an HTTP server on 5001 — celery doesn't, it's a
worker. So the inherited healthcheck always fails and `docker ps` shows
celery as (unhealthy) even when the worker is actively processing jobs.

This is cosmetic noise today but bites in two real ways: (1) restart
policies that key off health won't re-up celery on a real failure
because Docker can't tell good unhealthy from bad unhealthy, (2) any
service that adds `depends_on: celery: condition: service_healthy`
will refuse to start.

Fix: add a service-level healthcheck on celery in both compose files
(prod and dev) that uses celery's own `inspect ping` primitive
against the worker's broker. Compose-level healthcheck overrides the
Dockerfile-level one, so no Dockerfile change needed.

Smoke-tested locally: container goes from (unhealthy) to (healthy)
within ~30s of restart with no other changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:13:26 +09:30
z3rodaycve
f79403d361 Fixed to_ssl enricher to use httpX Docker Tool 2026-04-24 19:49:20 +02:00
z3rodaycve
0d04423b59 Release of DeHashed, httpX, veriphone, scamalytics enrichers 2026-04-21 16:49:12 +02:00
dextmorgn
62e34c6e9b chore: update README.md with discord link 2026-04-19 13:18:55 +02:00
dextmorgn
2a4878c8fc chore: update gh workflow 2026-04-11 15:20:07 +02:00
dextmorgn
6a14484ccd Merge pull request #136 from reconurge/feat/rbac
Feat/rbac
2026-04-11 14:51:34 +02:00
dextmorgn
47b0c05115 feat(app): RBAC permission gating, UserAvatar component, share dialog redesign and responsive grids
- Add UserAvatar and AvatarGroup components to /ui/avatar, migrate all consumers
- Display collaborator avatars in top navbar with AvatarGroup
- Gate edit actions behind canEdit: toolbar (connect, merge, layouts), nodes-view,
  nodes-panel, relationships-view (checkboxes + delete), edge-details-panel,
  graph-navigation (Add tab), import sheet, analysis creation
- Add role badge (owner/admin/editor/viewer) with colored icons in case-header
- Redesign share-dialog: role badges as dropdown triggers, skeleton loading,
  owner/member separation, hover-reveal delete button
- Switch all card grids from viewport breakpoints to CSS container queries
  (dashboard-stats, investigations-list, sketches-section, metrics-grid, custom-types)
- Extend container query classes in styles.css for all needed column counts
2026-04-11 14:44:29 +02:00
dextmorgn
8cd6b237bb fix(core/tests): update failing test 2026-04-11 14:44:29 +02:00
dextmorgn
ce1f50bb72 chore: update yarn.lock 2026-04-11 14:44:28 +02:00
dextmorgn
f5e6fb79ae feat(app): RBAC permission gating, share dialog, profile page and user display
- Add useCan hook reading permissions from $investigationId layout route loader
- Add $investigationId layout route to fetch investigation and share data with children
- Add share dialog with user search autocomplete (debounced) for collaborator management
- Add /dashboard/profile page for editing first name, last name, avatar URL
- Gate all edit/create/delete actions based on user role (deny by default)
  - Graph: node drag, quick add, link creation, context menus, detail panel editing
  - Analysis: editor readonly, title readonly, actions hidden for viewers
  - Investigation: delete (owner only), share (admin+), new sketch/analysis (editor+)
  - Settings: sketch info readonly, delete sketch and import hidden for viewers
  - Selected items panel: enrich, delete, ask AI hidden for viewers
- Fix nav-user to show real user data instead of hardcoded placeholder
- Add user-display utils (getDisplayName, getInitials) shared across components
- Add avatar display in nav-user, case-header collaborators and share dialog
2026-04-11 14:44:28 +02:00
dextmorgn
bb502379bd feat(api): add collaborator endpoints, profile update, user search and backfill migration
- Add GET/POST/PUT/DELETE /investigations/{id}/collaborators endpoints
- Inject current_user_role in all investigation responses
- Add GET/PUT /auth/me for profile management
- Add GET /auth/users/search for share dialog autocomplete
- Add ProfileUpdate schema with extra=forbid
- Add email to ProfileRead
- Add backfill migration for existing investigations missing owner role entries
2026-04-11 14:44:28 +02:00
dextmorgn
1a5d164805 feat(core): add RBAC with ADMIN role, collaborator management and auth user data
- Add ADMIN role to Role enum (OWNER > ADMIN > EDITOR > VIEWER)
- Update permission matrix: ADMIN can read/create/update/manage
- Fix get_with_relations bug filtering by owner_id (blocked non-owner collaborators)
- Add collaborator management methods to InvestigationRepository and InvestigationService
- Return user profile data in auth response
2026-04-11 14:44:28 +02:00
dextmorgn
400b21a7d1 feat(api): install uv Dockerfile 2026-04-11 14:42:39 +02:00
dextmorgn
85a85ed541 chore: switch to uv 2026-04-11 13:17:14 +02:00
dextmorgn
00ec9d6f26 feat(types): switch to uv 2026-04-11 13:15:06 +02:00
dextmorgn
cfaece5af2 feat(enrichers): switch to uv 2026-04-11 13:14:53 +02:00
dextmorgn
6f3af239b2 feat(core): switch to uv 2026-04-11 13:14:41 +02:00
dextmorgn
5569a2f9b7 feat(api): switch to uv 2026-04-11 13:14:16 +02:00
dextmorgn
c5525a8f38 Merge pull request #129 from z3rodaycve/feature/hudsonrock-enrichers
HudsonRock Integration Enrichers for Infostealer related data
2026-04-09 11:50:33 +02:00
dextmorgn
a38a773657 Merge pull request #128 from hillw3318-ui/tags-input
feat(app): Added 'tags-input'. Changed the text input for aliases in details panel
2026-04-08 11:28:40 +02:00
dextmorgn
6852ee3213 fix: missing AUTH_SECRET 2026-04-06 10:02:58 +02:00
dextmorgn
609c957677 feat(enrichers): remove heavy logging on whoxy enrichers 2026-03-16 11:15:16 +01:00
dextmorgn
976bd0ae99 feat(app): vertical link rendering 2026-03-16 11:15:16 +01:00
dextmorgn
4f5c24295c Merge pull request #131 from reconurge/dextmorgn-patch-1
Update README with support badges
2026-03-16 11:14:00 +01:00
dextmorgn
57e72cb337 Update README with support badges
Added support badges for Buy Me A Coffee and Ko-fi.
2026-03-16 11:13:44 +01:00
William Hill
1d376b8ac0 Merge branch 'reconurge:main' into tags-input 2026-03-16 00:32:00 +03:00
bjst0ne
def29144eb fix(app): fixed lists/arrays render in 'details-panel' 2026-03-16 00:37:03 +03:00
dextmorgn
3ef942a2bc feat(app): fix double-click detection and graphRef initialization
Thread MouseEvent through onBackgroundClick for double-click detection.
Use stable callback ref to avoid ForceGraph2D stale closure issues.
Fix graphRef not being set on first load by notifying parent immediately
after ForceGraph2D mounts. Close quick-add input on blur.
2026-03-15 13:34:34 +01:00
dextmorgn
1ee06f5c8a feat(types): format phone numbers to avoid doublons 2026-03-15 13:07:01 +01:00
dextmorgn
37cdd93257 feat(types): detect individual from spaces 2026-03-15 13:06:40 +01:00
dextmorgn
5292a3c541 feat(app): add quick-add node on double-click
Double-click on the graph background opens an inline input at that position.
Typing triggers debounced type detection via the API, showing the detected
type as a badge. On Enter, a node is created at the click position with the
detected type and persisted to the API. Escape or click outside cancels.
2026-03-15 12:37:46 +01:00
dextmorgn
972763343e feat(api): add type detection route and service
Add POST /api/types/detect endpoint that detects the type of a text input
by iterating registered types and calling their detect() classmethod.
Returns detected type with fields and primary field pre-filled.
Falls back to Phrase when no type matches.
2026-03-15 12:35:46 +01:00
dextmorgn
97ef7e4695 feat(app): add alt+drag edge creation between nodes
Allow creating edges by holding Alt and dragging from one node to another.
On release, the edge is added to the graph and the edge context menu opens
with the label input focused and selected, ready for editing. The edge is
only persisted to the API on Enter/submit; Escape removes it from the store.
2026-03-15 11:51:05 +01:00
bjst0ne
3e8e4c8423 Merge branch 'main' into tags-input 2026-03-10 13:37:48 +03:00
bjst0ne
cd67ae369b refactor(app): changed props for 'tags-input' 2026-03-10 12:54:16 +03:00
z3rodaycve
ff9d270ddd Release of HudsonRockToUsername, HudsonRockToPhone, HudsonRockToEmail 2026-03-10 10:24:39 +01:00
dextmorgn
aacbd5ac85 feat(app): add input and output type to enricher template 2026-03-09 19:39:33 +01:00
dextmorgn
6b35cccddb feat(core): add input and output type to template generator 2026-03-09 19:39:08 +01:00
dextmorgn
2ea40c4eae feat(api): add input and output type to enricher template 2026-03-09 19:38:42 +01:00
dextmorgn
611bc1a563 feat(app): improve node and edge rendering 2026-03-08 20:01:55 +01:00
dextmorgn
a2cc352235 feat: update gitignore for DS Store 2026-03-08 19:58:57 +01:00
dextmorgn
16df6b9928 feat(app): update status indicator height 2026-03-04 21:50:15 +01:00
dextmorgn
ffd44968ad feat(app): update toolbars layout 2026-03-04 19:53:50 +01:00
dextmorgn
bb7c955eab feat(app): update details panel 2026-03-04 19:33:02 +01:00
dextmorgn
fd8ddcfd77 Merge pull request #126 from gustavorps/fix/proxy-api
fix(api): Better support to reverse proxy deploy
2026-03-04 09:21:02 +01:00
dextmorgn
ddc36eac4d Merge pull request #125 from gustavorps/feat/soft-delete
feat(core): Soft delete
2026-03-02 20:50:11 +01:00
bjst0ne
5cdd8e70af feat(app): add copy for lists in details-panel 2026-03-02 02:33:57 +03:00
bjst0ne
15878019fa feat(app): add tags-input for list fields in creation form 2026-03-02 02:05:05 +03:00
bjst0ne
523a387661 refactor(app): change tags-input to stacked variant 2026-03-01 19:06:11 +03:00
bjst0ne
171c606e07 fix(app): reduced the height of tags-input 2026-03-01 19:06:11 +03:00
bjst0ne
22400f2e20 refactor(app): change styles to general design 2026-03-01 19:06:11 +03:00
bjst0ne
04e1c2f542 refactor(app): changed condition for aliases input 2026-03-01 19:06:10 +03:00
bjst0ne
ae81c216b0 feat(app): added 'tags-input'. Changed the text input for aliases to tags-input in the details panel 2026-03-01 19:06:10 +03:00
dextmorgn
f49e05eafb fix(core): check if input type is list 2026-02-28 21:29:37 +01:00
dextmorgn
86584ba84e fix(core): prevent sketch break on sick node 2026-02-28 18:35:57 +01:00
dextmorgn
ab13a8e2b0 feat(app/enricher-template): io bar in template enricher layout 2026-02-28 11:52:56 +01:00
dextmorgn
7a2ea266d5 feat(app/enricher-template): io bar to specify input and output type 2026-02-28 11:52:31 +01:00
gustavorps
a574d4ebea feat(core): soft delete 2026-02-20 15:41:46 -03:00
gustavorps
b2cfc67117 fix(app): removing slashes 2026-02-17 04:49:46 -03:00
gustavorps
5ee7bf24e0 fix(api): added redirect_slashes=False to face 307 redirect reverse proxy issue 2026-02-17 04:27:13 -03:00
gustavorps
da0280506e fix(api): added ignore_trailing_slash=True to face 307 redirect reverse proxy issue 2026-02-17 04:21:01 -03:00
gustavorps
d52669740b feat(api): added fastapi standard 2026-02-17 04:04:07 -03:00
dextmorgn
606bcced4b feat(enrichers): add delay to dummy enricher 2026-02-16 09:16:26 +01:00
dextmorgn
6494cee2d0 feat(api): update key service 2026-02-16 09:16:05 +01:00
dextmorgn
c7275594cb refactor(core): use permissions in base service 2026-02-16 09:15:19 +01:00
dextmorgn
4d97932604 feat(app): check chat key exists 2026-02-16 09:14:29 +01:00
dextmorgn
0fa48d3fc1 feat(migrations): backfill_edge_sketch_id 2026-02-15 18:08:59 +01:00
dextmorgn
517b342fc2 feat(enrichers): add delay on whoxy requests 2026-02-15 18:07:58 +01:00
dextmorgn
c05ad758d8 fix(core): missing sketch_id in relationships 2026-02-15 18:07:34 +01:00
dextmorgn
c481a14747 feat(app): path finder 2026-02-15 15:25:35 +01:00
dextmorgn
635f342aba feat(app): remove spaces from custom types 2026-02-15 12:57:29 +01:00
dextmorgn
d6324208a3 feat(app): remove feature flag 2026-02-14 18:00:53 +01:00
dextmorgn
490878ab06 feat(core): return no enricher for custom types 2026-02-14 18:00:27 +01:00
dextmorgn
9ddb708744 Merge pull request #123 from reconurge/fix/custom-types
Fix/custom types
2026-02-14 15:35:59 +01:00
dextmorgn
9d1d5c1416 feat(app): update custom types 2026-02-14 15:35:20 +01:00
dextmorgn
9d1991f45f feat(core): use type service 2026-02-14 15:34:58 +01:00
dextmorgn
eef214bdfa feat(api): custom types updates 2026-02-14 15:34:28 +01:00
dextmorgn
1db16ec13a feat: update yarn.lock 2026-02-14 15:34:04 +01:00
dextmorgn
2032aeee0b feat: update makefile with new commands 2026-02-14 15:32:29 +01:00
dextmorgn
92e5a7097a fix(app): radio group 2026-02-13 13:18:00 +01:00
dextmorgn
b9fa1bbde9 fix(enrichers): individual to domains 2026-02-13 13:17:44 +01:00
dextmorgn
dcce008b8d feat(app): minimal ui updates 2026-02-13 11:23:52 +01:00
dextmorgn
b6f2f8be68 Merge pull request #122 from reconurge/feat/enricher-templates
Feat/enricher templates
2026-02-12 23:53:22 +01:00
dextmorgn
ad6a31a7ab feat(app): enricher template feature flag 2026-02-12 23:52:39 +01:00
dextmorgn
6293c90511 feat: update yarn.lock 2026-02-12 23:44:19 +01:00
dextmorgn
ae070c14c7 feat(app): feature flag 2026-02-12 23:43:57 +01:00
dextmorgn
599fc8353d feat(api): increate prompt chat length 2026-02-12 23:40:12 +01:00
dextmorgn
11df9e6fde fix(app): chat context 2026-02-12 23:39:21 +01:00
dextmorgn
af590675ec fix(core): template generator chat prompt 2026-02-12 23:38:29 +01:00
dextmorgn
89f32e2182 feat(app): progress on templates 2026-02-12 22:04:29 +01:00
dextmorgn
3f252ea6ab feat(core): progress on templates 2026-02-12 22:04:28 +01:00
dextmorgn
a764704fbe feat(api): progress on templates 2026-02-12 22:04:28 +01:00
dextmorgn
19ee40c46d feat(app): chat context 2026-02-12 22:04:28 +01:00
dextmorgn
137d16e1fc feat(app): updates on chat 2026-02-12 22:04:28 +01:00
dextmorgn
38cee3759e feat(core): chat_service 2026-02-12 22:04:28 +01:00
dextmorgn
07f32c983a feat: use ai sdk useChat 2026-02-12 22:04:21 +01:00
dextmorgn
fbb9666c53 feat(core): poetry.lock 2026-02-12 22:03:28 +01:00
dextmorgn
a461f9997d feat: progress on enricher templates 2026-02-12 22:03:28 +01:00
dextmorgn
e4cf6ac518 feat: update yarn.lock 2026-02-12 22:03:24 +01:00
dextmorgn
ac397f6714 feat(app): enrichers from templates 2026-02-12 22:03:04 +01:00
dextmorgn
53a03575cd feat(api): enrichers from templates 2026-02-12 22:03:04 +01:00
dextmorgn
5ff6ba2b1c feat(core): enrichers from templates 2026-02-12 22:03:04 +01:00
dextmorgn
327123a729 feat(core): template enricher v1 2026-02-12 22:03:03 +01:00
dextmorgn
2bb1fdbfc1 feat(core): enricher template 2026-02-12 22:03:03 +01:00
dextmorgn
1bf3dae185 feat(app): update map 2026-02-12 22:02:14 +01:00
dextmorgn
8a01f353b8 feat(enrichers): whoisxml 2026-02-12 22:01:52 +01:00
dextmorgn
9e07429e7b feat(types): unique webtracker label 2026-02-12 22:01:26 +01:00
dextmorgn
2bac67e2a8 feat(types): individual.last_name becomes optional 2026-02-07 17:44:55 +01:00
dextmorgn
ad85a5c419 Merge pull request #119 from reconurge/feat/repositories
Feat/repositories
2026-02-07 17:30:43 +01:00
dextmorgn
177953b216 feat(api): use repositories in services 2026-02-07 17:23:16 +01:00
dextmorgn
dd0c53f089 feat(core): use repositories in services 2026-02-07 17:22:51 +01:00
dextmorgn
1ba3c0bd6c Merge pull request #117 from reconurge/feat/refactor-services
feat/refactor services
2026-02-05 22:41:18 +01:00
dextmorgn
2fec5b82e9 fix(app): remove next-theme dependency 2026-02-05 22:38:24 +01:00
dextmorgn
1cc88d1033 feat(core): add dedicated services for business logic 2026-02-05 22:38:00 +01:00
dextmorgn
2372678bda feat(api): use dedicated services for business logic 2026-02-05 22:37:31 +01:00
dextmorgn
fd20468c79 Merge pull request #115 from salmanmkc/upgrade-github-actions-node24-general
Upgrade GitHub Actions to latest versions
2026-02-02 20:56:42 +01:00
dextmorgn
427035fd41 Merge pull request #114 from salmanmkc/upgrade-github-actions-node24
Upgrade GitHub Actions for Node 24 compatibility
2026-02-02 20:56:18 +01:00
Salman Muin Kayser Chishti
152be112d0 Upgrade GitHub Actions to latest versions
Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com>
2026-02-01 09:12:10 +00:00
Salman Muin Kayser Chishti
01908e682b Upgrade GitHub Actions for Node 24 compatibility
Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com>
2026-02-01 09:12:05 +00:00
dextmorgn
20a3d35909 fix(api): keep docker user as root for now 2026-01-30 22:19:43 +01:00
dextmorgn
16c9497268 Merge pull request #113 from AlexanderLueftl/fix/crawler-validation
fix(enricher): graceful handling of invalid phone/email in website_to_crawler
2026-01-28 11:18:34 +01:00
Alexander Lüftl
12ef177127 fix(enricher): handle invalid phone/email validation gracefully in website_to_crawler
Previously, when the reconcrawl library extracted a phone number or email
that failed Pydantic validation, the entire crawl would fail and all
collected data would be lost.

This adds per-item try-except blocks around Phone() and Email() construction
so invalid items are logged and skipped while valid ones are still collected.
2026-01-28 10:07:41 +01:00
dextmorgn
14f79fecc5 feat(enricher): email to domain + fix empty values 2026-01-27 11:15:50 +01:00
dextmorgn
73bb4a0e7a fix(ci): only upload Trivy SARIF when scan succeeds 2026-01-25 23:39:19 +01:00
dextmorgn
51e2f87643 fix(app): analysis mention list 2026-01-25 21:13:01 +01:00
dextmorgn
ce2fcb49bd Merge pull request #112 from reconurge/feat/ci
feat(chore): update deploy components
2026-01-25 21:11:54 +01:00
dextmorgn
a178ef1bcd feat(chore): update deploy components 2026-01-25 20:45:22 +01:00
dextmorgn
7d33b813de chore(license): migrate from AGPL-3.0 to Apache License 2.0 2026-01-25 16:19:28 +01:00
358 changed files with 33404 additions and 51757 deletions

View File

@@ -0,0 +1,178 @@
---
name: flowsint-enricher-builder
description: Expert guidance for building Flowsint enrichers and their supporting types. Use when the user wants to add a new enricher, create a new Flowsint type, wire a new external API/tool into Flowsint, debug type/enricher discovery, or design a pivot from entity A to entity B. Knows where types live, how the enricher base class works, how vault secrets and params resolve, and when to recommend creating a new type instead of forcing data into an existing one.
---
# Flowsint Enricher Builder
You build enrichers and types for Flowsint. You do not memorize the catalog — you know where to look and how the pieces fit. Always read source before generating code: type definitions and existing enrichers are the ground truth.
## Authoritative source paths
Read these first. Never assume signatures or fields — open the file.
| What | Path |
|---|---|
| Type definitions | `flowsint-types/src/flowsint_types/<name>.py` |
| Type registry + decorator | `flowsint-types/src/flowsint_types/registry.py` |
| Type package exports | `flowsint-types/src/flowsint_types/__init__.py` |
| Enricher base class | `flowsint-core/src/flowsint_core/core/enricher_base.py` |
| Enricher registry + decorator | `flowsint-enrichers/src/flowsint_enrichers/registry.py` |
| Existing enrichers (templates) | `flowsint-enrichers/src/flowsint_enrichers/<input_type>/to_<output>.py` |
| UI category mapping | `flowsint-core/src/flowsint_core/core/services/type_registry_service.py` (`_get_category_definitions`) |
| Vault interface | `flowsint-core/src/flowsint_core/core/vault.py` |
| Logger interface | `flowsint-core/src/flowsint_core/core/logger.py` |
| Tools (external CLI/API wrappers) | `tools/` (top-level), e.g. `tools.network.subfinder.SubfinderTool` |
| Doc — types tutorial | `docs/developers/managing-types.mdx` |
| Doc — enrichers tutorial | `docs/developers/managing-enrichers.mdx` |
| Doc — enricher catalog | `docs/sources/available-enrichers.mdx` |
## The first question: new type or reuse?
When the user describes a enricher, decide before writing code:
1. **List the entities involved** — input data, output data, intermediate fields you'll attach.
2. **For each, check `flowsint-types/src/flowsint_types/`** — open the closest candidate file and read its fields.
3. **Decide:**
- **Reuse** if existing type covers all required fields (extras allowed — `ConfigDict.extra = "allow"`).
- **Extend an existing type** if 12 fields are missing — propose adding optional fields to the existing model.
- **Create new type** if the entity is conceptually distinct (different primary key, different label semantics, different graph role).
4. **Never cram data into a wrong type.** If a "Domain" enricher returns risk scores, a `RiskProfile` exists — don't stuff scores into `Domain` metadata. If nothing fits, propose a new type and tell the user why.
Surface the decision to the user before generating code: list candidate types you found, what's missing, and your recommendation.
## Anatomy of an enricher
Minimum surface (read `enricher_base.py` for the full contract):
```python
from typing import List
from flowsint_core.core.enricher_base import Enricher
from flowsint_core.core.logger import Logger
from flowsint_enrichers.registry import flowsint_enricher
from flowsint_types import Domain, Ip # or whatever types
@flowsint_enricher
class MyEnricher(Enricher):
"""[Source name] One-line purpose."""
InputType = Domain # base type, not List[Domain]
OutputType = Ip
@classmethod
def name(cls) -> str: return "domain_to_ip" # snake_case, unique
@classmethod
def category(cls) -> str: return "Domain" # see note on casing below
@classmethod
def key(cls) -> str: return "domain" # primary field of InputType
@classmethod
def get_params_schema(cls): # optional, only if params needed
return [...]
async def scan(self, data: List[InputType]) -> List[OutputType]:
...
def postprocess(self, results, input_data):
for src, dst in zip(input_data, results):
self.create_node(src)
self.create_node(dst)
self.create_relationship(src, dst, "RESOLVES_TO")
return results
InputType = MyEnricher.InputType
OutputType = MyEnricher.OutputType
```
**File location:** `flowsint-enrichers/src/flowsint_enrichers/<input_type>/to_<target>.py`. The directory matches the input type's lowercase name. If no directory exists for your input type, create it (no `__init__.py` needed — auto-discovery walks the tree).
**Registration:** the `@flowsint_enricher` decorator does it. Do not edit any `registry.py`. API restart picks up new files via `load_all_enrichers()`.
## Params and secrets
Defined via `get_params_schema()` classmethod. Each entry is a dict:
| Field | Required | Notes |
|---|---|---|
| `name` | yes | Param key; for `vaultSecret`, also the default vault key name |
| `type` | yes | One of `string`, `number`, `select`, `url`, `vaultSecret` |
| `description` | yes | Shown in UI |
| `required` | no | Defaults to `false` |
| `default` | no | Default value |
| `options` | for `select` | List of `{"label": ..., "value": ...}` |
Read params inside `scan()`:
```python
mode = self.params.get("mode", "passive")
api_key = self.get_secret("MY_API_KEY") # vault-resolved during async_init
```
**Vault resolution flow** (see `Enricher.resolve_params` in `enricher_base.py`):
1. If user passed a vault ID in params → vault looked up by that ID.
2. Else → vault looked up by the param name (e.g. `MY_API_KEY`).
3. If `required: true` and nothing found → `Exception("Required vault secret 'MY_API_KEY' is missing...")`.
**Never hardcode keys.** Always declare a `vaultSecret` param. Document the expected vault key name in the docstring.
## Graph operations (postprocess)
`create_node(obj)` and `create_relationship(from_obj, to_obj, rel_label="IS_RELATED_TO")` take Pydantic objects directly. Don't manually construct node dicts — pass the typed instance.
Relationship label convention: `UPPER_SNAKE_CASE` verb phrase (`HAS_DOMAIN`, `RESOLVES_TO`, `FOUND_IN_BREACH`). Be consistent with existing enrichers — grep before inventing a new label.
`self.log_graph_message("...")` for graph-related progress logs. `Logger.info / error / warn(self.sketch_id, {"message": "..."})` for general logs.
## Creating a new type — checklist
When you decide a new type is warranted:
1. **File**: `flowsint-types/src/flowsint_types/<snake_case>.py`.
2. **Class**: `PascalCase`, inherit from `FlowsintType`, decorate with `@flowsint_type`.
3. **Exactly one primary field**: `Field(..., json_schema_extra={"primary": True})`. Must uniquely identify the entity (used as Neo4j MERGE key).
4. **`compute_label`**: `@model_validator(mode='after')`, sets `self.nodeLabel`, returns `self`. Handle `None` for optional fields.
5. **Export in `__init__.py`**: add import + entry in `__all__`.
6. **Category** (optional but recommended): add a `("MyType", "primary_field_name", icon)` tuple in `_get_category_definitions()` in `type_registry_service.py`. Without this, the type works as an enricher I/O but doesn't show in the UI type picker.
7. **Reinstall**: `cd flowsint-types && poetry install` (or `make prod` from repo root).
8. **Test**: write a `tests/test_<name>.py` covering creation, primary uniqueness, `compute_label` with full/partial fields.
Full template + patterns: `docs/developers/managing-types.mdx`.
## Naming conventions (already-established, don't break)
- Enricher `name()`: `<input>_to_<output>` snake_case (e.g. `domain_to_ip`, `email_to_breaches`).
- Enricher file: `to_<target>.py` under `<input_type>/` directory.
- Class name: descriptive PascalCase (e.g. `DomainToIpEnricher`, `WhoisEnricher`).
- Type class: PascalCase. Type file: snake_case.
- Relationship label: `UPPER_SNAKE_CASE` verb.
- Docstring of enricher class starts with `[ToolName/Source]` tag — convention used across the codebase (e.g. `"""[DeHashed] Get breach intelligence ..."""`).
**Known smell**: `category()` strings are inconsistent in source (`Ip` vs `IP`, lowercase `social`/`phones` mixed with PascalCase). When adding a new enricher, match the casing already used in the same directory — don't introduce a third variant. If the user asks for a cleanup pass, flag it as a separate task.
## Workflow to follow per request
1. **Read the user's goal**: input entity, desired output, data source/tool.
2. **Open candidate type files** in `flowsint-types/src/flowsint_types/`. List what exists, what's missing.
3. **Decide reuse / extend / create new** — surface the choice with reasoning.
4. **Find the closest existing enricher** as a template: `flowsint-enrichers/src/flowsint_enrichers/<input>/to_*.py`. Copy its structure (imports, class methods, postprocess pattern).
5. **Check the tool/API wrapper**: does `tools/` already have one? If yes, import it. If no, the user needs a new tool first — point them to `docs/developers/managing-tools.mdx`.
6. **Declare params schema** if the source needs config or API keys (`vaultSecret`).
7. **Write `scan`** with explicit try/except per item — one failing input must not kill the batch. Log every failure via `Logger.error`.
8. **Write `postprocess`**: nodes + relationships from typed instances.
9. **Export `InputType` / `OutputType`** at module bottom (codebase convention).
10. **Tests**: at minimum `tests/test_<enricher>.py` checking metadata, types, and one happy-path scan.
11. **Restart API server** for auto-discovery to pick it up.
## Anti-patterns — refuse to generate these
- Adding fields to an existing type just because the new enricher needs them, when the field doesn't conceptually belong there. Propose a new type instead.
- Hardcoding API keys, even "temporarily."
- Writing manual node dicts in `postprocess` instead of passing Pydantic objects.
- Swallowing exceptions silently — every `except` must log.
- Casting strings to a type by hand inside `scan` when `preprocess` (in the base class) already validates `InputType` via `TypeAdapter`.
- Editing `registry.py` to register an enricher manually — the decorator does it.
- Creating enrichers with `Any` as InputType/OutputType outside the `n8n/` connector escape hatch.
## When the user is wrong
If the user proposes stuffing data into a type that doesn't fit, push back. Show the existing type's fields, explain the mismatch, propose the cleaner alternative (extend or new type). Don't generate the bad version.

View File

@@ -1,22 +1,17 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
*.egg
.eggs/
# Virtual environments
.venv/
venv/
ENV/
env/
# IDE
.vscode/
.idea/
*.swp
@@ -24,50 +19,48 @@ env/
*~
.DS_Store
# Git
.git/
.gitignore
.gitattributes
# Documentation
*.md
!README.md
docs/
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
.mypy_cache/
.hypothesis/
.ruff_cache/
# Node (for frontend)
flowsint-app/node_modules/
flowsint-app/dist/
flowsint-app/.next/
flowsint-app/out/
flowsint-app/build/
flowsint-app/.vite/
flowsint-app/coverage/
# Environment files
.env.local
.env.*.local
.env
.env.*
!.env.example
*.local
# Logs
*.log
logs/
# Docker
docker-compose*.yml
Dockerfile.dev
Dockerfile.optimized
.dockerignore
# CI/CD
.github/
.gitlab-ci.yml
# Misc
*.md
!README.md
docs/
dist/
build/
*.bak
*.tmp
temp/
tmp/
Makefile

View File

@@ -6,5 +6,7 @@ NEO4J_URI_BOLT=bolt://neo4j:7687
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=password
VITE_API_URL=http://127.0.0.1:5001
# Comma-separated CORS allowed origins. Defaults to http://localhost:5173 (Vite dev) when unset.
ALLOWED_ORIGINS=http://localhost:5173
DATABASE_URL=postgresql://flowsint:flowsint@localhost:5433/flowsint
REDIS_URL=redis://redis:6379/0

5
.gitattributes vendored Normal file
View File

@@ -0,0 +1,5 @@
# Force LF on files executed or parsed inside Linux containers,
# regardless of the host OS / core.autocrlf setting.
*.sh text eol=lf
.env.example text eol=lf
Makefile text eol=lf

View File

@@ -1,18 +1,34 @@
name: images
name: Build and Push Docker Images
on:
push:
tags:
- "v*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
environment: production
build-frontend:
name: Build Frontend
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
@@ -26,33 +42,134 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ github.repository_owner }}/flowsint-app
ghcr.io/${{ github.repository_owner }}/flowsint-app
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./flowsint-app
file: ./flowsint-app/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
- name: Run Trivy vulnerability scanner
id: trivy
uses: aquasecurity/trivy-action@v0.35.0
with:
image-ref: ghcr.io/${{ github.repository_owner }}/flowsint-app:${{ steps.meta.outputs.version }}
format: "sarif"
output: "trivy-frontend.sarif"
severity: "CRITICAL,HIGH"
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v4
if: always() && steps.trivy.outcome == 'success'
with:
sarif_file: "trivy-frontend.sarif"
build-backend:
name: Build Backend
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push frontend
uses: docker/build-push-action@v6
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
platforms: linux/amd64,linux/arm64
push: true
context: "{{defaultContext}}:flowsint-app"
tags: |
${{ github.repository_owner }}/flowsint-app:${{ github.ref_name }}
${{ github.repository_owner }}/flowsint-app:latest
ghcr.io/${{ github.repository_owner }}/flowsint-app:${{ github.ref_name }}
ghcr.io/${{ github.repository_owner }}/flowsint-app:latest
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push backend
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ github.repository_owner }}/flowsint-api
ghcr.io/${{ github.repository_owner }}/flowsint-api
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./flowsint-api/Dockerfile
target: production
platforms: linux/amd64,linux/arm64
push: true
target: prod
file: ./flowsint-api/Dockerfile
tags: |
${{ github.repository_owner }}/flowsint-api:${{ github.ref_name }}
${{ github.repository_owner }}/flowsint-api:latest
ghcr.io/${{ github.repository_owner }}/flowsint-api:${{ github.ref_name }}
ghcr.io/${{ github.repository_owner }}/flowsint-api:latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
- name: Run Trivy vulnerability scanner
id: trivy
uses: aquasecurity/trivy-action@v0.35.0
with:
image-ref: ghcr.io/${{ github.repository_owner }}/flowsint-api:${{ steps.meta.outputs.version }}
format: "sarif"
output: "trivy-backend.sarif"
severity: "CRITICAL,HIGH"
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v4
if: always() && steps.trivy.outcome == 'success'
with:
sarif_file: "trivy-backend.sarif"
security-summary:
name: Security Summary
runs-on: ubuntu-latest
needs: [build-frontend, build-backend]
if: always()
steps:
- name: Summary
run: |
echo "## Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Image | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Frontend | ${{ needs.build-frontend.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Backend | ${{ needs.build-backend.result }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Security scans uploaded to GitHub Security tab." >> $GITHUB_STEP_SUMMARY

7
.gitignore vendored
View File

@@ -2,7 +2,7 @@
__pycache__/
*.py[cod]
*$py.class
.DS_Store
/node_modules/
# C extensions
*.so
@@ -175,4 +175,7 @@ cython_debug/
.pypirc
/certs
.claude/
.claude/*
!.claude/skills/
.claude/skills/*
!.claude/skills/flowsint-enricher-builder/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

View File

@@ -2,7 +2,10 @@
## License
Flowsint is distributed under the **AGPL-3.0** (GNU Affero General Public License v3.0).
Flowsint is distributed under the **Apache License 2.0** (effective January 25, 2026).
Code contributed prior to January 25, 2026 was originally licensed under AGPL-3.0
and has been relicensed to Apache 2.0 with the written consent of all contributors.
## Ethical Foundation
@@ -48,4 +51,4 @@ If you become aware of any use that contradicts these principles, please report
## Responsibility Statement
The developers and maintainers of Flowsint are committed to upholding these ethical principles throughout the projects life cycle.
However, in accordance with the AGPL-3.0 license, **Flowsint is provided as is, without warranty**, and its authors **cannot be held liable for misuse or unlawful activity conducted by third parties**.
However, in accordance with the Apache License 2.0, **Flowsint is provided "as is," without warranty**, and its authors **cannot be held liable for misuse or unlawful activity conducted by third parties**.

861
LICENSE
View File

@@ -1,660 +1,201 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
A modern platform for visual, flexible, and extensible graph-based investigations. Built with modern technologies for seamless data exploration, enrichment, and connection.
Copyright (C) 2025 reconurge
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025-2026 Reconurge
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

228
Makefile
View File

@@ -1,16 +1,20 @@
PROJECT_ROOT := $(shell pwd)
COMPOSE_DEV := docker compose -f docker-compose.dev.yml
COMPOSE_PROD := docker compose -f docker-compose.prod.yml
COMPOSE_DEV := docker compose -f docker-compose.dev.yml
COMPOSE_PROD := docker compose -f docker-compose.prod.yml
COMPOSE_DEPLOY := docker compose -f docker-compose.deploy.yml
.PHONY: \
dev prod \
dev prod deploy \
build-dev build-prod \
up-dev up-prod down \
up-dev up-prod up-deploy down \
infra-dev infra-prod infra-stop-dev infra-stop-prod \
migrate-dev migrate-prod \
alembic-upgrade alembic-downgrade alembic-revision \
api frontend celery \
test install clean check-env open-browser
test install clean check-env open-browser-dev open-browser-prod \
logs-dev logs-prod logs-deploy status \
regenerate-router
ENV_DIRS := . flowsint-api flowsint-core flowsint-app
@@ -25,37 +29,90 @@ check-env:
fi; \
done
open-browser:
@echo "Waiting for frontend..."
@bash -c 'until curl -s http://localhost:5173 > /dev/null 2>&1; do sleep 1; done'
@open http://localhost:5173 2>/dev/null || \
xdg-open http://localhost:5173 2>/dev/null || \
echo "Frontend ready at http://localhost:5173"
dev:
@echo "Starting DEV environment..."
$(MAKE) check-env
$(MAKE) build-dev
$(MAKE) up-dev
$(MAKE) open-browser-dev
$(COMPOSE_DEV) logs -f
build-dev:
@echo "Building DEV images..."
$(COMPOSE_DEV) build
build-prod:
@echo "Building PROD images..."
$(COMPOSE_PROD) build
up-dev:
$(COMPOSE_DEV) up -d
infra-dev:
@echo "Starting DEV infra (postgres / redis / neo4j)..."
$(COMPOSE_DEV) up -d postgres redis neo4j
infra-prod:
@echo "Starting PROD infra (postgres / redis / neo4j)..."
$(COMPOSE_PROD) up -d postgres redis neo4j
infra-stop-dev:
@echo "Stopping DEV infra..."
$(COMPOSE_DEV) stop postgres redis neo4j
logs-dev:
$(COMPOSE_DEV) logs -f
open-browser-dev:
@echo "Waiting for frontend on port 5173..."
@bash -c 'until curl -s http://localhost:5173 > /dev/null 2>&1; do sleep 1; done'
@open http://localhost:5173 2>/dev/null || \
xdg-open http://localhost:5173 2>/dev/null || \
echo "Frontend ready at http://localhost:5173"
prod:
@echo "Starting PROD environment..."
$(MAKE) check-env
$(MAKE) build-prod
$(MAKE) up-prod
@echo ""
@echo "Production started!"
@echo " Frontend: http://localhost:5173"
@echo " API: http://localhost:5001"
build-prod:
@echo "Building PROD images..."
$(COMPOSE_PROD) build
up-prod:
$(COMPOSE_PROD) up -d
infra-prod:
@echo "Starting PROD infra (postgres / redis / neo4j)..."
$(COMPOSE_PROD) up -d postgres redis neo4j
infra-stop-prod:
@echo "Stopping PROD infra..."
$(COMPOSE_PROD) stop postgres redis neo4j
logs-prod:
$(COMPOSE_PROD) logs -f
open-browser-prod:
@echo "Waiting for frontend on port 80 (Traefik)..."
@bash -c 'until curl -s http://localhost > /dev/null 2>&1; do sleep 2; done'
@open http://localhost 2>/dev/null || \
xdg-open http://localhost 2>/dev/null || \
echo "Frontend ready at http://localhost"
deploy:
@echo "Starting DEPLOY environment (GHCR images)..."
$(MAKE) check-env
$(COMPOSE_DEPLOY) pull
$(COMPOSE_DEPLOY) up -d
@echo ""
@echo "Deploy started!"
@echo " Frontend: http://localhost (via Traefik)"
@echo " API: http://localhost/api"
up-deploy:
$(COMPOSE_DEPLOY) up -d
logs-deploy:
$(COMPOSE_DEPLOY) logs -f
migrate-dev:
@echo "Running DEV migrations..."
@if ! $(COMPOSE_DEV) ps -q neo4j | grep -q .; then \
@@ -73,56 +130,54 @@ migrate-prod:
fi
yarn migrate
dev:
@echo "Starting DEV environment..."
$(MAKE) check-env
alembic-upgrade:
@echo "Running Alembic migrations (upgrade head)..."
cd $(PROJECT_ROOT)/flowsint-api && uv run alembic upgrade head
alembic-downgrade:
@echo "Rolling back last Alembic migration..."
cd $(PROJECT_ROOT)/flowsint-api && uv run alembic downgrade -1
alembic-revision:
@if [ -z "$(m)" ]; then \
echo "Usage: make alembic-revision m=\"your migration message\""; exit 1; \
fi
@echo "Creating new Alembic migration: $(m)"
cd $(PROJECT_ROOT)/flowsint-api && uv run alembic revision --autogenerate -m "$(m)"
api:
cd $(PROJECT_ROOT)/flowsint-api && \
uv run uvicorn app.main:app --host 0.0.0.0 --port 5001 --reload
frontend:
cd $(PROJECT_ROOT)/flowsint-app && yarn dev
celery:
cd $(PROJECT_ROOT)/flowsint-api && \
uv run celery -A flowsint_core.core.celery \
worker --loglevel=info --pool=threads --concurrency=10
test:
cd flowsint-types && uv run pytest
cd flowsint-core && uv run pytest
cd flowsint-enrichers && uv run pytest
install:
$(MAKE) infra-dev
$(MAKE) build-dev
$(MAKE) up-dev
$(MAKE) open-browser
$(COMPOSE_DEV) logs -f
uv sync
cd flowsint-api && uv run alembic upgrade head
prod:
@echo "Starting PROD environment..."
$(MAKE) check-env
$(MAKE) build-prod
$(MAKE) up-prod
up-dev:
$(COMPOSE_DEV) up -d --no-build
up-prod:
$(COMPOSE_PROD) up -d --no-build
status:
@echo "=== DEV Containers ==="
@$(COMPOSE_DEV) ps 2>/dev/null || echo "No DEV containers"
@echo ""
@echo "=== PROD Containers ==="
@$(COMPOSE_PROD) ps 2>/dev/null || echo "No PROD containers"
down:
-$(COMPOSE_DEV) down
-$(COMPOSE_PROD) down
api:
cd $(PROJECT_ROOT)/flowsint-api && \
poetry run uvicorn app.main:app --host 0.0.0.0 --port 5001 --reload
frontend:
$(COMPOSE_DEV) up -d flowsint-app
$(MAKE) open-browser
celery:
cd $(PROJECT_ROOT)/flowsint-core && \
poetry run celery -A flowsint_core.core.celery \
worker --loglevel=info --pool=threads --concurrency=10
test:
cd flowsint-types && poetry run pytest
cd flowsint-core && poetry run pytest
cd flowsint-enrichers && poetry run pytest
install:
poetry config virtualenvs.in-project true --local
$(MAKE) infra-dev
poetry install
cd flowsint-core && poetry install
cd flowsint-enrichers && poetry install
cd flowsint-api && poetry install && poetry run alembic upgrade head
-$(COMPOSE_DEPLOY) down
clean:
@echo "This will remove ALL Docker data. Continue? [y/N]"
@@ -130,7 +185,50 @@ clean:
if [ "$$confirm" != "y" ]; then exit 1; fi
-$(COMPOSE_DEV) down -v --rmi all --remove-orphans
-$(COMPOSE_PROD) down -v --rmi all --remove-orphans
-$(COMPOSE_DEPLOY) down -v --rmi all --remove-orphans
rm -rf flowsint-app/node_modules
rm -rf flowsint-core/.venv
rm -rf flowsint-enrichers/.venv
rm -rf flowsint-api/.venv
rm -rf .venv
regenerate-router:
@echo "Regenerating flowsint-app/src/routeTree.gen.ts"
cd $(PROJECT_ROOT)/flowsint-app && npx tsr generate
help:
@echo "Flowsint Makefile"
@echo ""
@echo "Development:"
@echo " make dev - Start DEV environment (local build, hot-reload)"
@echo " make build-dev - Build DEV images"
@echo " make up-dev - Start DEV containers"
@echo " make logs-dev - Follow DEV logs"
@echo " make infra-dev - Start only infra (postgres/redis/neo4j)"
@echo ""
@echo "Production (local build):"
@echo " make prod - Start PROD environment (local build + Traefik)"
@echo " make build-prod - Build PROD images"
@echo " make up-prod - Start PROD containers"
@echo " make logs-prod - Follow PROD logs"
@echo ""
@echo "Deploy (GHCR images):"
@echo " make deploy - Start with GHCR images (no build)"
@echo " make up-deploy - Start DEPLOY containers"
@echo " make logs-deploy - Follow DEPLOY logs"
@echo ""
@echo "Local (no Docker):"
@echo " make api - Run API locally"
@echo " make frontend - Run frontend locally"
@echo " make celery - Run Celery worker locally"
@echo ""
@echo "Migrations:"
@echo " make migrate-dev - Run Neo4j DEV migrations"
@echo " make migrate-prod - Run Neo4j PROD migrations"
@echo " make alembic-upgrade - Run Alembic migrations (upgrade head)"
@echo " make alembic-downgrade - Rollback last Alembic migration"
@echo " make alembic-revision m=.. - Create new Alembic migration"
@echo ""
@echo "Utilities:"
@echo " make status - Show container status"
@echo " make down - Stop all containers"
@echo " make clean - Remove all Docker data"
@echo " make install - Install dependencies locally"
@echo " make test - Run tests"

30
NOTICE Normal file
View File

@@ -0,0 +1,30 @@
Flowsint
Copyright 2025-2026 Reconurge
This product includes software developed by Reconurge.
https://github.com/reconurge/flowsint
Licensed under the Apache License, Version 2.0.
===============================================================================
LICENSING HISTORY
===============================================================================
Effective January 25, 2026, Flowsint is licensed under the Apache License 2.0.
All code contributed prior to January 25, 2026 was originally released under
the GNU Affero General Public License v3.0 (AGPL-3.0-or-later). This code has
been relicensed to Apache License 2.0 with the explicit written consent of all
contributors.
The historical Git commits retain the original AGPL-3.0 license references for
archival purposes. Users obtaining code from Git history prior to commit
[LICENSE_MIGRATION_COMMIT] should note that such code was contributed under
AGPL-3.0 terms, but has since been relicensed.
For the avoidance of doubt:
- Code in releases from January 25, 2026 onward: Apache License 2.0
- Code in Git history prior to January 25, 2026: Originally AGPL-3.0,
relicensed to Apache License 2.0 with contributor consent
===============================================================================

View File

@@ -1,15 +1,27 @@
# Flowsint
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](./LICENSE)
[![License](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](./LICENSE)
[![Ethical Software](https://img.shields.io/badge/ethical-use-blue.svg)](./ETHICS.md)
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20coffee-support-FFDD00?logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/dextmorgn)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-support-F16061?logo=ko-fi&logoColor=white)](https://ko-fi.com/P5P01W3GPJ)
[![Discord](https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white)](https://discord.gg/aST9HMQr)
Flowsint is an open-source OSINT graph exploration tool designed for ethical investigation, transparency, and verification.
**Ethics:** Please read [ETHICS.md](./ETHICS.md) for responsible use guidelines.
<img width="1439" height="899" alt="hero-dark" src="https://github.com/user-attachments/assets/01eb128e-bef4-486e-9276-c4da58f829ae" />
<img width="1511" height="946" alt="Capture décran 2026-01-13 à 09 15 58" src="https://github.com/user-attachments/assets/d1a9eca6-9ec4-4402-93f4-303c3dc30de1" />
<img width="1511" height="948" alt="Capture décran 2026-01-13 à 09 19 45" src="https://github.com/user-attachments/assets/6d9e9e6d-d8c7-4ed2-8b8c-53a945b28d05" />
https://github.com/user-attachments/assets/eaabfa81-d7b3-414d-8cf7-f69b4e37bab6
https://github.com/user-attachments/assets/7457d94a-cf1d-4a97-949f-f9b1d8d92644
https://github.com/user-attachments/assets/65c3f26e-7132-4853-be45-21b8933688bd
## Contributing
@@ -19,6 +31,8 @@ Flowsint is still in early development and definetly needs the help of the commu
Don't want to read ? Got it. Here's your install instructions:
### Linux / macOS
#### 1. Install pre-requisites
- Docker
@@ -32,6 +46,35 @@ cd flowsint
make prod
```
### Windows
No Make needed. Works in both **Command Prompt (cmd)** and **PowerShell**.
#### 1. Install pre-requisites
- [Docker Desktop](https://docs.docker.com/desktop/setup/install/windows-install/) (make sure it is **running** before the next step)
- Git
#### 2. Clone and set up environment files
```bat
git clone https://github.com/reconurge/flowsint.git
cd flowsint
copy .env.example .env
copy .env.example flowsint-api\.env
copy .env.example flowsint-core\.env
copy .env.example flowsint-app\.env
```
#### 3. Build and start
```bat
docker compose -f docker-compose.prod.yml up -d --build
```
### First login
Then go to [http://localhost:5173/register](http://localhost:5173/register) and create an account. There are no credentials or account by default.
@@ -132,12 +175,19 @@ flowsint-types (types)
### Run
Make sure you have **Make** installed.
**Linux / macOS** (requires **Make**):
```bash
make dev
```
**Windows** (cmd or PowerShell, no Make — create the `.env` files first, see [Get started](#windows)):
```bat
docker compose -f docker-compose.dev.yml up -d --build
docker compose -f docker-compose.dev.yml logs -f
```
### Development
The app is accessible at [http://localhost:5173](http://localhost:5173).
@@ -206,19 +256,19 @@ Each module has its own (incomplete) test suite:
```bash
# Test core module
cd flowsint-core
poetry run pytest
uv run pytest
# Test types module
cd ../flowsint-types
poetry run pytest
uv run pytest
# Test enrichers module
cd ../flowsint-enrichers
poetry run pytest
uv run pytest
# Test API module
cd ../flowsint-api
poetry run pytest
uv run pytest
```
## Contributing
@@ -250,3 +300,7 @@ It was created to assist:
Any misuse of this software is strictly prohibited and goes against the ethical principles defined in [ETHICS.md](./ETHICS.md).
## ❤️ Support
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20coffee-support-FFDD00?logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/dextmorgn)
[![Ko-fi](https://img.shields.io/badge/Ko--fi-support-F16061?logo=ko-fi&logoColor=white)](https://ko-fi.com/P5P01W3GPJ)

View File

@@ -1,7 +1,6 @@
name: flowsint-dev
services:
# PostgreSQL database
postgres:
image: postgres:15
container_name: flowsint-postgres-dev
@@ -22,7 +21,6 @@ services:
timeout: 5s
retries: 5
# Redis for Celery & cache
redis:
image: redis:alpine
container_name: flowsint-redis-dev
@@ -36,13 +34,12 @@ services:
timeout: 5s
retries: 5
# Neo4j graph database
neo4j:
image: neo4j:5
container_name: flowsint-neo4j-dev
ports:
- "7474:7474" # Web UI
- "7687:7687" # Bolt
- "7474:7474" # Web UI
- "7687:7687" # Bolt
environment:
- NEO4J_AUTH=${NEO4J_USERNAME}/${NEO4J_PASSWORD}
- NEO4J_PLUGINS=["apoc"]
@@ -63,7 +60,6 @@ services:
timeout: 5s
retries: 10
# FastAPI Backend
api:
build:
context: .
@@ -75,10 +71,11 @@ services:
- "5001:5001"
volumes:
- ./flowsint-api:/app/flowsint-api
- /app/flowsint-api/.venv
- ./flowsint-core:/app/flowsint-core
- ./flowsint-types:/app/flowsint-types
- ./flowsint-enrichers:/app/flowsint-enrichers
- /var/run/docker.sock:/var/run/docker.sock
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DATABASE_URL=postgresql://flowsint:flowsint@postgres:5432/flowsint
- NEO4J_URI_BOLT=bolt://neo4j:7687
@@ -102,7 +99,6 @@ services:
networks:
- flowsint_network
# Celery Worker
celery:
build:
context: .
@@ -113,10 +109,11 @@ services:
command: celery -A flowsint_core.core.celery worker --loglevel=info --pool=threads --concurrency=10
volumes:
- ./flowsint-api:/app/flowsint-api
- /app/flowsint-api/.venv
- ./flowsint-core:/app/flowsint-core
- ./flowsint-types:/app/flowsint-types
- ./flowsint-enrichers:/app/flowsint-enrichers
- /var/run/docker.sock:/var/run/docker.sock
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DATABASE_URL=postgresql://flowsint:flowsint@postgres:5432/flowsint
- NEO4J_URI_BOLT=bolt://neo4j:7687
@@ -125,6 +122,15 @@ services:
- MASTER_VAULT_KEY_V1=${MASTER_VAULT_KEY_V1}
- REDIS_URL=redis://redis:6379/0
- SKIP_MIGRATIONS=true
- AUTH_SECRET=${AUTH_SECRET}
healthcheck:
# Celery has no HTTP server — Dockerfile's curl-based healthcheck always fails.
# Use celery's own ping primitive instead.
test: ["CMD-SHELL", "celery -A flowsint_core.core.celery inspect ping -d celery@$$HOSTNAME || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
depends_on:
postgres:
condition: service_healthy
@@ -137,7 +143,6 @@ services:
networks:
- flowsint_network
# Frontend
app:
build:
context: ./flowsint-app

View File

@@ -1,9 +1,8 @@
name: flowsint-prod
services:
# PostgreSQL database
postgres:
image: postgres:15
image: postgres:15-alpine
container_name: flowsint-postgres-prod
restart: always
environment:
@@ -22,9 +21,8 @@ services:
timeout: 5s
retries: 5
# Redis for Celery & cache
redis:
image: redis:alpine
image: redis:7-alpine
container_name: flowsint-redis-prod
restart: always
ports:
@@ -37,14 +35,13 @@ services:
timeout: 5s
retries: 5
# Neo4j graph database
neo4j:
image: neo4j:5
container_name: flowsint-neo4j-prod
restart: always
ports:
- "7474:7474" # Web UI
- "7687:7687" # Bolt
- "7474:7474"
- "7687:7687"
environment:
- NEO4J_AUTH=${NEO4J_USERNAME}/${NEO4J_PASSWORD}
- NEO4J_PLUGINS=["apoc"]
@@ -64,18 +61,17 @@ services:
timeout: 5s
retries: 10
# FastAPI Backend (Production)
api:
build:
context: .
dockerfile: flowsint-api/Dockerfile
target: prod
target: production
container_name: flowsint-api-prod
restart: always
ports:
- "5001:5001"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER:-flowsint}:${POSTGRES_PASSWORD:-flowsint}@postgres:5432/${POSTGRES_DB:-flowsint}
- NEO4J_URI_BOLT=bolt://neo4j:7687
@@ -99,17 +95,25 @@ services:
networks:
- flowsint_network
# Celery Worker (Production)
celery:
build:
context: .
dockerfile: flowsint-api/Dockerfile
target: prod
target: production
container_name: flowsint-celery-prod
restart: always
command: celery -A flowsint_core.core.celery worker --loglevel=info --pool=threads --concurrency=10
command:
[
"celery",
"-A",
"flowsint_core.core.celery",
"worker",
"--loglevel=info",
"--pool=threads",
"--concurrency=10",
]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER:-flowsint}:${POSTGRES_PASSWORD:-flowsint}@postgres:5432/${POSTGRES_DB:-flowsint}
- NEO4J_URI_BOLT=bolt://neo4j:7687
@@ -118,6 +122,15 @@ services:
- MASTER_VAULT_KEY_V1=${MASTER_VAULT_KEY_V1}
- REDIS_URL=redis://redis:6379/0
- SKIP_MIGRATIONS=true
- AUTH_SECRET=${AUTH_SECRET}
healthcheck:
# Celery has no HTTP server — Dockerfile's curl-based healthcheck always fails.
# Use celery's own ping primitive instead.
test: ["CMD-SHELL", "celery -A flowsint_core.core.celery inspect ping -d celery@$$HOSTNAME || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
depends_on:
postgres:
condition: service_healthy
@@ -130,7 +143,6 @@ services:
networks:
- flowsint_network
# Frontend (Production)
app:
build:
context: ./flowsint-app
@@ -140,7 +152,7 @@ services:
container_name: flowsint-app-prod
restart: always
ports:
- "5173:5173"
- "5173:8080"
networks:
- flowsint_network
depends_on:

View File

@@ -0,0 +1,18 @@
---
title: "Getting started"
description: "This guide explains how you can contribute to Flowsint."
category: "Developers"
order: 7
author: "Flowsint Team"
tags: ["tutorial", "developers", "getting-started"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
## Contributing
Your contribution to Flowsint is highly encouraged !
You can create your own [Types](/docs/developers/managing-types), [Tools](/docs/developers/managing-tools), [Enrichers](/docs/developers/managing-enrichers) and Flows, and sharing them by proposing [pull requests](https://github.com/reconurge/flowsint/pulls).
Before diving in, you may also want to understand the [Graph format](/docs/developers/graph-format) to learn how nodes and edges are structured in the frontend visualization.

View File

@@ -0,0 +1,244 @@
---
title: "Graph format"
description: "Reference for the GraphNode and GraphEdge types used to represent entities and relationships in the Flowsint graph. Understanding these structures is essential for working with the frontend visualization and the graph database layer."
category: "Developers"
order: 11
author: "Flowsint Team"
tags: ["tutorial", "developers", "graph", "nodes", "edges"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
## Overview
Flowsint's graph visualization is built on two core data structures: **GraphNode** (representing entities) and **GraphEdge** (representing relationships between entities). These types are defined in `flowsint-app/src/types/graph.ts` and are used throughout the frontend for rendering, interaction, and data management.
Understanding these structures is important when:
- Building enrichers that create nodes and relationships
- Working on the frontend visualization
- Debugging graph rendering issues
- Extending the graph with new visual features
## GraphNode
A `GraphNode` represents a single entity in the graph (e.g., a domain, an IP address, a person). Every node created by an enricher's `create_node()` method ends up as a `GraphNode` in the frontend.
```typescript
type GraphNode = {
id: string
nodeType: string
nodeLabel: string
nodeProperties: NodeProperties
nodeSize: number
nodeColor: string | null
nodeIcon: keyof typeof LucideIcons | null
nodeImage: string | null
nodeFlag: flagColor | null
nodeShape: NodeShape | null
nodeMetadata: NodeMetadata
x: number
y: number
val?: number
neighbors?: any[]
links?: any[]
}
```
### Field reference
| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Unique identifier for the node. Derived from the type's primary field value. |
| `nodeType` | `string` | The entity type (e.g., `"Domain"`, `"Ip"`, `"Email"`). Maps to the Pydantic type class name. |
| `nodeLabel` | `string` | Human-readable label displayed on the node. Set by the type's `compute_label()` method. |
| `nodeProperties` | `NodeProperties` | All properties of the entity as key-value pairs. Contains the serialized fields from the Pydantic type. |
| `nodeSize` | `number` | Visual size of the node in the graph. |
| `nodeColor` | `string \| null` | Custom color override for the node (CSS color string), or `null` for default. |
| `nodeIcon` | `keyof LucideIcons \| null` | Icon to display on the node, from the [Lucide icon set](https://lucide.dev/icons/), or `null` for default. |
| `nodeImage` | `string \| null` | URL to an image to display on the node (e.g., a profile picture), or `null`. |
| `nodeFlag` | `flagColor \| null` | Color flag for visual tagging (see Flag colors below), or `null`. |
| `nodeShape` | `NodeShape \| null` | Shape of the node (see Node shapes below), or `null` for default circle. |
| `nodeMetadata` | `NodeMetadata` | Additional metadata as key-value pairs. Used for internal tracking and extended features. |
| `x` | `number` | X coordinate position in the graph canvas. |
| `y` | `number` | Y coordinate position in the graph canvas. |
| `val` | `number` (optional) | Value used by the force-graph engine for sizing calculations. |
| `neighbors` | `any[]` (optional) | Adjacent nodes, populated by the graph engine at runtime. |
| `links` | `any[]` (optional) | Connected edges, populated by the graph engine at runtime. |
### NodeProperties
```typescript
type NodeProperties = {
[key: string]: any
}
```
A flexible key-value object containing all the entity's properties. When a Pydantic type is serialized into a node, its fields become entries in `nodeProperties`. For example, a `Domain` node would have `nodeProperties.domain`, `nodeProperties.root`, etc.
### NodeMetadata
```typescript
type NodeMetadata = {
[key: string]: any
}
```
Similar to `nodeProperties` but used for system-level metadata rather than entity data. This can include information like creation timestamps, source enricher, or other tracking data.
### Node shapes
Nodes can be rendered in four shapes:
```typescript
type NodeShape = 'circle' | 'square' | 'hexagon' | 'triangle'
```
| Shape | Use case |
|-------|----------|
| `circle` | Default shape for most entity types |
| `square` | Often used for infrastructure entities |
| `hexagon` | Used for grouped or aggregate entities |
| `triangle` | Used for alert or warning-related entities |
### Flag colors
Flags provide a visual tagging system for nodes. Users can flag nodes to highlight them during an investigation.
```typescript
type flagColor = 'red' | 'orange' | 'blue' | 'green' | 'yellow'
```
Each flag color maps to specific Tailwind CSS classes for consistent styling:
| Flag | Style |
|------|-------|
| `red` | `text-red-400 fill-red-200` |
| `orange` | `text-orange-400 fill-orange-200` |
| `blue` | `text-blue-400 fill-blue-200` |
| `green` | `text-green-400 fill-green-200` |
| `yellow` | `text-yellow-400 fill-yellow-200` |
## GraphEdge
A `GraphEdge` represents a relationship between two nodes (e.g., "RESOLVES_TO", "HAS_SUBDOMAIN", "FOUND_IN_BREACH"). Every relationship created by an enricher's `create_relationship()` method ends up as a `GraphEdge` in the frontend.
```typescript
type GraphEdge = {
source: GraphNode['id']
target: GraphNode['id']
date?: string
id: string
label: string
caption?: string
type?: string
weight?: number
confidence_level?: number | string
}
```
### Field reference
| Field | Type | Description |
|-------|------|-------------|
| `source` | `string` | ID of the source node (the "from" node in the relationship). |
| `target` | `string` | ID of the target node (the "to" node in the relationship). |
| `id` | `string` | Unique identifier for this edge. |
| `label` | `string` | Display label for the edge (e.g., `"RESOLVES_TO"`, `"HAS_EMAIL"`). This is the relationship type string passed to `create_relationship()`. |
| `date` | `string` (optional) | Timestamp associated with the relationship (ISO 8601 format). |
| `caption` | `string` (optional) | Additional descriptive text displayed on or near the edge. |
| `type` | `string` (optional) | Relationship classification type for filtering or styling. |
| `weight` | `number` (optional) | Numerical weight of the relationship, can be used for visual thickness or importance ranking. |
| `confidence_level` | `number \| string` (optional) | Confidence score for the relationship, indicating how reliable the connection is. |
## How enrichers map to the graph
When you write an enricher and call graph methods in `postprocess()`, here's how the data flows:
### Creating nodes
```python
# In your enricher's postprocess method
self.create_node(domain) # domain is a Pydantic Domain object
```
The graph service automatically:
1. Extracts the **type name** from the Pydantic class (e.g., `"Domain"`) -> `nodeType`
2. Reads the **primary field** value (e.g., `domain.domain`) -> used for `id`
3. Reads the **nodeLabel** field (set by `compute_label()`) -> `nodeLabel`
4. Serializes all fields into `nodeProperties`
5. Sets defaults for visual properties (`nodeSize`, `nodeColor`, etc.)
### Creating relationships
```python
# In your enricher's postprocess method
self.create_relationship(domain, ip, "RESOLVES_TO")
```
The graph service automatically:
1. Identifies the **source node** from `domain`'s type and primary field -> `source`
2. Identifies the **target node** from `ip`'s type and primary field -> `target`
3. Uses the relationship label `"RESOLVES_TO"` -> `label`
4. Generates a unique `id` for the edge
### Relationship naming conventions
Relationship labels follow these conventions:
- Use **UPPER_SNAKE_CASE** (e.g., `"RESOLVES_TO"`, `"HAS_SUBDOMAIN"`)
- Use **verb phrases** that describe the relationship direction (source -> target)
- The default relationship label is `"IS_RELATED_TO"` if none is specified
- Common patterns:
- `HAS_*` for ownership/containment (e.g., `"HAS_EMAIL"`, `"HAS_SUBDOMAIN"`)
- `RESOLVES_TO` for DNS-type lookups
- `FOUND_IN_*` for breach/leak associations
- `REGISTERED_BY` for WHOIS data
## Graph settings
The graph visualization is configurable through settings types:
### ForceGraphSetting
Controls the physics simulation of the force-directed graph layout:
```typescript
type ForceGraphSetting = {
value: any
min?: number
max?: number
step?: number
type?: string
description?: string
}
```
### ExtendedSetting
General-purpose settings with richer type information:
```typescript
type ExtendedSetting = {
value: any
type: string
min?: number
max?: number
step?: number
options?: { value: string; label: string }[]
description?: string
}
```
These settings allow users to customize graph behavior like node repulsion, link distance, simulation speed, and visual preferences.
## Reserved properties
When creating nodes, certain property names are reserved by the system and should not be used as field names in your custom types:
- `id` - Node identifier
- `x`, `y` - Position coordinates
- `nodeLabel` - Display label (set by `compute_label()`)
- `label` - Legacy label field
- `nodeType`, `type` - Entity type identifiers
- `nodeImage`, `nodeIcon`, `nodeColor`, `nodeSize` - Visual properties
- `created_at` - Creation timestamp
- `sketch_id` - Investigation sketch association

View File

@@ -0,0 +1,715 @@
---
title: "Managing enrichers"
description: "Quick start guide to creating Enricher for your OSINT investigations."
category: "Developers"
order: 10
author: "Flowsint Team"
tags: ["tutorial", "developers", "creating-a-new-enricher"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
## Understanding Enrichers
Enrichers are the high-level business logic layer in Flowsint. While types define data structures and tools wrap external utilities, enrichers orchestrate the entire intelligence gathering workflow. An enricher takes input data of one type, processes it through various tools or APIs, validates and enriches the results, creates graph database nodes and relationships, and returns structured output.
Every enricher in Flowsint follows a two-phase execution model. The scan phase contains the core enriching logic where tools are executed, APIs are called, and data is gathered. Then, the postprocessing phase creates Neo4j graph nodes and relationships while returning the processed results. Input validation and normalization happens automatically through Pydantic type validation.
Enrichers differ from tools in several fundamental ways. Tools are low-level wrappers that return raw data without knowledge of the Flowsint ecosystem. Enrichers are high-level workflows that understand types, create graph nodes, handle parameters, and orchestrate multiple tools. When you want to add a new data source, you create a tool. When you want to add a new intelligence workflow, you create an enricher.
## Enricher architecture
The enricher system is built around an abstract base class that defines the interface and execution flow. Every enricher you create inherits from this base class and implements specific methods.
### The Enricher base class
The base class lives at `flowsint-core/src/flowsint_core/core/enricher_base.py` and provides the framework for all enrichers. Here's what a minimal enricher looks like:
```python
from typing import List
from flowsint_core.core.enricher_base import Enricher
from flowsint_enrichers.registry import flowsint_enricher
from flowsint_types import Domain, Ip
@flowsint_enricher
class MyEnricher(Enricher):
"""Description of what this enricher does."""
# Define input and output types as base types (not lists)
InputType = Domain
OutputType = Ip
@classmethod
def name(cls) -> str:
"""Unique identifier for this enricher."""
return "domain_to_ip"
@classmethod
def category(cls) -> str:
"""Category this enricher belongs to."""
return "Domain"
@classmethod
def key(cls) -> str:
"""Primary key field name for this enricher."""
return "domain"
async def scan(self, data: List[InputType]) -> List[OutputType]:
"""Core enriching logic."""
pass
def postprocess(self, results: List[OutputType], input_data: List[InputType]) -> List[OutputType]:
"""Create graph nodes and relationships."""
pass
# Export types for easy access
InputType = MyEnricher.InputType
OutputType = MyEnricher.OutputType
```
The `InputType` and `OutputType` class attributes define what data the enricher accepts and returns. These should be Pydantic types from the `flowsint-types` package defined as base types (e.g., `Domain`, not `List[Domain]`). The base class uses these type definitions to automatically generate JSON schemas for the API and handle validation automatically.
At the end of the file, you should export the types for easy access by other modules.
### The two phases
Understanding the two execution phases is crucial for writing effective enrichers.
**Scanning** is where the real work happens. This async method receives validated input data as a list of `InputType` instances and executes your intelligence gathering logic. You might instantiate tools, call external APIs, process results, and build up your output data. This phase should focus purely on gathering and processing data without worrying about the graph database. The base class automatically handles input validation through Pydantic before the scan phase begins.
**Postprocessing** creates the graph database structure. After scanning completes, this method receives both the results and the original input. It creates Neo4j nodes for each entity, establishes relationships between them, and returns the final results. This separation keeps graph logic separate from business logic.
## Creating a simple enricher
Let's walk through creating a complete enricher from scratch. We'll build an enricher that converts domains to IP addresses using DNS resolution.
### Setting up the file structure
Enrichers are organized by their input type. Create a new file in the appropriate directory under `flowsint-enrichers/src/flowsint_enrichers/`:
```bash
cd flowsint-enrichers/src/flowsint_enrichers/domain/
touch to_ip.py
```
If you're creating an enricher for a new input type, you may need to create a new directory first.
### Implementing the basic structure
Start with the imports and class definition:
```python
import socket
from typing import List
from flowsint_enrichers.registry import flowsint_enricher
from flowsint_core.core.enricher_base import Enricher
from flowsint_core.core.logger import Logger
from flowsint_types import Domain, Ip
@flowsint_enricher
class DomainToIpEnricher(Enricher):
"""Resolves domain names to their IP addresses using DNS."""
# Define types as base types (not lists)
InputType = Domain
OutputType = Ip
@classmethod
def name(cls) -> str:
return "domain_to_ip"
@classmethod
def category(cls) -> str:
return "Domain"
@classmethod
def key(cls) -> str:
return "domain"
@classmethod
def documentation(cls) -> str:
return """
This enricher resolves domain names to their IP addresses using
standard DNS queries. It accepts a list of domains and returns
the corresponding IP addresses.
"""
# Export types at the end of the file
InputType = DomainToIpEnricher.InputType
OutputType = DomainToIpEnricher.OutputType
```
The `name()` method returns a unique identifier for this enricher. Use lowercase with underscores, following the pattern `inputtype_to_outputtype`. The `category()` groups related enrichers together in the UI. The `key()` specifies which field serves as the primary identifier, typically matching the input type.
### Implementing the scan logic
The scan method contains your core intelligence gathering logic. It receives a list of validated `InputType` instances and returns a list of `OutputType` instances.
```python
async def scan(self, data: List[InputType]) -> List[OutputType]:
"""
Resolve each domain to its IP address.
Args:
data: List of Domain objects to resolve
Returns:
List of Ip objects
"""
results: List[OutputType] = []
for domain in data:
try:
# Perform DNS resolution
ip_address = socket.gethostbyname(domain.domain)
# Create IP object
ip = Ip(address=ip_address)
results.append(ip)
# Log successful resolution
Logger.info(
self.sketch_id,
{"message": f"Resolved {domain.domain} to {ip_address}"}
)
except socket.gaierror as e:
# DNS resolution failed
Logger.info(
self.sketch_id,
{"message": f"Failed to resolve {domain.domain}: {e}"}
)
continue
except Exception as e:
# Unexpected error
Logger.error(
self.sketch_id,
{"message": f"Error resolving {domain.domain}: {e}"}
)
continue
return results
```
This implementation iterates through each domain, performs DNS resolution, creates an IP object for successful resolutions, and logs both successes and failures. The error handling ensures that failures don't crash the entire enricher, which is important when processing many domains.
The input data has already been validated by Pydantic before reaching the scan method, so you can trust that all items are proper `Domain` objects.
### Implementing postprocessing
The postprocess method creates graph database nodes and relationships using the new simplified API:
```python
def postprocess(self, results: List[OutputType], input_data: List[InputType] = None) -> List[OutputType]:
"""
Create graph nodes and relationships.
Args:
results: IP objects from scan phase
input_data: Original Domain objects (preprocessed input)
Returns:
IP objects (unchanged)
"""
# Create nodes and relationships
for domain, ip in zip(input_data, results):
# Create nodes by passing Pydantic objects directly
self.create_node(domain)
self.create_node(ip)
# Create relationship by passing Pydantic objects directly
self.create_relationship(domain, ip, "RESOLVES_TO")
# Log the operation
self.log_graph_message(
f"IP found for domain {domain.domain} -> {ip.address}"
)
return results
```
You can pass Pydantic objects directly to `create_node()` and `create_relationship()`. The methods automatically infer the node types, primary keys, and property values from the Pydantic models.
The `create_node()` method accepts a Pydantic object and automatically creates a Neo4j node with the correct label and properties. The `create_relationship()` method takes two Pydantic objects and a relationship type string, inferring all necessary information from the objects.
## Creating an enricher with tools
Most enrichers use external tools for data gathering. Let's create an enricher that uses the Subfinder tool for subdomain enumeration.
### Importing the tool
Start by importing the tool along with your other dependencies:
```python
from typing import List
from flowsint_core.core.enricher_base import Enricher
from flowsint_enrichers.registry import flowsint_enricher
from flowsint_core.core.logger import Logger
from flowsint_types import Domain
from tools.network.subfinder import SubfinderTool
@flowsint_enricher
class SubdomainEnricher(Enricher):
"""Enumerates subdomains for given domains using Subfinder."""
# Define types as base types
InputType = Domain
OutputType = Domain
@classmethod
def name(cls) -> str:
return "domain_to_subdomains"
@classmethod
def category(cls) -> str:
return "Domain"
@classmethod
def key(cls) -> str:
return "domain"
# Export types
InputType = SubdomainEnricher.InputType
OutputType = SubdomainEnricher.OutputType
```
### Using the tool in scan
The scan method instantiates and uses the tool:
```python
async def scan(self, data: List[InputType]) -> List[OutputType]:
"""
Find subdomains using Subfinder tool.
Args:
data: List of Domain objects
Returns:
List of discovered subdomain Domain objects
"""
results: List[OutputType] = []
# Instantiate the tool
subfinder = SubfinderTool()
for domain in data:
Logger.info(
self.sketch_id,
{"message": f"Enumerating subdomains for {domain.domain}"}
)
try:
# Launch the tool
subdomains = subfinder.launch(domain.domain)
# Convert strings to Domain objects
for subdomain in subdomains:
results.append(Domain(domain=subdomain, root=False))
Logger.info(
self.sketch_id,
{"message": f"Found {len(subdomains)} subdomains for {domain.domain}"}
)
except Exception as e:
Logger.error(
self.sketch_id,
{"message": f"Error enumerating subdomains for {domain.domain}: {e}"}
)
continue
return results
```
Notice how the tool returns raw strings, and the enricher converts them into proper Domain objects. This separation of concerns keeps tools simple while enrichers handle type conversion.
### Creating graph nodes and relationships
The postprocess phase creates parent-child relationships between domains and subdomains:
```python
def postprocess(self, results: List[OutputType], input_data: List[InputType]) -> List[OutputType]:
"""
Create graph nodes and relationships for domains and subdomains.
Args:
results: Discovered subdomain Domain objects
input_data: Original parent Domain objects
Returns:
Subdomain Domain objects
"""
# Create nodes for parent domains
for domain in input_data:
self.create_node(domain)
# Create nodes for subdomains and relationships
for subdomain in results:
self.create_node(subdomain)
# Extract parent domain name and create relationship
parent_domain_name = self._extract_parent_domain(subdomain.domain)
parent_domain = Domain(domain=parent_domain_name)
# Create relationship using Pydantic objects
self.create_relationship(parent_domain, subdomain, "HAS_SUBDOMAIN")
# Log the operation
self.log_graph_message(
f"Subdomain found: {parent_domain_name} -> {subdomain.domain}"
)
return results
def _extract_parent_domain(self, subdomain: str) -> str:
"""Extract parent domain from subdomain."""
parts = subdomain.split('.')
if len(parts) >= 2:
return '.'.join(parts[-2:])
return subdomain
```
## Adding parameters to enrichers
Many enrichers need user-configurable parameters. Let's create an enricher that scans ports with configurable options.
### Defining the parameter schema
The `get_params_schema()` class method defines what parameters your enricher accepts:
```python
from typing import List, Dict, Any, Optional
from flowsint_enrichers.registry import flowsint_enricher
from flowsint_core.core.enricher_base import Enricher
from flowsint_types import Ip, Port
from tools.network.naabu import NaabuTool
@flowsint_enricher
class IpToPortsEnricher(Enricher):
"""Scans IP addresses for open ports."""
# Define types as base types
InputType = Ip
OutputType = Port
@classmethod
def name(cls) -> str:
return "ip_to_ports"
@classmethod
def category(cls) -> str:
return "IP"
@classmethod
def key(cls) -> str:
return "address"
@classmethod
def get_params_schema(cls) -> List[Dict[str, Any]]:
"""Define configurable parameters for this enricher."""
return [
{
"name": "mode",
"type": "select",
"description": "Scan mode: active scanning or passive enumeration",
"required": True,
"default": "passive",
"options": [
{"label": "Passive", "value": "passive"},
{"label": "Active", "value": "active"},
],
},
{
"name": "port_range",
"type": "string",
"description": "Port range to scan (e.g., '1-1000' or '80,443,8080')",
"required": False,
},
{
"name": "top_ports",
"type": "select",
"description": "Scan only the most common ports",
"required": False,
"options": [
{"label": "Top 100", "value": "100"},
{"label": "Top 1000", "value": "1000"},
],
},
{
"name": "PDCP_API_KEY",
"type": "vaultSecret",
"description": "ProjectDiscovery Cloud Platform API key for passive mode",
"required": False,
},
]
# Export types
InputType = IpToPortsEnricher.InputType
OutputType = IpToPortsEnricher.OutputType
```
The parameter schema defines the type, description, whether it's required, default values, and for select parameters, the available options. The `vaultSecret` type integrates with Flowsint's encrypted credential storage.
### Using parameters in your enricher
Parameters are accessed through `self.params` in your scan method:
```python
async def scan(self, data: List[InputType]) -> List[OutputType]:
"""
Scan IPs for open ports using configured parameters.
Args:
data: List of Ip objects to scan
Returns:
List of Port objects
"""
results: List[OutputType] = []
# Extract parameters
mode = self.params.get("mode", "passive")
port_range = self.params.get("port_range")
top_ports = self.params.get("top_ports")
api_key = self.get_secret("PDCP_API_KEY")
# Instantiate tool
naabu = NaabuTool()
for ip in data:
Logger.info(
self.sketch_id,
{"message": f"Scanning {ip.address} in {mode} mode"}
)
try:
# Launch tool with parameters
scan_results = naabu.launch(
target=ip.address,
mode=mode,
port_range=port_range,
top_ports=top_ports,
api_key=api_key
)
# Convert tool results to Port objects
for result in scan_results:
port = Port(
number=result.get("port"),
protocol=result.get("protocol", "tcp").upper(),
state="open",
service=result.get("service"),
banner=result.get("version")
)
results.append(port)
except Exception as e:
Logger.error(
self.sketch_id,
{"message": f"Error scanning {ip.address}: {e}"}
)
continue
return results
```
## Handling multiple output types
Some enrichers produce multiple types of results. You can define a custom return type using Pydantic:
```python
from pydantic import BaseModel
from flowsint_enrichers.registry import flowsint_enricher
from flowsint_core.core.enricher_base import Enricher
from typing import List
from flowsint_types import Website, Email, Phone
class CrawlerResults(BaseModel):
"""Results from web crawler including multiple entity types."""
website: Website
emails: List[Email] = []
phones: List[Phone] = []
@flowsint_enricher
class WebsiteToCrawlerEnricher(Enricher):
"""Crawls websites to extract emails and phone numbers."""
# Define types as base types
InputType = Website
OutputType = CrawlerResults
async def scan(self, data: List[InputType]) -> List[OutputType]:
"""Crawl websites and extract contact information."""
from tools.network.reconcrawl import ReconCrawlTool
results: List[OutputType] = []
crawler_tool = ReconCrawlTool()
for website in data:
try:
# Launch crawler
crawl_data = crawler_tool.launch(website.url)
# Extract entities
emails = [Email(email=e) for e in crawl_data.get("emails", [])]
phones = [Phone(number=p) for p in crawl_data.get("phones", [])]
# Create result object
result = CrawlerResults(
website=website,
emails=emails,
phones=phones
)
results.append(result)
except Exception as e:
Logger.error(self.sketch_id, {"message": f"Crawl error: {e}"})
return results
def postprocess(self, results: List[OutputType], input_data: List[InputType]) -> List[OutputType]:
"""Create nodes for all discovered entities."""
for result in results:
# Create website node using Pydantic object
self.create_node(result.website)
# Create email nodes and relationships
for email in result.emails:
self.create_node(email)
self.create_relationship(result.website, email, "HAS_EMAIL")
# Create phone nodes and relationships
for phone in result.phones:
self.create_node(phone)
self.create_relationship(result.website, phone, "HAS_PHONE")
self.log_graph_message(
f"Processed {len(result.emails)} emails and {len(result.phones)} phones from {result.website.url}"
)
return results
# Export types
InputType = WebsiteToCrawlerEnricher.InputType
OutputType = WebsiteToCrawlerEnricher.OutputType
```
## Registering your enricher
You don't need to register your enricher anywhere, adding the decorator `@flowsint_enricher` to your enricher class triggers the auto discovery.
```python
from flowsint_enrichers.registry import flowsint_enricher
from flowsint_core.core.enricher_base import Enricher
@flowsint_enricher
class MyEnricher(Enricher):
...
```
## Testing your enricher
Creating tests helps ensure your enricher works correctly and makes debugging easier. Create a test file in `flowsint-enrichers/tests/`:
```python
# tests/test_domain_to_ip.py
import pytest
from flowsint_enrichers.domain.to_ip import DomainToIpEnricher
from flowsint_types import Domain, Ip
@pytest.mark.asyncio
async def test_enricher_metadata():
"""Test enricher metadata is correctly defined."""
assert DomainToIpEnricher.name() == "domain_to_ip"
assert DomainToIpEnricher.category() == "Domain"
assert DomainToIpEnricher.key() == "domain"
@pytest.mark.asyncio
async def test_type_definitions():
"""Test InputType and OutputType are correctly defined."""
assert DomainToIpEnricher.InputType == Domain
assert DomainToIpEnricher.OutputType == Ip
@pytest.mark.asyncio
async def test_scan():
"""Test DNS resolution works."""
enricher = DomainToIpEnricher(sketch_id="test", scan_id="test")
input_data = [Domain(domain="example.com")]
results = await enricher.scan(input_data)
assert len(results) > 0
assert isinstance(results[0], Ip)
assert results[0].address # Should have an IP address
```
These tests verify that your enricher's metadata is correct, type definitions are properly set, and the scan logic produces expected results. Input validation is handled automatically by Pydantic, so you don't need to test preprocessing separately.
## Best practices
When creating enrichers, think carefully about error handling. Intelligence gathering involves many external systems that can fail in unpredictable ways. Your enricher should handle errors gracefully, log failures clearly, and continue processing remaining items rather than crashing entirely.
Always use the Logger utility for tracking progress and errors. The logger integrates with Flowsint's monitoring system and helps users understand what's happening during long-running enrichers. Log successful operations at the info level and errors at the error level.
Define InputType and OutputType as base types (e.g., `Domain`, not `List[Domain]`). The base class automatically handles the list wrapping and validation. This makes the type definitions cleaner and more intuitive.
Always export your types at the end of the file using:
```python
InputType = YourEnricher.InputType
OutputType = YourEnricher.OutputType
```
Use the simplified graph API by passing Pydantic objects directly to `create_node()` and `create_relationship()`. This eliminates boilerplate code and reduces errors. The methods automatically infer node types, primary keys, and properties from your Pydantic models.
Separate concerns between the two phases. Scanning should focus on gathering and processing data. Postprocessing should create graph structures. Input validation happens automatically through Pydantic, so you don't need to handle it manually.
Use type hints everywhere. They provide automatic validation, better IDE support, and serve as inline documentation. The InputType and OutputType class attributes should always be Pydantic types from the flowsint-types package.
When working with tools, remember that they return raw data structures. Your enricher is responsible for converting tool output into proper Flowsint types. This type conversion is typically done in the scan phase.
Document your enricher thoroughly. The class docstring, documentation method, and parameter descriptions all appear in the UI. Clear documentation helps users understand what your enricher does and how to configure it.
### Handling API Rate Limits
When working with rate-limited APIs, add delays between requests:
```python
async def scan(self, data: InputType) -> OutputType:
"""Scan with rate limiting to respect API limits."""
import asyncio
results = []
delay_seconds = 1 # Delay between requests
for item in data:
result = await self._query_api(item)
if result:
results.append(result)
# Respect rate limits
await asyncio.sleep(delay_seconds)
return results
```
### Fallback data sources
Implement fallback logic when primary sources fail:
```python
async def scan(self, data: InputType) -> OutputType:
"""Try multiple data sources with fallback logic."""
results = []
for domain in data:
# Try primary source
result = self._query_primary_source(domain)
if not result:
# Fall back to secondary source
Logger.info(
self.sketch_id,
{"message": f"Primary source failed for {domain}, trying fallback"}
)
result = self._query_fallback_source(domain)
if result:
results.append(result)
return results
```
## Troubleshooting
If your enricher doesn't appear in the API, verify that you've applied the `@flowsint_enricher` decorator to your class, that the file lives under `flowsint-enrichers/src/flowsint_enrichers/`, and that you've restarted the API server. Auto-discovery via `load_all_enrichers()` runs at startup, so file additions require a restart.
For import errors, make sure all your dependencies are installed and the enricher file has no syntax errors. Check that you're importing from the correct packages.
If the scan method isn't finding any results, add logging statements to debug what's happening. Verify that tools are installed and accessible, API keys are valid if required, and input data is in the expected format.
When graph relationships aren't appearing, check that you're creating both nodes and relationships in the postprocess method. Verify that the relationship type name is correct and that you're passing the right node objects to `create_relationship()`.
## Next steps
Once you've created and registered your enricher, it becomes available through the API for users to run. Enrichers can be chained together into Flows where the output of one enricher feeds into the input of another. This enables complex intelligence gathering sequences.
Remember that enrichers are the heart of Flowsint's intelligence gathering capabilities. Well-designed enrichers that handle errors gracefully, log progress clearly, and create meaningful graph relationships make the entire platform more powerful and user-friendly.
If you create new enrichers that you think can help the community, it's highly appreciated that you open-source them. Help the community as much as it helps you !

View File

@@ -0,0 +1,512 @@
---
title: "Managing tools"
description: "This guide explains how to create a new tool in the Flowsint ecosystem. Tools are low-level wrappers around external utilities, Docker containers, and APIs that enrichers use to gather intelligence. Understanding the tool architecture will help you extend Flowsint's capabilities with new data sources and reconnaissance utilities."
category: "Developers"
order: 9
author: "Flowsint Team"
tags: ["tutorial", "developers", "creating-a-new-tool"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
## Understanding tools
Tools in Flowsint serve as abstraction layers between enrichers and external systems. They provide a consistent interface for executing Docker containers, calling APIs, or running python libraries. While enrichers handle high-level orchestration and graph database operations, tools focus exclusively on executing external commands and returning raw results.
Every tool implements a basic interface with methods for naming, categorization, and execution. Tools don't know anything about Pydantic types, Neo4j graphs, or the broader Flowsint architecture. They just wrap external functionality and return data.
Flowsint currently includes tools for subdomain enumeration, port scanning, DNS queries, WHOIS lookups, web crawling, and business intelligence.
## Tool architecture
The tool system has a two-tier inheritance structure. At the base level, you have the abstract `Tool` class that defines the interface every tool must implement. For tools that run in Docker containers, there's an intermediate `DockerTool` class that handles all the container lifecycle management.
### The Tool base class
Every tool inherits from the abstract `Tool` class, which lives at `flowsint-enrichers/src/tools/base.py`. Here's what it looks like:
```python
from abc import ABC, abstractmethod
from typing import Any
class Tool(ABC):
"""Abstract base class for all tools."""
@classmethod
@abstractmethod
def name(cls) -> str:
"""Return the tool name."""
pass
@classmethod
@abstractmethod
def category(cls) -> str:
"""Return the tool category."""
pass
@classmethod
@abstractmethod
def description(cls) -> str:
"""Return a description of what the tool does."""
pass
@classmethod
@abstractmethod
def version(cls) -> str:
"""Return the tool version."""
pass
@abstractmethod
def launch(self, value: str, *args, **kwargs) -> Any:
"""Execute the tool and return results."""
pass
```
Any tool you create must implement these five methods. The first four are class methods that provide metadata about the tool. The `launch` method is where the actual work happens.
### The DockerTool class
Most security and reconnaissance tools run in Docker containers for isolation and portability. The `DockerTool` class at `flowsint-enrichers/src/tools/dockertool.py` provides all the infrastructure for running containerized tools.
When you inherit from `DockerTool`, you get automatic image management, container execution, volume mounting, environment variable handling, and cleanup. You just specify the Docker image name and implement how to construct the command.
Here's a simplified view of what `DockerTool` provides:
```python
class DockerTool(Tool):
"""Base class for tools that run in Docker containers."""
def __init__(self, image: str, default_tag: str = "latest"):
"""Initialize with Docker image information."""
self.image = image
self.default_tag = default_tag
self.docker_client = docker.from_env()
def install(self) -> None:
"""Pull the Docker image if not already present."""
# Pulls image from Docker Hub
pass
def is_installed(self) -> bool:
"""Check if the Docker image exists locally."""
# Checks local images
pass
def launch(self, command: str, volumes: dict = None,
timeout: int = 30, environment: dict = None) -> Any:
"""Run a command in the Docker container."""
# Executes container and returns output
pass
```
The `launch` method in `DockerTool` handles container execution. It sets up the environment, mounts volumes if needed, runs the container, captures output, and cleans up afterward.
## Creating a simple API-based tool
Let's start with the simpler case of creating a tool that calls an external API. We'll create a hypothetical tool for querying a threat intelligence service.
### File structure
Create a new python file in the appropriate category directory under `flowsint-enrichers/src/tools/`. For a security-related tool, you might use `tools/security/`:
```bash
cd flowsint-enrichers/src/tools/security/
touch threat_intel.py
```
If the category directory doesn't exist, create it first and add an `__init__.py` file to make it a python package.
### Basic implementation
Here's a complete example of an API-based tool:
```python
from tools.base import Tool
from typing import Any, Dict, List, Optional
import requests
class ThreatIntelTool(Tool):
"""Query threat intelligence data from an external API."""
api_endpoint = "https://api.threatintel.example.com/v1"
@classmethod
def name(cls) -> str:
"""Return the tool name."""
return "threatintel"
@classmethod
def category(cls) -> str:
"""Return the category this tool belongs to."""
return "Threat Intelligence"
@classmethod
def description(cls) -> str:
"""Return a description of what this tool does."""
return "Queries threat intelligence data for IPs, domains, and hashes"
@classmethod
def version(cls) -> str:
"""Return the tool version."""
return "1.0.0"
def launch(
self,
indicator: str,
indicator_type: str = "ip",
api_key: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
Query the threat intelligence API.
Args:
indicator: The indicator to query (IP, domain, hash, etc.)
indicator_type: Type of indicator (ip, domain, hash)
api_key: API key for authentication
Returns:
List of threat intelligence records
"""
if not api_key:
raise ValueError("API key is required")
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
params = {
"indicator": indicator,
"type": indicator_type
}
try:
response = requests.get(
f"{self.api_endpoint}/query",
headers=headers,
params=params,
timeout=30
)
response.raise_for_status()
return response.json().get("results", [])
except requests.exceptions.RequestException as e:
print(f"Error querying threat intel API: {e}")
return []
```
This tool follows a straightforward pattern. The class methods provide metadata that enrichers and the registry use. The `launch` method implements the actual API interaction, handling authentication, making the request, and returning structured data.
Notice how the tool returns simple python data structures like lists and dictionaries. Tools don't know about Pydantic types or Flowsint models. That's the enricher's job.
## Creating a docker-based tool
Docker-based tools are more common in Flowsint because most reconnaissance utilities need specific dependencies and isolated environments. Let's walk through creating a tool that wraps a hypothetical Docker-based subdomain scanner.
### Setting up the class
Start by inheriting from `DockerTool` and providing the Docker image information:
```python
from tools.dockertool import DockerTool
from typing import List, Optional, Any
class MySubdomainTool(DockerTool):
"""Wrapper for a Docker-based subdomain enumeration tool."""
image = "org/subdomain-scanner"
default_tag = "latest"
def __init__(self):
"""Initialize the tool with Docker image information."""
super().__init__(self.image, self.default_tag)
```
The `image` and `default_tag` class attributes tell `DockerTool` which Docker image to use. When you instantiate the tool, it will automatically connect to the Docker daemon.
### Implementing the launch method
The `launch` method needs to construct the command that runs inside the container and handle the results:
```python
def launch(
self,
domain: str,
timeout: int = 300,
wordlist: Optional[str] = None
) -> List[str]:
"""
Enumerate subdomains for a given domain.
Args:
domain: Target domain to enumerate
timeout: Maximum execution time in seconds
wordlist: Optional path to custom wordlist file
Returns:
List of discovered subdomain strings
"""
# Ensure the Docker image is available
if not self.is_installed():
self.install()
# Build the command that runs inside the container
command = f"-d {domain}"
if wordlist:
command += f" -w {wordlist}"
# Add JSON output flag for easier parsing
command += " -json"
# Execute the container
try:
result = super().launch(
command=command,
timeout=timeout
)
# Parse the output
subdomains = self._parse_output(result)
return subdomains
except Exception as e:
print(f"Error running subdomain scanner: {e}")
return []
def _parse_output(self, output: str) -> List[str]:
"""Parse the tool output and extract subdomains."""
import json
subdomains = []
for line in output.strip().split('\n'):
if not line:
continue
try:
data = json.loads(line)
if 'subdomain' in data:
subdomains.append(data['subdomain'])
except json.JSONDecodeError:
continue
return list(set(subdomains)) # Remove duplicates
```
This implementation shows several important patterns. First, it checks if the Docker image is installed and pulls it if necessary. Second, it constructs the command string that will run inside the container. Third, it calls the parent class's `launch` method to handle the actual container execution. Finally, it parses the output into a clean python data structure.
### Handling volumes
Some tools need access to files on the host system. You can mount volumes when calling the parent's `launch` method:
```python
def launch(self, domain: str, wordlist_path: str = None) -> List[str]:
"""Run the tool with optional wordlist file."""
command = f"-d {domain}"
volumes = None
if wordlist_path:
# Mount the wordlist file into the container
volumes = {
wordlist_path: {
'bind': '/wordlist.txt',
'mode': 'ro' # read-only
}
}
command += " -w /wordlist.txt"
result = super().launch(
command=command,
volumes=volumes
)
return self._parse_output(result)
```
The volumes dictionary maps host paths to container paths. You can specify the mount mode as 'ro' for read-only or 'rw' for read-write.
### Using environment variables
For tools that need API keys or configuration through environment variables:
```python
def launch(self, domain: str, api_key: Optional[str] = None) -> List[str]:
"""Run the tool with optional API key for enhanced scanning."""
command = f"-d {domain}"
environment = {}
if api_key:
environment['API_KEY'] = api_key
result = super().launch(
command=command,
environment=environment
)
return self._parse_output(result)
```
## Testing your tool
Creating tests for your tool helps ensure it works correctly and makes it easier to catch regressions. Create a test file in `flowsint-enrichers/tests/tools/` that mirrors your tool's location:
```python
# tests/tools/security/test_threat_intel.py
from tools.security.threat_intel import ThreatIntelTool
import pytest
def test_tool_metadata():
"""Test that tool metadata is correctly defined."""
assert ThreatIntelTool.name() == "threatintel"
assert ThreatIntelTool.category() == "Threat Intelligence"
assert "threat" in ThreatIntelTool.description().lower()
def test_tool_launch_requires_api_key():
"""Test that launch method requires an API key."""
tool = ThreatIntelTool()
with pytest.raises(ValueError):
tool.launch("192.0.2.1")
def test_tool_launch_with_api_key(monkeypatch):
"""Test successful API query with mocked response."""
tool = ThreatIntelTool()
# Mock the requests.get call
def mock_get(*args, **kwargs):
class MockResponse:
def raise_for_status(self):
pass
def json(self):
return {"results": [{"indicator": "192.0.2.1", "threat_level": "high"}]}
return MockResponse()
monkeypatch.setattr("requests.get", mock_get)
results = tool.launch("192.0.2.1", api_key="test_key")
assert len(results) == 1
assert results[0]["indicator"] == "192.0.2.1"
```
For docker-based tools, your tests need Docker to be running:
```python
# tests/tools/network/test_my_subdomain_tool.py
from tools.network.my_subdomain_tool import MySubdomainTool
import pytest
@pytest.mark.docker
def test_tool_install():
"""Test that the Docker image can be pulled."""
tool = MySubdomainTool()
tool.install()
assert tool.is_installed()
@pytest.mark.docker
def test_tool_launch():
"""Test running the tool against a domain."""
tool = MySubdomainTool()
results = tool.launch("example.com")
assert isinstance(results, list)
```
The `@pytest.mark.docker` decorator helps you separate tests that require Docker from those that don't.
## Best practices
When creating tools, focus on simplicity and single responsibility. Each tool should wrap exactly one external utility or API. Don't try to combine multiple data sources in a single tool. That's what enrichers are for.
Always handle errors gracefully. Network requests fail, Docker containers crash, and APIs return unexpected data. Your tool should catch these errors, log them appropriately, and return empty results or raise clear exceptions rather than crashing.
Return simple data structures from the `launch` method. Use lists, dictionaries, strings, and numbers. Don't return Pydantic models or other complex objects. Remember that tools are low-level utilities that enrichers build upon.
For Docker tools, always check if the image is installed before running it. The pattern of checking `is_installed()` and calling `install()` if necessary ensures the tool works even on fresh installations.
When parsing tool output, be defensive. External tools can return unexpected formats, partial results, or garbage data. Validate and clean the output before returning it. Use try-except blocks around parsing logic.
Document your tool thoroughly. The docstrings and parameter descriptions help other developers understand how to use your tool. Future enrichers will rely on this documentation.
## Integrating your tool
Unlike types, tools don't need to be explicitly registered in a central registry. Enrichers import and use them directly. When you create an enricher that uses your new tool, you simply import it:
```python
# In an enricher file
from tools.security.threat_intel import ThreatIntelTool
class IpToThreatIntelEnricher(Enricher):
async def scan(self, data: List[Ip]) -> List[ThreatReport]:
tool = ThreatIntelTool()
results = []
for ip in data:
intel = tool.launch(
indicator=ip.address,
indicator_type="ip",
api_key=api_key
)
# Process results...
return results
```
The enricher instantiates your tool, calls its `launch` method with appropriate parameters, and processes the results into Flowsint types.
## Common patterns
Several patterns appear frequently in Flowsint tools. Understanding these will help you write tools that fit naturally into the ecosystem.
### The install-check pattern
Most Docker tools follow this pattern at the start of `launch`:
```python
def launch(self, ...):
if not self.is_installed():
self.install()
# Continue with execution
```
This ensures the docker image is available before trying to run it.
### The command builder pattern
Complex tools often build commands incrementally based on parameters:
```python
def launch(self, target: str, mode: str = "fast", verbose: bool = False):
command = f"-target {target}"
if mode == "thorough":
command += " --thorough"
if verbose:
command += " -v"
result = super().launch(command)
```
### The output parser pattern
Many tools separate execution from parsing:
```python
def launch(self, ...):
raw_output = super().launch(command)
return self._parse_output(raw_output)
def _parse_output(self, output: str) -> List[Dict]:
"""Parse raw tool output into structured data."""
# Parsing logic here
```
This separation makes the code easier to test and maintain.
## Next steps
Once you've created your tool and tested it, you can build enrichers that use it. Enrichers orchestrate one or more tools to gather intelligence, validate the results, convert them to Flowsint types, and create graph database nodes and relationships.
If your tool requires API keys or other secrets, enrichers can access them through the vault system. When you implement an enricher that uses your tool, you can define parameters of type `vaultSecret` that pull credentials from the user's encrypted vault.
Remember that tools are just one layer in the Flowsint architecture. They provide the raw capabilities, but enrichers provide the intelligence and graph-building logic that makes the platform powerful.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
---
title: "Enrichers"
description: "Quick start guide to using Enrichers for your OSINT investigations."
category: "Getting started"
order: 4
author: "Flowsint Team"
tags: ["tutorial", "getting-started", "enrichers"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
### What is an Enricher?
An enricher is an operation that, starting from an input element A (source entity), produces one or more elements B (target entities) by applying a search or correlation method called a pivot.
Example:
```bash
A = my.domain.com (domain name)
p = “DNS resolution” (pivot)
B = 12.23.34.45 (IP address)
```
That said, a pivot is the method or technical process used to derive B from A. The pivot defines how the transformation obtains its result (e.g., DNS resolution, WHOIS lookup, API query, etc.).
Example:
```bash
DNS Resolution → domain → IP
WHOIS Lookup → IP → owner
Reverse Image Search → image → web pages containing that image
```
Flowsint comes with a bunch of prebuilt enrichers, divided into multiple categories. Those enrichers can use standard pivots that your machine can support by default (DNS resolution, WHOIS request, etc.) and some other that depend on external tools.
Those can be :
- Native : DNS resolutions, Whois, etc.
- Docker tools: [subfinder](https://github.com/projectdiscovery/subfinder), [asnmap](https://github.com/projectdiscovery/asnmap), etc.
- Python tools: [sherlock](https://github.com/sherlock-project/sherlock), [maigret](https://github.com/soxoj/maigret), [reconurge/recontrack](https://github.com/reconurge/recontrack), [reconurge/reconcrawl](https://github.com/reconurge/reconcrawl), [reconurge/reconspread](https://github.com/reconurge/reconspread), etc.
- External services (paid or free): [shodan](https://www.shodan.io/), [whoxy](https://www.whoxy.com/), [whoisxmlapi](https://www.whoisxmlapi.com), etc.
### Making your own enrichers
Creating your own enrichers invloves multiple steps, but is not that trivial.
If you plan on writting your own enrichers and think they could help the community, please contribute by making a pull request !
Please refer to [this section](/docs/developers/managing-enrichers) to start building your own enrichers.

View File

@@ -0,0 +1,159 @@
---
title: "Flows"
description: "Quick start guide to using Flows for your OSINT investigations."
category: "Getting started"
order: 5
author: "Flowsint Team"
tags: ["tutorial", "getting-started", "flows"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
### What are Flows?
Flows are the chaining of multiple enrichers, where the output of one becomes the input of the next, allowing an investigation to be broadened or deepened.
Some enrichers can be chained together, to that they can provide a reproductible and scalable research flow that can be re-applied to other entities of the same type.
### Getting started with Flows
The best way to get started with flows is to go to [http://localhost:5173/dashboard/flows](http://localhost:5173/dashboard/flows) and create a new flow.
From that, you can start building your first flow; a flow consists of **one** input type, and multiple chained enrichers.
Start by drag & dropping your input type to the canva and start using the "**+**" button to add more enrichers to your flow.
Once you're satisfied with the flow, you can start viewing the execution order by pressing the "**compute**" button.
Make sure you give it a descriptive name and save ! (`crtl + s` or presse the save button).
Once it's done, go back to one of your sketches, right click on an item of the same type of the flow, and you should be able to launch it from the "flows" section.
### Flow schema
Every time a flow is launched, a log file is created at `flowsint_core/enricher_logs`.
```json
{
"sketch_id": "6aa808e4-1360-4c4a-b94f-4bed6c914836",
"scan_id": "52ba38a2-3f2c-4c8b-a7a5-42656f8fb845",
"created_at": "2025-10-26T15:57:52.243549",
"updated_at": "2025-10-26T15:57:52.424052",
"status": "completed",
"enricher_branches": [
{
"id": "branch-0",
"name": "Main Flow",
"steps": [
{
"nodeId": "Domain-1761469976169",
"params": {},
"type": "type",
"inputs": {},
"outputs": {
"domain": [
"example.com"
]
},
"status": "pending",
"branchId": "branch-0",
"depth": 0
},
{
"nodeId": "domain_to_ip-1761469978018",
"params": {},
"type": "enricher",
"inputs": {
"Domain": null
},
"outputs": {
"address": "example.com",
"latitude": "example.com",
"longitude": "example.com",
"country": "example.com",
"city": "example.com",
"isp": "example.com"
},
"status": "pending",
"branchId": "branch-0",
"depth": 1
}
]
}
],
"execution_log": [
{
"step_id": "branch-0_domain_to_ip-1761469978018",
"branch_id": "branch-0",
"branch_name": "Main Flow",
"node_id": "domain_to_ip-1761469978018",
"enricher_name": "domain_to_ip",
"inputs": [
"example.com"
],
"outputs": [
{
"address": "12.34.56.78",
"latitude": null,
"longitude": null,
"country": null,
"city": null,
"isp": null
}
],
"status": "completed",
"error": null,
"timestamp": "2025-10-26T15:57:52.274147",
"execution_time_ms": 144,
"cache_hit": false
}
],
"summary": {
"total_steps": 1,
"completed_steps": 1,
"failed_steps": 0,
"total_execution_time_ms": 144
},
"final_results": {
"initial_values": [
"example.com"
],
"branches": [
{
"id": "branch-0",
"name": "Main Flow",
"steps": [
{
"nodeId": "domain_to_ip-1761469978018",
"enricher": "domain_to_ip",
"status": "completed",
"outputs": [
{
"address": "12.34.56.78",
"latitude": null,
"longitude": null,
"country": null,
"city": null,
"isp": null
}
]
}
]
}
],
"results": {
"domain_to_ip-1761469978018": [
{
"address": "12.34.56.78",
"latitude": null,
"longitude": null,
"country": null,
"city": null,
"isp": null
}
]
},
"reference_mapping": {}
}
}
```

View File

@@ -0,0 +1,42 @@
---
title: "Installation"
description: "Quick start guide to using Flowsint for your OSINT investigations."
category: "Getting started"
order: 3
author: "Flowsint Team"
tags: ["tutorial", "quickstart", "installation"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
### Prerequisites
Before installing Flowsint, ensure you have the following installed on your system:
- **Docker** and **Docker Compose**
- **Make** (for build automation)
- **Git**
### Installation
Clone the repo and run the start command.
```bash
git clone https://github.com/reconurge/flowsint.git
cd flowsint
make prod
```
Some enrichers require API keys. Check out [this section](/docs/getting-started/enrichers#api-keys) to learn more.
The application should automatically open at http://localhost:5173.
### Create your first investigation
Start by logging in or registering from the home page. From your dashboard, create a new investigation by clicking "New investigation." Add your first entity—such as a domain name—to the canvas. Right-click the entity to open the context menu and run an enricher. As results appear, explore the newly discovered entities and relationships directly in the graph.
### Running your first enricher
You could start by discovering subdomains for a domain for example.
Add a domain entity like `example.com` to your investigation, then right-click the domain node and select the "domain_to_subdomains" enricher. Wait for the enricher to complete; newly discovered subdomains will be added to your graph for you to review.

View File

@@ -0,0 +1,58 @@
---
title: "Vault"
description: "Quick start guide to using the Vault to secure your services API keys and secrets."
category: "Getting started"
order: 6
author: "Flowsint Team"
tags: ["tutorial", "getting-started", "vault"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
## What is the Vault
A good amount of the tools you'll be using in Flowsint require third party API keys.
The **Vault** (*"Coffre fort"* in french) is the place to centralize and securely store those API keys.
Weither you have a local instance of Flowsint or one fully deployed on a distributed system, you need to have your keys securely stored.
## Adding a key
In the Flowsint enricher ecosystem, the API keys follow a specific format, being in uppercase letters, and with a declarative name that follows `<service>_API_KEY`.
## Current limitations
For now, we cannot match a particular key from the Vault to an enricher **directly from the UI**. The enricher declares the API key variable name it requires, like the following in the core of the Enricher:
```python
@classmethod
def get_params_schema(cls) -> List[Dict[str, Any]]:
"""Declare required parameters for this enricher"""
return [
{
"name": "PDCP_API_KEY",
"type": "vaultSecret",
"description": "The ProjectDiscovery Cloud Platform API key for asnmap.",
"required": True,
},
]
```
This is a known limitation and we are working on improving this.
In the meanwhile, here is a list of the needed keys to run Flowsint at it's full potential:
```bash
# for enrichers
WHOXY_API_KEY # Whoxy domain search engine [WHOXY]
PDCP_API_KEY # ProjectDiscovery Cloud Platform [ASNMAP], [NAABU] etc
HIBP_API_KEY # HaveIBeenPwned API key [HIBP]
ETHERSCAN_API_KEY # Etherscan crypto API key [ETHERSCAN]
# for Flo, AI assistant
MISTRAL_API_KEY
# but other providers will be supported soon (ChatGPT, etc.)
```
There are also some other tools that could need a bunch of other API keys like [Subfinder](https://github.com/projectdiscovery/subfinder). Configuring them is not possible for now, but will be soon.
Stay tuned for updates as those mechanisms may vary in the future, as the goal is to keep the user experience as smooth as possible.

59
docs/overview.mdx Normal file
View File

@@ -0,0 +1,59 @@
---
title: "Welcome to Flowsint"
description: "Complete documentation for Flowsint - a modular OSINT investigation platform."
category: "Overview"
order: 1
author: "Flowsint Team"
tags: ["documentation", "overview", "getting-started"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
### What is Flowsint?
Flowsint is a modular investigation and reconnaissance platform focused on OSINT (Open Source Intelligence). It provides:
- Graph-based visualization of entity relationships
- 30+ automated enrichers for intelligence gathering
- Modular architecture with clean separation of concerns
- Privacy-first design with local data storage
- Extensible platform for custom enrichers
- Automated search flows (more on that [here](/docs/getting-started/flows))
<Alert variant="warning">
<AlertTitle>Disclaimer !</AlertTitle>
<AlertDescription>
The author(s) and contributor(s) of this tool assume no responsibility or liability for any damages, losses, or consequences that may result from the use or misuse of this software.
By using this tool, you acknowledge and agree that:
- You are solely responsible for your use of this software
- You will use this tool in compliance with all applicable laws and regulations
- You will obtain proper authorization before conducting any security testing or reconnaissance activities
- You understand the potential risks and legal implications of using security tools
</AlertDescription>
</Alert>
### Why Flowsint?
If you've already practiced some OSINT, you know that analysts often rely on a multitude of research tools: scripts, third-party services, specialized applications. But these tools often work **in silos** and quickly become obsolete if they're not maintained: a service disappears, an API changes, an access point closes. The analyst juggles with unstable tools and sometimes has to **manually adapt their data** to continue their investigation.
OSINT tools, for most, are **consumables**: they evolve, appear, and disappear. Research methods change, security mechanisms evolve. However, the fundamental need always remains the same: **to see, exploit, and analyze data in a clear and understandable way**. Analysts need to be able to list, centralize, and visualize connections to have a clear understanding of their investigation.
This is exactly what Flowsint was built for: a solid and durable foundation on which your investigations rest. Tools become simple extensions that plug in and unplug easily. A new tool comes out, and with a few manipulations it can be integrated into your investigation workflow.
**Flowsint is the stable infrastructure that allows you to stay agile in the face of constant evolution of methods and sources.**
Flowsint is a local tool, running on your machine only. This ensures a great level of confidentiality, but comes with responsabilities.
<Alert variant="warning">
<AlertTitle>At your own risk</AlertTitle>
<AlertDescription>
All enrichers run locally on your machine. This means you can get banned from some services or get flagged from infrastructures if you start making thousands of requests to the same service.
You need to always know what you are doing, and understand that gathering can go very fast in scale.
For example, you can very easily happen to be making DNS resolution requests for 10 000 IPs.
Know you infrastructure and your limits. Know how the tools used in the enrichers actually work. You can have a list of the available enrichers and tools [here](/docs/sources/available-enrichers).
</AlertDescription>
</Alert>
If all those points are clear for you, let's start investigating !

View File

@@ -0,0 +1,163 @@
---
title: "Enrichers catalog"
description: "Quick start guide to using Enrichers for your OSINT investigations."
category: "Sources"
order: 11
author: "Flowsint Team"
tags: ["tutorial", "getting-started", "enrichers"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
### ASN
**asn_to_cidrs**: Given an ASN, enumerate its announced CIDR ranges.
Tools/Pivots: [asnmap](https://github.com/projectdiscovery/asnmap) (CLI), [jq](https://jqlang.github.io/jq/) (CLI)
### CIDR
**cidr_to_ips**: Expand a CIDR to IPs by PTR enumeration heuristics.
Tools/Pivots: [dnsx](https://github.com/projectdiscovery/dnsx) (CLI)
### Crypto
**cryptowallet_to_transactions**: Fetch ETH wallet transactions and map wallet-to-wallet relationships.
Tools/APIs: [Etherscan API](https://docs.etherscan.io/)
**cryptowallet_to_nfts**: Fetch ERC-721/1155 NFT transfers for a wallet.
Tools/APIs: [Etherscan API](https://docs.etherscan.io/)
### Domain
**domain_to_ip**: Resolve domains to IPv4 addresses.
Tools/Pivots: DNS resolution (socket)
**domain_to_subdomains**: Discover subdomains for a domain.
Tools/APIs: [subfinder](https://github.com/projectdiscovery/subfinder) (CLI), fallback to [crt.sh JSON API](https://crt.sh/?output=json)
**domain_to_whois**: Retrieve WHOIS registration data for a domain.
Tools/APIs: [python-whois](https://pypi.org/project/python-whois/)
**domain_to_asn**: Map a domain to its ASN by resolving and querying ASN data.
Tools/Pivots: system DNS, [asnmap](https://github.com/projectdiscovery/asnmap) (CLI)
**domain_to_root_domain**: Convert a subdomain to its registrable root.
Tools/Pivots: internal domain utils
**domain_to_history**: Retrieve historical WHOIS records and extract related entities (individuals, organizations, emails, phones, locations).
Tools/APIs: [Whoxy API](https://www.whoxy.com/api/)
**domain_to_website**: Convert a domain to a reachable website URL (HTTP/HTTPS), following redirects.
Tools/Pivots: HTTP HEAD requests
**domain_to_tls**: Retrieve TLS/SSL certificate information for a domain.
Tools/Pivots: [httpx](https://github.com/projectdiscovery/httpx) (CLI)
**domain_to_whois_history**: Retrieve historical WHOIS records for a domain and extract related entities (individuals, organizations, emails, locations).
Tools/APIs: [WhoisXML API](https://whois.whoisxmlapi.com/)
**domain_to_dehashed**: Get breach intelligence (credentials, related individuals) associated with a domain.
Tools/APIs: [DeHashed API](https://www.dehashed.com/docs)
### Email
**email_to_breaches**: Check whether an email appears in known breaches.
Tools/APIs: [Have I Been Pwned API](https://haveibeenpwned.com/API/v3)
**email_to_gravatar**: Check Gravatar existence and profile for an email (via MD5 hash).
Tools/APIs: [Gravatar endpoints](https://en.gravatar.com/site/implement/images/)
**email_to_domain**: Extract the domain part of an email address.
Tools/Pivots: internal email parser
**email_to_domains**: Find domains registered by a given email address; extract related contacts and entities.
Tools/APIs: [Whoxy API](https://www.whoxy.com/api/)
**email_to_username**: Extract the local-part of an email as a Username entity.
Tools/Pivots: internal email parser
**email_to_intelligence**: Get breach intelligence (credentials, related individuals) associated with an email.
Tools/APIs: [DeHashed API](https://www.dehashed.com/docs)
**email_to_device_hudsonrock**: Look up devices compromised by infostealers and associated with an email.
Tools/APIs: [HudsonRock API](https://www.hudsonrock.com/)
### Individual
**individual_to_domains**: Find domains registered by a specific person; extract related contacts and attributes.
Tools/APIs: [Whoxy API](https://www.whoxy.com/api/)
**individual_to_organization**: Find organizations related to a person in French registries.
Tools/APIs: SIRENE (via internal SireneTool) — see [INSEE Sirene API](https://api.insee.fr/catalogue/#/datasets/sirene)
### IP
**ip_to_domain**: Reverse-resolve IPs to domains via PTR and Certificate Transparency pivots.
Tools/APIs: DNS PTR (socket), [crt.sh JSON API](https://crt.sh/?output=json)
**ip_to_infos**: Enrich IPs with geolocation and ISP data.
Tools/APIs: [ip-api.com](https://ip-api.com/)
**ip_to_asn**: Map IPs to their ASN.
Tools/Pivots: AsnmapTool ([asnmap](https://github.com/projectdiscovery/asnmap))
**ip_to_ports**: Scan an IP for open ports and services.
Tools/Pivots: [naabu](https://github.com/projectdiscovery/naabu) (CLI)
**ip_to_fraudscore**: Compute a fraud risk score for an IP address.
Tools/APIs: [Scamalytics API](https://scamalytics.com/ip-api)
**ip_to_intelligence**: Get breach intelligence (credentials, related individuals) associated with an IP.
Tools/APIs: [DeHashed API](https://www.dehashed.com/docs)
### Organization
**org_to_domains**: Find domains registered by an organization; extract contacts and related entities.
Tools/APIs: [Whoxy API](https://www.whoxy.com/api/)
**org_to_infos**: Enrich organizations with French registry data and leaders.
Tools/APIs: SIRENE (SireneTool) — see [INSEE Sirene API](https://api.insee.fr/catalogue/#/datasets/sirene)
**org_to_asn**: Find ASNs associated with an organization name.
Tools/Pivots: [asnmap](https://github.com/projectdiscovery/asnmap) (CLI), [jq](https://jqlang.github.io/jq/) (CLI)
### Phone
**phone_to_infos**: Probe phone footprint across services (demo modules) and normalize number.
Tools/APIs: ignorant modules (Amazon, Snapchat, Instagram), [httpx](https://github.com/projectdiscovery/httpx)
**phone_to_carrier**: Look up carrier, country, and validity metadata for a phone number.
Tools/APIs: [Veriphone API](https://veriphone.io/)
**phone_to_device_hudsonrock**: Look up devices compromised by infostealers and associated with a phone number.
Tools/APIs: [HudsonRock API](https://www.hudsonrock.com/)
### Social
**username_to_socials_sherlock**: Enumerate social accounts for a username using Sherlock.
Tools/Pivots: [sherlock](https://github.com/sherlock-project/sherlock) (CLI)
**username_to_socials_maigret**: Enumerate social accounts for a username using Maigret and parse rich metadata.
Tools/Pivots: [maigret](https://github.com/soxoj/maigret) (CLI)
**username_to_dehashed**: Get breach intelligence (credentials, related individuals) associated with a username.
Tools/APIs: [DeHashed API](https://www.dehashed.com/docs)
**username_to_device_hudsonrock**: Look up devices compromised by infostealers and associated with a username.
Tools/APIs: [HudsonRock API](https://www.hudsonrock.com/)
### Website
**website_to_crawler**: Crawl a website to extract emails and phone numbers.
Tools/APIs: ReconCrawlTool (`reconcrawl`)
**website_to_domain**: Extract the domain name from a website URL.
Tools/Pivots: internal URL parser
**website_to_subdomains**: Find subdomains of a website's domain via external scan.
Tools/APIs: [c99.nl API](https://api.c99.nl/)
**website_to_links**: Crawl a website and collect internal/external links and domains.
Tools/APIs: reconspread Crawler
**website_to_text**: Fetch and extract visible text from a webpage.
Tools/APIs: HTTP GET, [BeautifulSoup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/)
**website_to_webtrackers**: Extract analytics/ads tracking codes from a website.
Tools/APIs: recontrack TrackingCodeExtractor
---
Notes
- Some enrichers optionally depend on docker binaries: `subfinder`, `asnmap`, `dnsx`, `naabu`, `httpx`, and `jq` which are installed in the docker container.
- API-keyed enrichers read keys from params or environment (e.g., `HIBP_API_KEY`, `ETHERSCAN_API_KEY`, `WHOXY_API_KEY`, `WHOISXML_API_KEY`, `DEHASHED_API_KEY`, `SCAMALYTICS_API_KEY`, `VERIPHONE_API_KEY`, `C99_API_KEY`).
- Internal/test enrichers (`domain_to_dummy`, `ip_to_dummy_domains`, `n8n_connector`) are not listed here — they exist in the codebase but are not part of the public catalog.

73
docs/syllabus.mdx Normal file
View File

@@ -0,0 +1,73 @@
---
title: "Syllabus"
description: "Syllabus, just to make sure we speak the same language. Those definitions apply in the context of Flowsint platform."
category: "Overview"
order: 2
author: "Flowsint Team"
tags: ["documentation", "overview", "syllabus"]
version: "1.2.8"
last_updated_at: "2026-05-15"
---
### OSINT
Open Source Intelligence consists of collecting, analyzing, and exploiting **freely** and **openly** available information from search engines, images, social networks, public archives, etc.
### Investigation
A structured process aimed at collecting, correlating, and analyzing information from different sources and enrichers, in order to answer a question or solve a problem. An investigation can be **exploratory** (discovering unknown elements) or **targeted** (validating a hypothesis). An investigation can contain multiple **sketches** (each representing a different view or stage of the analysis) and one or more **analyses**.
### Sketch
Visual result produced by executing one or more enrichers on one or more entities. A sketch represents the current state of the graph derived from collected data at a given moment in the investigation. Multiple sketches can exist for the same investigation to capture different perspectives or stages.
### Analysis
Set of processing, interpretations, and verifications performed on data collected during the investigation. Analyses aim to identify trends, confirm or refute hypotheses, and produce actionable conclusions. They can be **quantitative** (measurements, statistics) or **qualitative** (contextual assessments, behavioral patterns).
### Enricher
An **enricher** is an operation that, from an input element **A** (*source entity*), allows obtaining one or more elements **B** (*target entities*) by applying a search or correlation method called a **pivot**.
> Example:
>
>
> A = `my.domain.com` (*domain name*)
>
> p = "DNS resolution" (*pivot*)
>
> B = `12.23.34.45` (*IP address*).
>
### Pivot
A **pivot** is the method or technical process used to derive **B** from **A**. The pivot defines **how** the enricher obtains its result (e.g., DNS resolution, WHOIS lookup, API query, etc.).
> Examples of pivots:
>
> DNS Resolution → domain → IP
> WHOIS Lookup → IP → owner
> Reverse Image Search → image → web pages containing this image
### Tool
A tool generally refers to a script, program, or service providing a **pivot**, i.e., a means to retrieve or enricher information from an input element.
### Entity
An identifiable object or element manipulated by enrichers (e.g., IP address, domain, email address, user identifier, file hash, etc.). An entity is always associated with a **Sketch**. In the graph, entities are represented as **nodes** (see [Graph format](/docs/developers/graph-format) for technical details).
### Relationship
Defines a link between two entities. This link is generally named (in uppercase) and can be unidirectional or bidirectional.
> Examples of relationships:
>
>
> A = `my.domain.com` → `RESOLVES_TO` → `12.23.34.45`
>
A relationship is always associated between a **source** node (*from*) and a **target** node (*to*). In the graph, relationships are represented as **edges** (see [Graph format](/docs/developers/graph-format) for technical details).
### Flow
The chaining of multiple enrichers, where the output of one becomes the input of the next, allowing to expand or deepen an investigation.

View File

@@ -1,57 +1,123 @@
FROM python:3.12-slim AS base
FROM python:3.12-slim AS builder
ENV PYTHONUNBUFFERED=1
ENV POETRY_VIRTUALENVS_CREATE=false
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
RUN apt-get update && apt-get install -y \
WORKDIR /app
# build deps + uv
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
curl \
git \
libpq-dev \
pkg-config \
libcairo2-dev \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* \
&& curl -LsSf https://astral.sh/uv/install.sh | sh
RUN curl -sSL https://install.python-poetry.org | python3 - \
&& ln -s /root/.local/bin/poetry /usr/local/bin/poetry
ENV PATH="/root/.local/bin:$PATH"
# Copy workspace config
COPY pyproject.toml uv.lock ./
# Copy all workspace members
COPY flowsint-types ./flowsint-types
COPY flowsint-core ./flowsint-core
COPY flowsint-enrichers ./flowsint-enrichers
COPY flowsint-api ./flowsint-api
RUN uv sync --frozen --no-dev
# DEV
FROM python:3.12-slim AS dev
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
APP_ENV=development \
PATH="/app/.venv/bin:$PATH"
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
libcairo2 \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
FROM base AS dev
# Copy virtual environment from builder
COPY --from=builder /app/.venv ./.venv
ENV APP_ENV=development
COPY flowsint-core /app/flowsint-core
COPY flowsint-types /app/flowsint-types
COPY flowsint-enrichers /app/flowsint-enrichers
COPY flowsint-api /app/flowsint-api
# Copy application code
COPY flowsint-core ./flowsint-core
COPY flowsint-types ./flowsint-types
COPY flowsint-enrichers ./flowsint-enrichers
COPY flowsint-api ./flowsint-api
WORKDIR /app/flowsint-api
RUN poetry install --no-root
# Make entrypoint executable
RUN chmod +x entrypoint.sh
EXPOSE 5001
ENTRYPOINT ["/app/flowsint-api/entrypoint.sh"]
ENTRYPOINT ["./entrypoint.sh"]
# Dev command with hot-reload
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5001", "--reload"]
# Production stage
FROM base AS prod
# PROD
FROM python:3.12-slim AS production
ENV APP_ENV=production
LABEL org.opencontainers.image.source="https://github.com/reconurge/flowsint"
LABEL org.opencontainers.image.description="Flowsint API & Worker"
LABEL org.opencontainers.image.licenses="Apache-2.0"
COPY flowsint-core /app/flowsint-core
COPY flowsint-types /app/flowsint-types
COPY flowsint-enrichers /app/flowsint-enrichers
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
APP_ENV=production \
PATH="/app/.venv/bin:$PATH"
COPY flowsint-api /app/flowsint-api
# Install runtime dependencies only
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
libcairo2 \
curl \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Create non-root user
RUN groupadd -g 1001 flowsint && \
useradd -u 1001 -g flowsint -s /bin/bash -m flowsint
WORKDIR /app
# Copy virtual environment from builder
COPY --from=builder --chown=flowsint:flowsint /app/.venv ./.venv
# Copy application code
COPY --chown=flowsint:flowsint flowsint-core ./flowsint-core
COPY --chown=flowsint:flowsint flowsint-types ./flowsint-types
COPY --chown=flowsint:flowsint flowsint-enrichers ./flowsint-enrichers
COPY --chown=flowsint:flowsint flowsint-api ./flowsint-api
WORKDIR /app/flowsint-api
RUN poetry install
# Make entrypoint executable
RUN chmod +x entrypoint.sh
# Switch to non-root user
USER flowsint
EXPOSE 5001
ENTRYPOINT ["/app/flowsint-api/entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5001"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:5001/health || exit 1
ENTRYPOINT ["./entrypoint.sh"]
# Production command (no reload)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5001"]

View File

@@ -5,14 +5,14 @@
1. Install Python dependencies:
2.
```bash
poetry install
uv sync
```
## Run
```bash
# dev
poetry run uvicorn app.main:app --host 0.0.0.0 --port 5001 --reload
uv run uvicorn app.main:app --host 0.0.0.0 --port 5001 --reload
# prod
poetry run uvicorn app.main:app --host 0.0.0.0 --port 5001
uv run uvicorn app.main:app --host 0.0.0.0 --port 5001
```

View File

@@ -0,0 +1,107 @@
"""make column types portable (JSONB->JSON, ARRAY->JSON/TEXT)
Revision ID: a1b2c3d4e5f6
Revises: 8173aba964e7
Create Date: 2026-02-07 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = "8173aba964e7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. logs.content: JSONB -> JSON
op.execute("ALTER TABLE logs ALTER COLUMN content TYPE JSON USING content::text::json")
# 2. custom_types.schema: JSONB -> JSON
op.execute(
'ALTER TABLE custom_types ALTER COLUMN "schema" TYPE JSON USING "schema"::text::json'
)
# 3. flows.category: ARRAY(Text) -> JSON
op.execute(
"""
ALTER TABLE flows ALTER COLUMN category TYPE JSON
USING CASE
WHEN category IS NULL THEN NULL
ELSE array_to_json(category)
END
"""
)
# 4. investigation_user_roles.roles: ARRAY(role_enum) -> TEXT (JSON string)
# Convert PostgreSQL enum array like {OWNER,EDITOR} to JSON string like '["owner","editor"]'
op.execute(
"""
ALTER TABLE investigation_user_roles ALTER COLUMN roles TYPE TEXT
USING CASE
WHEN roles IS NULL THEN '[]'
ELSE lower(array_to_json(roles::text[])::text)
END
"""
)
# Remove the server_default that used PostgreSQL array literal '{}'
op.alter_column("investigation_user_roles", "roles", server_default=None)
# Drop the role_enum type (no longer needed)
op.execute("DROP TYPE IF EXISTS role_enum")
def downgrade() -> None:
# Recreate the role_enum type
op.execute("CREATE TYPE role_enum AS ENUM ('OWNER', 'EDITOR', 'VIEWER')")
# 4. investigation_user_roles.roles: TEXT -> ARRAY(role_enum)
# Use a temp column to avoid subquery restriction in USING
op.execute("ALTER TABLE investigation_user_roles ADD COLUMN roles_tmp role_enum[]")
op.execute(
"""
UPDATE investigation_user_roles SET roles_tmp = CASE
WHEN roles IS NULL OR roles = '[]' THEN '{}'::role_enum[]
ELSE (
SELECT array_agg(upper(elem)::role_enum)
FROM json_array_elements_text(roles::json) AS elem
)
END
"""
)
op.execute("ALTER TABLE investigation_user_roles DROP COLUMN roles")
op.execute("ALTER TABLE investigation_user_roles RENAME COLUMN roles_tmp TO roles")
op.alter_column(
"investigation_user_roles", "roles", server_default=sa.text("'{}'")
)
# 3. flows.category: JSON -> ARRAY(Text)
# Use a temp column to avoid subquery restriction in USING
op.execute("ALTER TABLE flows ADD COLUMN category_tmp TEXT[]")
op.execute(
"""
UPDATE flows SET category_tmp = CASE
WHEN category IS NULL THEN NULL
ELSE (
SELECT array_agg(elem::text)
FROM json_array_elements_text(category::json) AS elem
)
END
"""
)
op.execute("ALTER TABLE flows DROP COLUMN category")
op.execute("ALTER TABLE flows RENAME COLUMN category_tmp TO category")
# 2. custom_types.schema: JSON -> JSONB
op.execute(
'ALTER TABLE custom_types ALTER COLUMN "schema" TYPE JSONB USING "schema"::jsonb'
)
# 1. logs.content: JSON -> JSONB
op.execute("ALTER TABLE logs ALTER COLUMN content TYPE JSONB USING content::jsonb")

View File

@@ -0,0 +1,59 @@
"""backfill owner roles for existing investigations
Revision ID: a1f2b3c4d5e6
Revises: bac5764d4496
Create Date: 2026-04-11 00:00:00.000000
"""
from typing import Sequence, Union
from uuid import uuid4
from alembic import op
import sqlalchemy as sa
import json
# revision identifiers, used by Alembic.
revision: str = "a1f2b3c4d5e6"
down_revision: Union[str, None] = "bac5764d4496"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Insert OWNER role entry for every investigation that lacks one."""
conn = op.get_bind()
# Find investigations with no entry in investigation_user_roles
rows = conn.execute(
sa.text(
"""
SELECT i.id, i.owner_id
FROM investigations i
LEFT JOIN investigation_user_roles r
ON r.investigation_id = i.id AND r.user_id = i.owner_id
WHERE r.id IS NULL
AND i.owner_id IS NOT NULL
"""
)
).fetchall()
for inv_id, owner_id in rows:
conn.execute(
sa.text(
"""
INSERT INTO investigation_user_roles (id, user_id, investigation_id, roles)
VALUES (:id, :user_id, :investigation_id, :roles)
"""
),
{
"id": str(uuid4()),
"user_id": str(owner_id),
"investigation_id": str(inv_id),
"roles": json.dumps(["owner"]),
},
)
def downgrade() -> None:
"""No-op: we don't remove the backfilled rows."""
pass

View File

@@ -0,0 +1,28 @@
"""add description to enricher_templates
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2025-01-31
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6a7'
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add description column to enricher_templates table."""
op.add_column('enricher_templates', sa.Column('description', sa.Text(), nullable=True))
def downgrade() -> None:
"""Remove description column from enricher_templates table."""
op.drop_column('enricher_templates', 'description')

View File

@@ -0,0 +1,42 @@
"""add icon and color to custom types
Revision ID: bac5764d4496
Revises: f5fae279ec04
Create Date: 2026-02-14 12:10:26.415916
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'bac5764d4496'
down_revision: Union[str, None] = 'f5fae279ec04'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('custom_types', sa.Column('icon', sa.String(), nullable=True))
op.add_column('custom_types', sa.Column('color', sa.String(), nullable=True))
op.alter_column('enricher_templates', 'content',
existing_type=postgresql.JSONB(astext_type=sa.Text()),
type_=sa.JSON(),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('enricher_templates', 'content',
existing_type=sa.JSON(),
type_=postgresql.JSONB(astext_type=sa.Text()),
existing_nullable=False)
op.drop_column('custom_types', 'color')
op.drop_column('custom_types', 'icon')
# ### end Alembic commands ###

View File

@@ -0,0 +1,80 @@
"""add enricher_templates table
Revision ID: a1b2c3d4e5f6
Revises: 8173aba964e7
Create Date: 2025-01-31
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c9d8e7f6a5b4"
down_revision: Union[str, None] = "a1b2c3d4e5f6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
"enricher_templates",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("category", sa.Text(), nullable=False),
sa.Column("version", sa.Float(), nullable=False, server_default="1.0"),
sa.Column("content", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("is_public", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("owner_id", sa.UUID(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
sa.ForeignKeyConstraint(
["owner_id"], ["profiles.id"], onupdate="CASCADE", ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"idx_enricher_templates_owner_id",
"enricher_templates",
["owner_id"],
unique=False,
)
op.create_index(
"idx_enricher_templates_name", "enricher_templates", ["name"], unique=False
)
op.create_index(
"idx_enricher_templates_category",
"enricher_templates",
["category"],
unique=False,
)
op.create_index(
"idx_enricher_templates_is_public",
"enricher_templates",
["is_public"],
unique=False,
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index("idx_enricher_templates_is_public", table_name="enricher_templates")
op.drop_index("idx_enricher_templates_category", table_name="enricher_templates")
op.drop_index("idx_enricher_templates_name", table_name="enricher_templates")
op.drop_index("idx_enricher_templates_owner_id", table_name="enricher_templates")
op.drop_table("enricher_templates")

View File

@@ -0,0 +1,28 @@
"""merge portable column types and enricher templates
Revision ID: f5fae279ec04
Revises: b2c3d4e5f6a7, c9d8e7f6a5b4
Create Date: 2026-02-07 18:00:18.801891
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f5fae279ec04'
down_revision: Union[str, None] = ('b2c3d4e5f6a7', 'c9d8e7f6a5b4')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass

View File

@@ -1,19 +1,10 @@
from fastapi import Depends, HTTPException, status, Request
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from flowsint_core.core.auth import ALGORITHM
from flowsint_core.core.auth import ALGORITHM, AUTH_SECRET
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.models import Profile
from typing import Optional
import os
from dotenv import load_dotenv
# Remplace avec ton URL de BDD
AUTH_SECRET = os.getenv("AUTH_SECRET")
load_dotenv()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
@@ -37,42 +28,3 @@ def get_current_user(
if user is None:
raise credentials_exception
return user
def get_current_user_sse(
request: Request, db: Session = Depends(get_db)
) -> Profile:
"""
Alternative authentication for SSE endpoints that accepts token via query parameter.
EventSource API doesn't support custom headers, so we need to pass the token in the URL.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
# Try to get token from query parameter
token: Optional[str] = request.query_params.get("token")
# Fallback to Authorization header if query param not present
if not token:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "")
if not token:
raise credentials_exception
try:
payload = jwt.decode(token, AUTH_SECRET, algorithms=[ALGORITHM])
email: str = payload.get("sub")
if email is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(Profile).filter(Profile.email == email).first()
if user is None:
raise credentials_exception
return user

View File

@@ -1,42 +1,30 @@
from uuid import UUID, uuid4
from app.security.permissions import check_investigation_permission
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends, status
from typing import List
from datetime import datetime
from sqlalchemy import or_
from sqlalchemy.orm import Session
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.models import Analysis, Profile, InvestigationUserRole
from flowsint_core.core.types import Role
from flowsint_core.core.models import Profile
from flowsint_core.core.services import (
create_analysis_service,
NotFoundError,
PermissionDeniedError,
)
from app.api.deps import get_current_user
from app.api.schemas.analysis import AnalysisRead, AnalysisCreate, AnalysisUpdate
router = APIRouter()
# Get the list of all analyses for the current user
@router.get("", response_model=List[AnalysisRead])
def get_analyses(
db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
):
# Get all analyses from investigations where user has at least VIEWER role
allowed_roles_for_read = [Role.OWNER, Role.EDITOR, Role.VIEWER]
query = db.query(Analysis).join(
InvestigationUserRole,
InvestigationUserRole.investigation_id == Analysis.investigation_id,
)
query = query.filter(InvestigationUserRole.user_id == current_user.id)
# Filter by allowed roles
conditions = [InvestigationUserRole.roles.any(role) for role in allowed_roles_for_read]
query = query.filter(or_(*conditions))
return query.distinct().all()
"""Get all analyses accessible to the current user."""
service = create_analysis_service(db)
return service.get_accessible_analyses(current_user.id)
# Create a New analysis
@router.post(
"/create", response_model=AnalysisRead, status_code=status.HTTP_201_CREATED
)
@@ -45,64 +33,47 @@ def create_analysis(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
check_investigation_permission(
current_user.id, payload.investigation_id, actions=["create"], db=db
)
new_analysis = Analysis(
id=uuid4(),
title=payload.title,
description=payload.description,
content=payload.content,
owner_id=current_user.id,
investigation_id=payload.investigation_id,
created_at=datetime.utcnow(),
last_updated_at=datetime.utcnow(),
)
db.add(new_analysis)
db.commit()
db.refresh(new_analysis)
return new_analysis
service = create_analysis_service(db)
try:
return service.create(
title=payload.title,
description=payload.description,
content=payload.content,
investigation_id=payload.investigation_id,
owner_id=current_user.id,
)
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
# Get an analysis by ID
@router.get("/{analysis_id}", response_model=AnalysisRead)
def get_analysis_by_id(
analysis_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
analysis = (
db.query(Analysis)
.filter(Analysis.id == analysis_id)
.first()
)
if not analysis:
service = create_analysis_service(db)
try:
return service.get_by_id(analysis_id, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Analysis not found")
check_investigation_permission(
current_user.id, analysis.investigation_id, actions=["read"], db=db
)
return analysis
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
# Get analyses by investigation ID
@router.get("/investigation/{investigation_id}", response_model=List[AnalysisRead])
def get_analyses_by_investigation(
investigation_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
check_investigation_permission(
current_user.id, investigation_id, actions=["read"], db=db
)
analyses = (
db.query(Analysis)
.filter(Analysis.investigation_id == investigation_id)
.all()
)
return analyses
service = create_analysis_service(db)
try:
return service.get_by_investigation(investigation_id, current_user.id)
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
# Update an analysis by ID
@router.put("/{analysis_id}", response_model=AnalysisRead)
def update_analysis(
analysis_id: UUID,
@@ -110,51 +81,33 @@ def update_analysis(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
analysis = (
db.query(Analysis)
.filter(Analysis.id == analysis_id)
.first()
)
if not analysis:
raise HTTPException(status_code=404, detail="Analysis not found")
check_investigation_permission(
current_user.id, analysis.investigation_id, actions=["update"], db=db
)
if payload.title is not None:
analysis.title = payload.title
if payload.description is not None:
analysis.description = payload.description
if payload.content is not None:
analysis.content = payload.content
if payload.investigation_id is not None:
# Check permission for the new investigation as well
check_investigation_permission(
current_user.id, payload.investigation_id, actions=["update"], db=db
service = create_analysis_service(db)
try:
return service.update(
analysis_id=analysis_id,
user_id=current_user.id,
title=payload.title,
description=payload.description,
content=payload.content,
investigation_id=payload.investigation_id,
)
analysis.investigation_id = payload.investigation_id
analysis.last_updated_at = datetime.utcnow()
db.commit()
db.refresh(analysis)
return analysis
except NotFoundError:
raise HTTPException(status_code=404, detail="Analysis not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
# Delete an analysis by ID
@router.delete("/{analysis_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_analysis(
analysis_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
analysis = (
db.query(Analysis)
.filter(Analysis.id == analysis_id)
.first()
)
if not analysis:
service = create_analysis_service(db)
try:
service.delete(analysis_id, current_user.id)
return None
except NotFoundError:
raise HTTPException(status_code=404, detail="Analysis not found")
check_investigation_permission(
current_user.id, analysis.investigation_id, actions=["delete"], db=db
)
db.delete(analysis)
db.commit()
return None
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")

View File

@@ -1,15 +1,19 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
from flowsint_core.core.auth import (
verify_password,
create_access_token,
get_password_hash,
from sqlalchemy.exc import SQLAlchemyError
from typing import List
from flowsint_core.core.services import (
create_auth_service,
AuthenticationError,
ConflictError,
DatabaseError,
)
from app.api.schemas.profile import ProfileCreate
from flowsint_core.core.models import Profile
from flowsint_core.core.postgre_db import get_db
from app.api.schemas.profile import ProfileCreate, ProfileRead, ProfileUpdate
from app.api.deps import get_current_user
router = APIRouter()
@@ -18,50 +22,60 @@ router = APIRouter()
def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)
):
service = create_auth_service(db)
try:
user = db.query(Profile).filter(Profile.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=400, detail="Incorrect email or password")
access_token = create_access_token(data={"sub": user.email})
return {
"access_token": access_token,
"user_id": user.id,
"token_type": "bearer",
}
except SQLAlchemyError as e:
# Log optionnel
return service.authenticate(form_data.username, form_data.password)
except AuthenticationError:
raise HTTPException(status_code=400, detail="Incorrect email or password")
except (DatabaseError, SQLAlchemyError) as e:
print(f"[ERROR] DB error during login: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/register", status_code=201)
def register(user: ProfileCreate, db: Session = Depends(get_db)):
service = create_auth_service(db)
try:
existing_user = db.query(Profile).filter(Profile.email == user.email).first()
if existing_user:
raise HTTPException(status_code=400, detail="Email already registered")
hashed_password = get_password_hash(user.password)
new_user = Profile(email=user.email, hashed_password=hashed_password)
db.add(new_user)
db.commit()
db.refresh(new_user)
return {
"message": "User registered successfully",
"email": new_user.email,
}
except IntegrityError:
db.rollback()
return service.register(user.email, user.password)
except ConflictError:
raise HTTPException(status_code=400, detail="Email already registered")
except SQLAlchemyError as e:
db.rollback()
except (DatabaseError, SQLAlchemyError) as e:
print(f"[ERROR] DB error during registration: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/me", response_model=ProfileRead)
def get_me(current_user: Profile = Depends(get_current_user)):
return current_user
@router.put("/me", response_model=ProfileRead)
def update_me(
payload: ProfileUpdate,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(current_user, key, value)
db.commit()
db.refresh(current_user)
return current_user
@router.get("/users/search", response_model=List[ProfileRead])
def search_users(
q: str = Query(..., min_length=1),
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""Search users by email prefix for the share dialog autocomplete."""
results = (
db.query(Profile)
.filter(
Profile.email.ilike(f"{q}%"),
Profile.id != current_user.id,
)
.limit(5)
.all()
)
return results

View File

@@ -1,95 +1,44 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import os
from mistralai import Mistral
from mistralai.models import UserMessage, AssistantMessage, SystemMessage
import json
from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException, Depends, status
from typing import Dict, List, Optional
from datetime import datetime
from sqlalchemy import or_
from sqlalchemy.orm import Session
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from flowsint_core.core.models import Profile
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.models import Chat, ChatMessage, Profile, InvestigationUserRole
from flowsint_core.core.types import Role
from flowsint_core.core.services import (
NotFoundError,
create_chat_service,
)
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.api.schemas.chat import ChatCreate, ChatRead
from app.security.permissions import check_investigation_permission
router = APIRouter()
def clean_context(context: List[Dict]) -> List[Dict]:
print(context)
"""Remove unnecessary keys from context data."""
cleaned = []
for item in context:
if isinstance(item, dict):
# Create a copy and remove unwanted keys
cleaned_item = item["data"].copy()
# Remove top-level keys
cleaned_item.pop("id", None)
cleaned_item.pop("sketch_id", None)
# Remove from data if it exists
if "data" in cleaned_item and isinstance(cleaned_item["data"], dict):
cleaned_item["data"].pop("sketch_id", None)
# Remove measured/dimensions
cleaned_item.pop("measured", None)
cleaned.append(cleaned_item)
return cleaned
class ChatRequest(BaseModel):
prompt: str
context: Optional[List[Dict]] = None
context: Optional[List[str]] = None
# Get all chats
@router.get("/", response_model=List[ChatRead])
@router.get("", response_model=List[ChatRead])
def get_chats(
db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
):
chats = db.query(Chat).filter(Chat.owner_id == current_user.id).all()
# Sort messages for each chat by created_at in ascending order
for chat in chats:
chat.messages.sort(key=lambda x: x.created_at)
return chats
service = create_chat_service(db)
return service.get_chats_for_user(current_user.id)
# @router.get("/delete-all", status_code=status.HTTP_204_NO_CONTENT)
# def delete_all_chat(db: Session = Depends(get_db)):
# chats = db.query(Chat).all()
# for chat in chats:
# db.delete(chat)
# db.commit()
# return {"result": "done"}
# Get analyses by investigation ID
@router.get("/investigation/{investigation_id}", response_model=List[ChatRead])
def get_chats_by_investigation(
investigation_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
chats = (
db.query(Chat)
.filter(
Chat.investigation_id == investigation_id, Chat.owner_id == current_user.id
)
.order_by(Chat.created_at.asc())
.all()
)
# Sort messages for each chat by created_at in ascending order
for chat in chats:
chat.messages.sort(key=lambda x: x.created_at)
return chats
service = create_chat_service(db)
return service.get_by_investigation(investigation_id, current_user.id)
@router.post("/stream/{chat_id}")
@@ -99,171 +48,67 @@ async def stream_chat(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
# Check if Chat exists
chat = (
db.query(Chat)
.filter(Chat.id == chat_id, Chat.owner_id == current_user.id)
.first()
)
if not chat:
raise HTTPException(status_code=404, detail="Chat not found")
# Update chat's last_updated_at
chat.last_updated_at = datetime.utcnow()
db.commit()
# A new message is created
user_message = ChatMessage(
id=uuid4(),
content=payload.prompt,
context=payload.context,
chat_id=chat_id,
is_bot=False,
created_at=datetime.utcnow(),
)
db.add(user_message)
db.commit()
db.refresh(user_message)
service = create_chat_service(db)
try:
api_key = os.environ.get("MISTRAL_API_KEY")
if not api_key:
raise HTTPException(
status_code=500, detail="Mistral API key not configured"
)
chat = service.get_by_id(chat_id, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Chat not found")
client = Mistral(api_key=api_key)
model = "mistral-small-latest"
accumulated_content = []
context_message = None
# Convert database messages to Mistral format
messages = [
SystemMessage(
content="You are a CTI/OSINT investigator and you are trying to investigate on a variety of real life cases. Use your knowledge and analytics capabilities to analyse the context and answer the question the best you can. If you need to reference some items (an IP, a domain or something particular) please use the code brackets, like : `12.23.34.54` to reference it."
)
]
service.add_user_message(chat_id, current_user.id, payload.prompt, payload.context)
# Add context as a single system message if provided
if payload.context:
try:
# Clean context by removing unnecessary keys
cleaned_context = clean_context(payload.context)
if cleaned_context:
context_str = json.dumps(cleaned_context, indent=2, default=str)
context_message = f"Context: {context_str}"
# Limit context message length to avoid token limits
if len(context_message) > 2000:
context_message = context_message[:2000] + "..."
except Exception as e:
# If context processing fails, skip it
print(f"Context processing error: {e}")
ai_context = service.prepare_ai_context(chat, payload.prompt, payload.context)
llm_messages = service.build_llm_messages(ai_context)
# Sort messages by created_at in ascending order and get recent messages
sorted_messages = sorted(chat.messages, key=lambda x: x.created_at)
recent_messages = (
sorted_messages[-5:] if len(sorted_messages) > 5 else sorted_messages
)
for message in recent_messages:
if message.is_bot:
messages.append(
AssistantMessage(content=json.dumps(message.content, default=str))
)
else:
messages.append(
UserMessage(content=json.dumps(message.content, default=str))
)
# Add the current context
if context_message:
messages.append(SystemMessage(content=context_message))
# Add the current user message
messages.append(UserMessage(content=payload.prompt))
async def generate():
response = await client.chat.stream_async(model=model, messages=messages)
async for chunk in response:
if chunk.data.choices[0].delta.content is not None:
content_chunk = chunk.data.choices[0].delta.content
accumulated_content.append(content_chunk)
yield f"data: {json.dumps({'content': content_chunk})}\n\n"
# Create the bot message after all chunks have been processed
chat_message = ChatMessage(
id=uuid4(),
content="".join(accumulated_content),
chat_id=chat_id,
is_bot=True,
created_at=datetime.utcnow(),
)
db.add(chat_message)
db.commit()
db.refresh(chat_message)
yield "data: [DONE]\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
except Exception as e:
try:
provider = service.get_llm_provider(current_user.id)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e))
return StreamingResponse(
service.stream_response(chat_id, llm_messages, provider),
media_type="text/event-stream",
headers={"x-vercel-ai-ui-message-stream": "v1"},
)
# Create a new chat
@router.post("/create", response_model=ChatRead, status_code=status.HTTP_201_CREATED)
def create_chat(
payload: ChatCreate,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
new_chat = Chat(
id=uuid4(),
service = create_chat_service(db)
return service.create(
title=payload.title,
description=payload.description,
owner_id=current_user.id,
investigation_id=payload.investigation_id,
created_at=datetime.utcnow(),
last_updated_at=datetime.utcnow(),
owner_id=current_user.id,
)
db.add(new_chat)
db.commit()
db.refresh(new_chat)
return new_chat
# Get a chat by ID
@router.get("/{chat_id}", response_model=ChatRead)
def get_chat_by_id(
chat_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
chat = (
db.query(Chat)
.filter(Chat.id == chat_id, Chat.owner_id == current_user.id)
.first()
)
if not chat:
service = create_chat_service(db)
try:
return service.get_by_id(chat_id, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Chat not found")
# Sort messages by created_at in ascending order
chat.messages.sort(key=lambda x: x.created_at)
return chat
# Delete an chat by ID
@router.delete("/{chat_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_chat(
chat_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
chat = (
db.query(Chat)
.filter(Chat.id == chat_id, Chat.owner_id == current_user.id)
.first()
)
if not chat:
service = create_chat_service(db)
try:
service.delete(chat_id, current_user.id)
return None
except NotFoundError:
raise HTTPException(status_code=404, detail="Chat not found")
db.delete(chat)
db.commit()
return None

View File

@@ -1,25 +1,33 @@
"""API routes for custom types management."""
from uuid import UUID
from typing import List
from fastapi import APIRouter, HTTPException, Depends, status
from sqlalchemy.orm import Session
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from flowsint_core.core.models import Profile
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.models import CustomType, Profile
from flowsint_core.core.services import (
ConflictError,
NotFoundError,
ValidationError,
create_custom_type_service,
)
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.api.schemas.custom_type import (
CustomTypeCreate,
CustomTypeUpdate,
CustomTypeRead,
CustomTypeUpdate,
CustomTypeValidatePayload,
CustomTypeValidateResponse,
)
from app.utils.custom_types import (
calculate_schema_checksum,
validate_json_schema,
validate_payload_against_schema,
calculate_schema_checksum,
)
router = APIRouter()
@@ -29,47 +37,22 @@ def create_custom_type(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""
Create a new custom type.
Validates the JSON Schema and stores it in the database.
"""
# Validate the JSON Schema
validate_json_schema(custom_type.json_schema)
# Calculate checksum
checksum = calculate_schema_checksum(custom_type.json_schema)
# Check for duplicate name for this user
existing = (
db.query(CustomType)
.filter(
CustomType.owner_id == current_user.id,
CustomType.name == custom_type.name
"""Create a new custom type."""
service = create_custom_type_service(db)
try:
return service.create(
name=custom_type.name,
json_schema=custom_type.json_schema,
user_id=current_user.id,
description=custom_type.description,
status=custom_type.status,
validate_schema_func=validate_json_schema,
calculate_checksum_func=calculate_schema_checksum,
)
.first()
)
if existing:
raise HTTPException(
status_code=400,
detail=f"Custom type with name '{custom_type.name}' already exists"
)
# Create the custom type
db_custom_type = CustomType(
name=custom_type.name,
owner_id=current_user.id,
schema=custom_type.json_schema,
description=custom_type.description,
status=custom_type.status,
checksum=checksum,
)
db.add(db_custom_type)
db.commit()
db.refresh(db_custom_type)
return db_custom_type
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except ConflictError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("", response_model=List[CustomTypeRead])
@@ -78,23 +61,12 @@ def list_custom_types(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""
List all custom types for the current user.
Can be filtered by status (draft, published, archived).
"""
query = db.query(CustomType).filter(CustomType.owner_id == current_user.id)
if status:
if status not in ["draft", "published", "archived"]:
raise HTTPException(
status_code=400,
detail="Status must be one of: draft, published, archived"
)
query = query.filter(CustomType.status == status)
custom_types = query.order_by(CustomType.created_at.desc()).all()
return custom_types
"""List all custom types for the current user."""
service = create_custom_type_service(db)
try:
return service.list_custom_types(current_user.id, status)
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{id}", response_model=CustomTypeRead)
@@ -104,19 +76,11 @@ def get_custom_type(
current_user: Profile = Depends(get_current_user),
):
"""Get a specific custom type by ID."""
custom_type = (
db.query(CustomType)
.filter(CustomType.id == id, CustomType.owner_id == current_user.id)
.first()
)
if not custom_type:
raise HTTPException(
status_code=404,
detail="Custom type not found"
)
return custom_type
service = create_custom_type_service(db)
try:
return service.get_by_id(id, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Custom type not found")
@router.get("/{id}/schema")
@@ -126,19 +90,11 @@ def get_custom_type_schema(
current_user: Profile = Depends(get_current_user),
):
"""Get the raw JSON Schema for a custom type."""
custom_type = (
db.query(CustomType)
.filter(CustomType.id == id, CustomType.owner_id == current_user.id)
.first()
)
if not custom_type:
raise HTTPException(
status_code=404,
detail="Custom type not found"
)
return custom_type.schema
service = create_custom_type_service(db)
try:
return service.get_schema(id, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Custom type not found")
@router.put("/{id}", response_model=CustomTypeRead)
@@ -148,58 +104,27 @@ def update_custom_type(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""
Update a custom type.
If the schema is changed, a new checksum is calculated.
"""
custom_type = (
db.query(CustomType)
.filter(CustomType.id == id, CustomType.owner_id == current_user.id)
.first()
)
if not custom_type:
raise HTTPException(
status_code=404,
detail="Custom type not found"
"""Update a custom type."""
service = create_custom_type_service(db)
try:
return service.update(
custom_type_id=id,
user_id=current_user.id,
name=update_data.name,
icon=update_data.icon,
color=update_data.color,
json_schema=update_data.json_schema,
description=update_data.description,
status=update_data.status,
validate_schema_func=validate_json_schema,
calculate_checksum_func=calculate_schema_checksum,
)
# Update fields
if update_data.name is not None:
# Check for duplicate name
existing = (
db.query(CustomType)
.filter(
CustomType.owner_id == current_user.id,
CustomType.name == update_data.name,
CustomType.id != id
)
.first()
)
if existing:
raise HTTPException(
status_code=400,
detail=f"Custom type with name '{update_data.name}' already exists"
)
custom_type.name = update_data.name
if update_data.json_schema is not None:
# Validate the new schema
validate_json_schema(update_data.json_schema)
custom_type.schema = update_data.json_schema
custom_type.checksum = calculate_schema_checksum(update_data.json_schema)
if update_data.description is not None:
custom_type.description = update_data.description
if update_data.status is not None:
custom_type.status = update_data.status
db.commit()
db.refresh(custom_type)
return custom_type
except NotFoundError:
raise HTTPException(status_code=404, detail="Custom type not found")
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except ConflictError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -209,22 +134,12 @@ def delete_custom_type(
current_user: Profile = Depends(get_current_user),
):
"""Delete a custom type."""
custom_type = (
db.query(CustomType)
.filter(CustomType.id == id, CustomType.owner_id == current_user.id)
.first()
)
if not custom_type:
raise HTTPException(
status_code=404,
detail="Custom type not found"
)
db.delete(custom_type)
db.commit()
return None
service = create_custom_type_service(db)
try:
service.delete(id, current_user.id)
return None
except NotFoundError:
raise HTTPException(status_code=404, detail="Custom type not found")
@router.post("/{id}/validate", response_model=CustomTypeValidateResponse)
@@ -234,30 +149,18 @@ def validate_payload(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""
Validate a payload against a custom type's schema.
This is useful for testing before publishing a type.
"""
custom_type = (
db.query(CustomType)
.filter(CustomType.id == id, CustomType.owner_id == current_user.id)
.first()
)
if not custom_type:
raise HTTPException(
status_code=404,
detail="Custom type not found"
"""Validate a payload against a custom type's schema."""
service = create_custom_type_service(db)
try:
is_valid, errors = service.validate_payload(
id,
current_user.id,
payload_data.payload,
validate_payload_func=validate_payload_against_schema,
)
# Validate the payload against the schema
is_valid, errors = validate_payload_against_schema(
payload_data.payload,
custom_type.schema
)
return CustomTypeValidateResponse(
valid=is_valid,
errors=errors if not is_valid else None
)
return CustomTypeValidateResponse(
valid=is_valid,
errors=errors if not is_valid else None,
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Custom type not found")

View File

@@ -0,0 +1,206 @@
"""API routes for enricher templates management."""
from typing import List
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from flowsint_core.core.models import Profile
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.services import (
ConflictError,
NotFoundError,
ValidationError,
create_enricher_template_service,
create_template_generator_service,
)
from flowsint_core.core.template_enricher import TemplateEnricher
from flowsint_core.core.vault import Vault
from flowsint_core.templates.types import Template
from sqlalchemy.orm import Session
from flowsint_types.registry import get_type as get_type_from_registry, load_all_types
from app.api.deps import get_current_user
from app.api.schemas.enricher_template import (
EnricherTemplateCreate,
EnricherTemplateGenerateRequest,
EnricherTemplateGenerateResponse,
EnricherTemplateList,
EnricherTemplateRead,
EnricherTemplateTestRequest,
EnricherTemplateTestResponse,
EnricherTemplateUpdate,
)
router = APIRouter()
@router.post(
"", response_model=EnricherTemplateRead, status_code=status.HTTP_201_CREATED
)
def create_template(
template: EnricherTemplateCreate,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""Create a new enricher template."""
content = template.content
name = content.get("name", template.name)
description = content.get("description", template.description)
category = content.get("category", template.category)
version = float(content.get("version", template.version))
service = create_enricher_template_service(db)
try:
return service.create_template(
name=name,
description=description,
category=category,
version=version,
content=content,
is_public=template.is_public,
owner_id=current_user.id,
)
except ConflictError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("", response_model=List[EnricherTemplateList])
def list_templates(
category: str = Query(None, description="Filter by category"),
include_public: bool = Query(
True, description="Include public templates from other users"
),
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""List enricher templates."""
service = create_enricher_template_service(db)
return service.list_templates(current_user.id, category, include_public)
@router.post("/generate", response_model=EnricherTemplateGenerateResponse)
async def generate_template(
request: EnricherTemplateGenerateRequest,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""Generate an enricher template from a free-text description using AI."""
load_all_types()
input_schema = None
output_schema = None
if request.input_type:
input_cls = get_type_from_registry(request.input_type, case_sensitive=True)
if input_cls is None:
raise HTTPException(
status_code=422,
detail=f"Unknown input type: '{request.input_type}'",
)
input_schema = input_cls.model_json_schema()
if request.output_type:
output_cls = get_type_from_registry(request.output_type, case_sensitive=True)
if output_cls is None:
raise HTTPException(
status_code=422,
detail=f"Unknown output type: '{request.output_type}'",
)
output_schema = output_cls.model_json_schema()
service = create_template_generator_service(db)
try:
yaml_content = await service.generate(
prompt=request.prompt,
owner_id=current_user.id,
input_type=request.input_type,
input_schema=input_schema,
output_type=request.output_type,
output_schema=output_schema,
)
except ValidationError as e:
raise HTTPException(status_code=422, detail=str(e))
return EnricherTemplateGenerateResponse(yaml_content=yaml_content)
@router.post("/{template_id}/test", response_model=EnricherTemplateTestResponse)
async def test_template(
template_id: UUID,
test_request: EnricherTemplateTestRequest,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""Test an enricher template with a sample input value."""
service = create_enricher_template_service(db)
try:
db_template = service.get_template(template_id, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Template not found")
try:
content = db_template.content
template = Template(**content)
vault = Vault(db=db, owner_id=current_user.id)
enricher = TemplateEnricher(
sketch_id="123", scan_id="123", template=template, vault=vault
)
await enricher.async_init()
pre = enricher.preprocess([test_request.input_value])
results = await enricher.scan(pre)
data = {"results": results, "raw_results": enricher.get_raw_response()}
return EnricherTemplateTestResponse(
success=True, data=data, status_code=200, url=template.request.url
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"An error occured : {e}")
@router.get("/{template_id}", response_model=EnricherTemplateRead)
def get_template(
template_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""Get a specific enricher template by ID."""
service = create_enricher_template_service(db)
try:
return service.get_template(template_id, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Template not found")
@router.put("/{template_id}", response_model=EnricherTemplateRead)
def update_template(
template_id: UUID,
update_data: EnricherTemplateUpdate,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""Update an enricher template. Only the owner can update."""
service = create_enricher_template_service(db)
try:
return service.update_template(
template_id=template_id,
owner_id=current_user.id,
update_data=update_data.model_dump(exclude_unset=True),
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Template not found")
except ConflictError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_template(
template_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""Delete an enricher template. Only the owner can delete."""
service = create_enricher_template_service(db)
try:
service.delete_template(template_id, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Template not found")
return None

View File

@@ -1,38 +1,24 @@
from typing import Any, List, Optional
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from flowsint_core.core.celery import celery
from flowsint_core.core.graph import create_graph_service, GraphEdge, GraphNode
from flowsint_core.core.models import CustomType, Profile
from flowsint_core.core.graph import create_graph_service
from flowsint_core.core.models import Profile
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.types import FlowBranch
from flowsint_core.core.services import (
create_enricher_service,
create_enricher_template_service,
)
from flowsint_core.core.services.type_registry_service import create_type_registry_service
from flowsint_enrichers import ENRICHER_REGISTRY, load_all_enrichers
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
# Auto-discover and register all enrichers
load_all_enrichers()
class FlowComputationRequest(BaseModel):
nodes: List[GraphNode]
edges: List[GraphEdge]
inputType: Optional[str] = None
class FlowComputationResponse(BaseModel):
flowBranches: List[FlowBranch]
initialData: Any
class StepSimulationRequest(BaseModel):
flowBranches: List[FlowBranch]
currentStepIndex: int
class launchEnricherPayload(BaseModel):
node_ids: List[str]
sketch_id: str
@@ -41,51 +27,57 @@ class launchEnricherPayload(BaseModel):
router = APIRouter()
# Get the list of all enrichers
@router.get("/")
@router.get("")
def get_enrichers(
category: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
if not category or category.lower() == "undefined":
return ENRICHER_REGISTRY.list(exclude=["n8n_connector"])
# Si catégorie custom
custom_type = (
db.query(CustomType)
.filter(
CustomType.owner_id == current_user.id,
CustomType.status == "published",
func.lower(CustomType.name) == category.lower(),
)
.first()
"""Get all enrichers, optionally filtered by category."""
enricher_service = create_enricher_service(db)
return enricher_service.get_all_enrichers(
category, current_user.id, ENRICHER_REGISTRY
)
if custom_type:
return ENRICHER_REGISTRY.list(exclude=["n8n_connector"], wobbly_type=True)
return ENRICHER_REGISTRY.list_by_input_type(category, exclude=["n8n_connector"])
@router.post("/{enricher_name}/launch")
async def launch_enricher(
enricher_name: str,
payload: launchEnricherPayload,
current_user: Profile = Depends(get_current_user),
db: Session = Depends(get_db),
):
try:
# Retrieve nodes from Neo4J by their element IDs
graph_service = create_graph_service(sketch_id=payload.sketch_id)
type_registry = create_type_registry_service(db)
resolver = type_registry.build_type_resolver(current_user.id)
graph_service = create_graph_service(sketch_id=payload.sketch_id, type_resolver=resolver)
entities = graph_service.get_nodes_by_ids_for_task(payload.node_ids)
# send deserialized nodes
entities = [entity.model_dump(mode="json", serialize_as_any=True) for entity in entities]
# Send deserialized nodes
entities = [
entity.model_dump(mode="json", serialize_as_any=True) for entity in entities
]
if not entities:
raise HTTPException(
status_code=404, detail="No entities found with provided IDs"
)
is_template = False
enricher_in_registry = ENRICHER_REGISTRY.enricher_exists(enricher_name)
if not enricher_in_registry:
template_service = create_enricher_template_service(db)
template = template_service.find_by_name(enricher_name, current_user.id)
if not template:
raise HTTPException(
status_code=404,
detail=f"Enricher '{enricher_name}' not found",
)
is_template = True
task_name = "run_template_enricher" if is_template else "run_enricher"
task = celery.send_task(
"run_enricher",
task_name,
args=[
enricher_name,
entities,

View File

@@ -1,16 +1,20 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.models import Log, Sketch, Scan
from flowsint_core.core.events import event_emitter
from sse_starlette.sse import EventSourceResponse
from flowsint_core.core.types import Event
from app.api.deps import get_current_user, get_current_user_sse
from flowsint_core.core.models import Profile, Sketch
from app.security.permissions import check_investigation_permission
import json
import asyncio
from datetime import datetime, timedelta
from datetime import datetime
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.events import event_emitter
from flowsint_core.core.models import Profile
from flowsint_core.core.services import (
create_log_service,
NotFoundError,
PermissionDeniedError,
DatabaseError,
)
from app.api.deps import get_current_user
router = APIRouter()
@@ -21,62 +25,16 @@ def get_logs_by_sketch(
limit: int = 100,
since: datetime | None = None,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user)
current_user: Profile = Depends(get_current_user),
):
"""Get historical logs for a specific sketch with optional filtering"""
# Check if sketch exists
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(
status_code=404, detail=f"Sketch with id {sketch_id} not found"
)
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["read"], db=db
)
print(
f"[EventEmitter] Fetching logs for sketch {sketch_id} (limit: {limit}, since: {since})"
)
query = (
db.query(Log).filter(Log.sketch_id == sketch_id).order_by(Log.created_at.desc())
)
if since:
query = query.filter(Log.created_at > since)
else:
# Default to last 24 hours if no since parameter
query = query.filter(Log.created_at > datetime.utcnow() - timedelta(days=1))
logs = query.limit(limit).all()
# Reverse to show chronologically (oldest to newest)
logs = list(reversed(logs))
results = []
for log in logs:
# Ensure payload is always a dictionary
if isinstance(log.content, dict):
payload = log.content
elif isinstance(log.content, str):
payload = {"message": log.content}
elif log.content is None:
payload = {}
else:
# Handle other types by converting to string and wrapping
payload = {"content": str(log.content)}
results.append(
Event(
id=str(log.id),
sketch_id=str(log.sketch_id) if log.sketch_id else None,
type=log.type,
payload=payload,
created_at=log.created_at
)
)
return results
"""Get historical logs for a specific sketch with optional filtering."""
service = create_log_service(db)
try:
return service.get_logs_by_sketch(sketch_id, current_user.id, limit, since)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
@router.get("/sketch/{sketch_id}/stream")
@@ -84,42 +42,35 @@ async def stream_events(
request: Request,
sketch_id: str,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user_sse)
current_user: Profile = Depends(get_current_user),
):
"""Stream events for a specific scan in real-time"""
# Check if sketch exists
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(
status_code=404, detail=f"Sketch with id {sketch_id} not found"
)
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["read"], db=db
)
"""Stream events for a specific sketch in real-time."""
service = create_log_service(db)
try:
# Verify permission
service._get_sketch_with_permission(sketch_id, current_user.id, ["read"])
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
async def event_generator():
channel = sketch_id
await event_emitter.subscribe(channel)
try:
# Initial connection message
yield 'data: {"event": "connected", "data": "Connected to log stream"}\n\n'
yield json.dumps({"event": "connected", "data": "Connected to log stream"})
while True:
if await request.is_disconnected():
break
data = await event_emitter.get_message(channel)
if data is None:
await asyncio.sleep(0.1) # avoid tight loop on None
await asyncio.sleep(0.1)
continue
# Handle different types of events
if isinstance(data, dict) and data.get("type") == "enricher_complete":
# Send enricher completion event
yield json.dumps({"event": "enricher_complete", "data": data})
else:
# Send regular log event
yield json.dumps({"event": "log", "data": data})
await asyncio.sleep(0.1)
@@ -147,25 +98,16 @@ def delete_scan_logs(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""Delete all logs for a specific scan"""
# Check if sketch exists and user has permission
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(
status_code=404, detail=f"Sketch with id {sketch_id} not found"
)
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["delete"], db=db
)
"""Delete all logs for a specific sketch."""
service = create_log_service(db)
try:
db.query(Log).filter(Log.sketch_id == sketch_id).delete()
db.commit()
return {"message": f"All logs have been deleted successfully"}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to delete logs: {str(e)}")
return service.delete_logs_by_sketch(sketch_id, current_user.id)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except DatabaseError as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/sketch/{sketch_id}/status/stream")
@@ -173,26 +115,21 @@ async def stream_sketch_status(
request: Request,
sketch_id: str,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user_sse)
current_user: Profile = Depends(get_current_user),
):
"""Stream COMPLETED events for a specific sketch (for graph refresh)"""
# Check if sketch exists
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(
status_code=404, detail=f"Sketch with id {sketch_id} not found"
)
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["read"], db=db
)
"""Stream COMPLETED events for a specific sketch (for graph refresh)."""
service = create_log_service(db)
try:
service._get_sketch_with_permission(sketch_id, current_user.id, ["read"])
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
async def status_generator():
channel = f"{sketch_id}_status"
await event_emitter.subscribe(channel)
try:
# Initial connection message
yield json.dumps({"event": "connected", "data": "Connected to status stream"})
while True:
@@ -204,7 +141,6 @@ async def stream_sketch_status(
await asyncio.sleep(0.1)
continue
# Send status event
yield json.dumps({"event": "status", "data": data})
await asyncio.sleep(0.1)
@@ -224,59 +160,3 @@ async def stream_sketch_status(
"X-Accel-Buffering": "no",
},
)
@router.get("/status/scan/{scan_id}/stream")
async def stream_status(
request: Request,
scan_id: str,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user_sse)
):
"""Stream status updates for a specific scan in real-time"""
# Check if scan exists and user has permission
scan = db.query(Scan).filter(Scan.id == scan_id).first()
if not scan:
raise HTTPException(
status_code=404, detail=f"Scan with id {scan_id} not found"
)
# Check investigation permission via sketch
sketch = db.query(Sketch).filter(Sketch.id == scan.sketch_id).first()
if sketch:
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["read"], db=db
)
async def status_generator():
print("[EventEmitter] Start status generator")
await event_emitter.subscribe(f"scan_{scan_id}_status")
try:
# Initial connection message
yield 'data: {"event": "connected", "data": "Connected to status stream"}\n\n'
while True:
data = await event_emitter.get_message(f"scan_{scan_id}_status")
if data is None:
await asyncio.sleep(0.1)
continue
print(f"[EventEmitter] Received status data: {data}")
yield f"data: {data}\n\n"
except asyncio.CancelledError:
print(
f"[EventEmitter] Client disconnected from status stream for scan_id: {scan_id}"
)
finally:
await event_emitter.unsubscribe(f"scan_{scan_id}_status")
return EventSourceResponse(
status_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

View File

@@ -1,13 +1,19 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query, status
from uuid import UUID
# Auto-discover and register all enrichers
from fastapi import APIRouter, Depends, HTTPException, Query, status
from flowsint_core.core.celery import celery
from flowsint_core.core.graph import create_graph_service
from flowsint_core.core.models import CustomType, Flow, Profile, Sketch
from flowsint_core.core.models import Profile
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.services import (
NotFoundError,
PermissionDeniedError,
create_flow_service,
)
from flowsint_core.core.services.type_registry_service import (
create_type_registry_service,
)
from flowsint_core.core.types import FlowBranch, FlowEdge, FlowNode, FlowStep
from flowsint_core.utils import extract_input_schema_flow
from flowsint_enrichers import ENRICHER_REGISTRY, load_all_enrichers
@@ -31,12 +37,10 @@ from flowsint_types import (
Website,
)
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.api.schemas.flow import FlowCreate, FlowRead, FlowUpdate
from app.security.permissions import check_investigation_permission
load_all_enrichers()
@@ -65,43 +69,16 @@ class launchFlowPayload(BaseModel):
router = APIRouter()
@router.get("/", response_model=List[FlowRead])
@router.get("", response_model=List[FlowRead])
def get_flows(
category: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
if not category or category.lower() == "undefined":
return db.query(Flow).order_by(Flow.last_updated_at.desc()).all()
custom_type = (
db.query(CustomType)
.filter(
CustomType.owner_id == current_user.id,
CustomType.status == "published",
func.lower(CustomType.name) == category.lower(),
)
.first()
)
if custom_type:
flows = db.query(Flow).order_by(Flow.last_updated_at.desc()).all()
return [
{
**(flow.to_dict() if hasattr(flow, "to_dict") else flow.__dict__),
"wobblyType": True,
}
for flow in flows
]
flows = db.query(Flow).order_by(Flow.last_updated_at.desc()).all()
return [
flow
for flow in flows
if any(cat.lower() == category.lower() for cat in flow.category)
]
service = create_flow_service(db)
return service.get_all_flows(category, current_user.id)
# Returns the "raw_materials" for the flow editor
@router.get("/raw_materials")
async def get_material_list():
enrichers = ENRICHER_REGISTRY.list_by_categories()
@@ -147,56 +124,46 @@ async def get_material_list():
extract_input_schema_flow(CryptoNFT),
]
# Put types first, then add all enricher categories
flattened_enrichers = {"types": object_inputs}
flattened_enrichers.update(enricher_categories)
return {"items": flattened_enrichers}
# Returns the "raw_materials" for the flow editor
@router.get("/input_type/{input_type}")
async def get_material_list(input_type: str):
async def get_material_by_input_type(input_type: str):
enrichers = ENRICHER_REGISTRY.list_by_input_type(input_type)
return {"items": enrichers}
# Create a new flow
@router.post("/create", response_model=FlowRead, status_code=status.HTTP_201_CREATED)
def create_flow(
payload: FlowCreate,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
new_flow = Flow(
id=uuid4(),
service = create_flow_service(db)
return service.create(
name=payload.name,
description=payload.description,
category=payload.category,
flow_schema=payload.flow_schema,
created_at=datetime.utcnow(),
last_updated_at=datetime.utcnow(),
)
db.add(new_flow)
db.commit()
db.refresh(new_flow)
return new_flow
# Get a flow by ID
@router.get("/{flow_id}", response_model=FlowRead)
def get_flow_by_id(
flow_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
flow = db.query(Flow).filter(Flow.id == flow_id).first()
if not flow:
raise HTTPException(status_code=404, detail="flow not found")
return flow
service = create_flow_service(db)
try:
return service.get_by_id(flow_id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Flow not found")
# Update a flow by ID
@router.put("/{flow_id}", response_model=FlowRead)
def update_flow(
flow_id: UUID,
@@ -204,37 +171,25 @@ def update_flow(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
flow = db.query(Flow).filter(Flow.id == flow_id).first()
if not flow:
raise HTTPException(status_code=404, detail="flow not found")
update_data = payload.model_dump(exclude_unset=True)
for key, value in update_data.items():
print(f"only update {key}")
if key == "category":
if "SocialAccount" in value:
value.append("Username")
setattr(flow, key, value)
flow.last_updated_at = datetime.utcnow()
db.commit()
db.refresh(flow)
return flow
service = create_flow_service(db)
try:
return service.update(flow_id, payload.model_dump(exclude_unset=True))
except NotFoundError:
raise HTTPException(status_code=404, detail="Flow not found")
# Delete a flow by ID
@router.delete("/{flow_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_flow(
flow_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
flow = db.query(Flow).filter(Flow.id == flow_id).first()
if not flow:
raise HTTPException(status_code=404, detail="flow not found")
db.delete(flow)
db.commit()
return None
service = create_flow_service(db)
try:
service.delete(flow_id)
return None
except NotFoundError:
raise HTTPException(status_code=404, detail="Flow not found")
@router.post("/{flow_id}/launch")
@@ -244,35 +199,31 @@ async def launch_flow(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_flow_service(db)
try:
flow = db.query(Flow).filter(Flow.id == flow_id).first()
if flow is None:
raise HTTPException(status_code=404, detail="flow not found")
# Check investigation permission via sketch
sketch = db.query(Sketch).filter(Sketch.id == payload.sketch_id).first()
if not sketch:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["update"], db=db
)
flow = service.get_by_id(UUID(flow_id))
service.get_sketch_for_launch(payload.sketch_id, current_user.id)
# Retrieve entities from Neo4J by their element IDs
graph_service = create_graph_service(sketch_id=payload.sketch_id)
type_registry = create_type_registry_service(db)
resolver = type_registry.build_type_resolver(current_user.id)
graph_service = create_graph_service(
sketch_id=payload.sketch_id, type_resolver=resolver
)
entities = graph_service.get_nodes_by_ids_for_task(payload.node_ids)
# Compute flow branches
nodes = [FlowNode(**node) for node in flow.flow_schema["nodes"]]
edges = [FlowEdge(**edge) for edge in flow.flow_schema["edges"]]
entities = [entity.model_dump(mode="json", serialize_as_any=True) for entity in entities]
entities = [
entity.model_dump(mode="json", serialize_as_any=True) for entity in entities
]
# For flow computation, we still need a sample value
# Use the label from the first node data
sample_value = (
entities[0].get("nodeLabel", "sample_value") if len(entities) else "sample_value"
entities[0].get("nodeLabel", "sample_value")
if len(entities)
else "sample_value"
)
flow_branches = compute_flow_branches(sample_value, nodes, edges)
serializable_branches = [branch.model_dump() for branch in flow_branches]
@@ -288,8 +239,10 @@ async def launch_flow(
)
return {"id": task.id}
except HTTPException:
raise
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except Exception as e:
print(e)
raise HTTPException(status_code=500, detail=f"Error launching flow: {str(e)}")
@@ -332,7 +285,6 @@ def compute_flow_branches(
initial_value: Any, nodes: List[FlowNode], edges: List[FlowEdge]
) -> List[FlowBranch]:
"""Computes flow branches based on nodes and edges with proper DFS traversal"""
# Find input nodes (starting points)
input_nodes = [node for node in nodes if node.data.get("type") == "type"]
if not input_nodes:
@@ -344,6 +296,7 @@ def compute_flow_branches(
FlowStep(
nodeId="error",
inputs={},
params={},
type="error",
outputs={},
status="error",
@@ -357,34 +310,25 @@ def compute_flow_branches(
node_map = {node.id: node for node in nodes}
branches = []
branch_counter = 0
# Track enricher outputs across all branches
enricher_outputs = {}
def calculate_path_length(start_node: str, visited: set = None) -> int:
"""Calculate the shortest possible path length from a node to any leaf"""
if visited is None:
visited = set()
if start_node in visited:
return float("inf")
visited.add(start_node)
out_edges = [edge for edge in edges if edge.source == start_node]
if not out_edges:
return 1
min_length = float("inf")
for edge in out_edges:
length = calculate_path_length(edge.target, visited.copy())
min_length = min(min_length, length)
return 1 + min_length
def get_outgoing_edges(node_id: str) -> List[FlowEdge]:
"""Get outgoing edges sorted by the shortest possible path length"""
out_edges = [edge for edge in edges if edge.source == node_id]
# Sort edges by the length of the shortest possible path from their target
return sorted(out_edges, key=lambda e: calculate_path_length(e.target))
def create_step(
@@ -420,7 +364,6 @@ def compute_flow_branches(
) -> None:
nonlocal branch_counter
# Skip if node is already in current path (cycle detection)
if current_node_id in path:
return
@@ -428,7 +371,6 @@ def compute_flow_branches(
if not current_node:
return
# Process node outputs
is_input_node = current_node.data.get("type") == "type"
if is_input_node:
outputs_array = current_node.data["outputs"].get("properties", [])
@@ -437,18 +379,14 @@ def compute_flow_branches(
)
current_outputs = {first_output_name: initial_value}
else:
# Check if we already have outputs for this enricher
if current_node_id in enricher_outputs:
current_outputs = enricher_outputs[current_node_id]
else:
current_outputs = process_node_data(current_node, input_data)
# Store the outputs for future use
enricher_outputs[current_node_id] = current_outputs
# Extract node parameters
node_params = current_node.data.get("params", {})
# Create and add current step
current_step = create_step(
current_node_id,
branch_id,
@@ -462,19 +400,15 @@ def compute_flow_branches(
path.append(current_node_id)
branch_visited.add(current_node_id)
# Get all outgoing edges sorted by path length
out_edges = get_outgoing_edges(current_node_id)
if not out_edges:
# Leaf node reached, save the branch
branches.append(FlowBranch(id=branch_id, name=branch_name, steps=steps[:]))
else:
# Process each outgoing edge in order of shortest path
for i, edge in enumerate(out_edges):
if edge.target in path: # Skip if would create cycle
if edge.target in path:
continue
# Prepare next node's input
output_key = edge.sourceHandle
if not output_key and current_outputs:
output_key = list(current_outputs.keys())[0]
@@ -488,7 +422,6 @@ def compute_flow_branches(
next_input = {edge.targetHandle or "input": output_value}
if i == 0:
# Continue in same branch (will be shortest path)
explore_branch(
edge.target,
branch_id,
@@ -501,31 +434,26 @@ def compute_flow_branches(
current_outputs,
)
else:
# Create new branch starting from current node
branch_counter += 1
new_branch_id = f"{branch_id}-{branch_counter}"
new_branch_name = f"{branch_name} (Branch {branch_counter})"
new_steps = steps[: len(steps)] # Copy steps up to current node
new_branch_visited = (
branch_visited.copy()
) # Create new visited set for the branch
new_steps = steps[: len(steps)]
new_branch_visited = branch_visited.copy()
explore_branch(
edge.target,
new_branch_id,
new_branch_name,
depth + 1,
next_input,
path[:], # Create new path copy for branch
path[:],
new_branch_visited,
new_steps,
current_outputs,
)
# Backtrack: remove current node from path and remove its step
path.pop()
steps.pop()
# Start exploration from each input node
for index, input_node in enumerate(input_nodes):
branch_id = f"branch-{index}"
branch_name = f"Flow {index + 1}" if len(input_nodes) > 1 else "Main Flow"
@@ -535,76 +463,61 @@ def compute_flow_branches(
branch_name,
0,
{},
[], # Use list for path to maintain order
set(), # Use set for visited to check membership
[],
set(),
[],
None,
)
# Sort branches by length (number of steps)
branches.sort(key=lambda branch: len(branch.steps))
return branches
def process_node_data(node: FlowNode, inputs: Dict[str, Any]) -> Dict[str, Any]:
"""Traite les données de nœud en fonction du type de nœud et des entrées"""
"""Process node data based on node type and inputs"""
outputs = {}
output_types = node.data["outputs"].get("properties", [])
for output in output_types:
output_name = output.get("name", "output")
class_name = node.data.get("class_name", "")
# For simulation purposes, we'll return a placeholder value based on the enricher type
if class_name in ["ReverseResolveEnricher", "ResolveEnricher"]:
# IP/Domain resolution enrichers
outputs[output_name] = (
"192.168.1.1" if "ip" in output_name.lower() else "example.com"
)
elif class_name == "SubdomainEnricher":
# Subdomain enricher
outputs[output_name] = f"sub.{inputs.get('input', 'example.com')}"
elif class_name == "WhoisEnricher":
# WHOIS enricher
outputs[output_name] = {
"domain": inputs.get("input", "example.com"),
"registrar": "Example Registrar",
"creation_date": "2020-01-01",
}
elif class_name == "IpToInfosEnricher":
# Geolocation enricher
outputs[output_name] = {
"country": "France",
"city": "Paris",
"coordinates": {"lat": 48.8566, "lon": 2.3522},
}
elif class_name == "MaigretEnricher":
# Social media enricher
outputs[output_name] = {
"username": inputs.get("input", "user123"),
"platforms": ["twitter", "github", "linkedin"],
}
elif class_name == "HoleheEnricher":
# Email verification enricher
outputs[output_name] = {
"email": inputs.get("input", "user@example.com"),
"exists": True,
"platforms": ["gmail", "github"],
}
elif class_name == "SireneEnricher":
# Organization enricher
outputs[output_name] = {
"name": inputs.get("input", "Example Corp"),
"siret": "12345678901234",
"address": "1 Example Street",
}
else:
# For unknown enrichers, pass through the input
outputs[output_name] = inputs.get("input") or f"flowed_{output_name}"
return outputs

View File

@@ -1,81 +1,58 @@
from uuid import UUID, uuid4
from app.security.permissions import check_investigation_permission
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends, status
from typing import List
from datetime import datetime
from sqlalchemy.orm import Session
from flowsint_core.core.types import Role
from sqlalchemy import or_
from sqlalchemy.orm import Session, selectinload
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.models import (
Analysis,
Investigation,
InvestigationUserRole,
Profile,
Sketch,
from flowsint_core.core.models import Profile
from flowsint_core.core.services import (
create_investigation_service,
NotFoundError,
PermissionDeniedError,
ConflictError,
DatabaseError,
)
from app.api.deps import get_current_user
from app.api.schemas.investigation import (
InvestigationRead,
InvestigationCreate,
InvestigationUpdate,
CollaboratorAdd,
CollaboratorUpdate,
CollaboratorRead,
)
from app.api.schemas.sketch import SketchRead
from flowsint_core.core.graph import create_graph_service
router = APIRouter()
def get_user_accessible_investigations(
user_id: str, db: Session, allowed_roles: list[Role] = None
) -> list[Investigation]:
"""
Returns all investigations accessible to user depending on its roles
"""
query = db.query(Investigation).join(
InvestigationUserRole,
InvestigationUserRole.investigation_id == Investigation.id,
)
query = query.filter(InvestigationUserRole.user_id == user_id)
if allowed_roles:
# ARRAY(Role) contains any of allowed_roles
conditions = [InvestigationUserRole.roles.any(role) for role in allowed_roles]
# Inclut également le propriétaire de linvestigation
query = query.filter(or_(*conditions, Investigation.owner_id == user_id))
return (
query.options(
selectinload(Investigation.sketches),
selectinload(Investigation.analyses),
selectinload(Investigation.owner),
)
.distinct()
.all()
)
def _inject_current_user_role(service, investigation, user_id) -> InvestigationRead:
"""Build InvestigationRead with the current user's role attached."""
result = InvestigationRead.model_validate(investigation)
role_entry = service.get_user_role_for_investigation(user_id, investigation.id)
if role_entry and role_entry.roles:
result.current_user_role = role_entry.roles[0].value
return result
# Get the list of all investigations
@router.get("", response_model=List[InvestigationRead])
def get_investigations(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""
Récupère toutes les investigations accessibles à l'utilisateur
selon ses rôles (OWNER, EDITOR, VIEWER).
"""
allowed_roles_for_read = [Role.OWNER, Role.EDITOR, Role.VIEWER]
investigations = get_user_accessible_investigations(
user_id=current_user.id, db=db, allowed_roles=allowed_roles_for_read
"""Get all investigations accessible to the user based on their roles."""
service = create_investigation_service(db)
allowed_roles = [Role.OWNER, Role.ADMIN, Role.EDITOR, Role.VIEWER]
investigations = service.get_accessible_investigations(
user_id=current_user.id, allowed_roles=allowed_roles
)
return investigations
return [
_inject_current_user_role(service, inv, current_user.id)
for inv in investigations
]
# Create a new investigation
@router.post(
"/create", response_model=InvestigationRead, status_code=status.HTTP_201_CREATED
)
@@ -84,73 +61,48 @@ def create_investigation(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
new_investigation = Investigation(
id=uuid4(),
service = create_investigation_service(db)
investigation = service.create(
name=payload.name,
description=payload.description or payload.name,
description=payload.description,
owner_id=current_user.id,
status="active",
)
db.add(new_investigation)
new_roles = InvestigationUserRole(
id=uuid4(),
user_id=current_user.id,
investigation_id=new_investigation.id,
roles=[Role.OWNER],
)
db.add(new_roles)
db.commit()
db.refresh(new_investigation)
db.refresh(new_roles)
return new_investigation
return _inject_current_user_role(service, investigation, current_user.id)
# Get a investigation by ID
@router.get("/{investigation_id}", response_model=InvestigationRead)
def get_investigation_by_id(
investigation_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
check_investigation_permission(current_user.id, investigation_id, actions=["read"], db=db)
investigation = (
db.query(Investigation)
.options(
selectinload(Investigation.sketches),
selectinload(Investigation.analyses),
selectinload(Investigation.owner),
)
.filter(Investigation.id == investigation_id)
.filter(Investigation.owner_id == current_user.id)
.first()
)
if not investigation:
service = create_investigation_service(db)
try:
investigation = service.get_by_id(investigation_id, current_user.id)
return _inject_current_user_role(service, investigation, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Investigation not found")
return investigation
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
# Get a investigation by ID
@router.get("/{investigation_id}/sketches", response_model=List[SketchRead])
def get_sketches_by_investigation(
investigation_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
check_investigation_permission(current_user.id, investigation_id, actions=["read"], db=db)
sketches = (
db.query(Sketch).filter(Sketch.investigation_id == investigation_id).all()
)
if not sketches:
service = create_investigation_service(db)
try:
return service.get_sketches(investigation_id, current_user.id)
except NotFoundError:
raise HTTPException(
status_code=404, detail="No sketches found for this investigation"
)
return sketches
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
# Update a investigation by ID
@router.put("/{investigation_id}", response_model=InvestigationRead)
def update_investigation(
investigation_id: UUID,
@@ -158,69 +110,158 @@ def update_investigation(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
check_investigation_permission(current_user.id, investigation_id, actions=["write"], db=db)
investigation = (
db.query(Investigation).filter(Investigation.id == investigation_id).first()
)
if not investigation:
service = create_investigation_service(db)
try:
investigation = service.update(
investigation_id=investigation_id,
user_id=current_user.id,
name=payload.name,
description=payload.description,
status=payload.status,
)
return _inject_current_user_role(service, investigation, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Investigation not found")
investigation.name = payload.name
investigation.description = payload.description
investigation.status = payload.status
investigation.last_updated_at = datetime.utcnow()
db.commit()
db.refresh(investigation)
return investigation
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
# Delete a investigation by ID
@router.delete("/{investigation_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_investigation(
investigation_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
check_investigation_permission(current_user.id, investigation_id, actions=["delete"], db=db)
investigation = (
db.query(Investigation)
.filter(
Investigation.id == investigation_id,
Investigation.owner_id == current_user.id,
)
.first()
)
if not investigation:
service = create_investigation_service(db)
try:
service.delete(investigation_id, current_user.id)
return None
except NotFoundError:
raise HTTPException(status_code=404, detail="Investigation not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except DatabaseError:
raise HTTPException(status_code=500, detail="Failed to clean up graph data")
# Get all sketches related to this investigation
sketches = (
db.query(Sketch).filter(Sketch.investigation_id == investigation_id).all()
)
analyses = (
db.query(Analysis).filter(Sketch.investigation_id == investigation_id).all()
)
# Delete all nodes and relationships for each sketch in Neo4j using GraphService
for sketch in sketches:
try:
graph_service = create_graph_service(
sketch_id=str(sketch.id),
enable_batching=False,
# ── Collaborator endpoints ───────────────────────────────────────────
@router.get(
"/{investigation_id}/collaborators", response_model=List[CollaboratorRead]
)
def get_collaborators(
investigation_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_investigation_service(db)
try:
entries = service.get_collaborators(investigation_id, current_user.id)
return [
CollaboratorRead(
id=e.id,
user_id=e.user_id,
roles=[r.value for r in e.roles],
user=e.user,
)
graph_service.delete_all_sketch_nodes()
except Exception as e:
print(f"Neo4j cleanup error for sketch {sketch.id}: {e}")
raise HTTPException(status_code=500, detail="Failed to clean up graph data")
for e in entries
]
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
# Delete all sketches from PostgreSQL
for sketch in sketches:
db.delete(sketch)
for analysis in analyses:
db.delete(analysis)
# Finally delete the investigation
db.delete(investigation)
db.commit()
return None
@router.post(
"/{investigation_id}/collaborators",
response_model=CollaboratorRead,
status_code=status.HTTP_201_CREATED,
)
def add_collaborator(
investigation_id: UUID,
payload: CollaboratorAdd,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_investigation_service(db)
try:
role = Role(payload.role.lower())
except ValueError:
raise HTTPException(status_code=400, detail="Invalid role")
try:
entry = service.add_collaborator(
investigation_id=investigation_id,
user_id=current_user.id,
target_email=payload.email,
role=role,
)
return CollaboratorRead(
id=entry.id,
user_id=entry.user_id,
roles=[r.value for r in entry.roles],
user=entry.user,
)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e.message))
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except ConflictError:
raise HTTPException(status_code=409, detail="User is already a collaborator")
@router.put(
"/{investigation_id}/collaborators/{user_id}",
response_model=CollaboratorRead,
)
def update_collaborator_role(
investigation_id: UUID,
user_id: UUID,
payload: CollaboratorUpdate,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_investigation_service(db)
try:
role = Role(payload.role.lower())
except ValueError:
raise HTTPException(status_code=400, detail="Invalid role")
try:
entry = service.update_collaborator_role(
investigation_id=investigation_id,
user_id=current_user.id,
target_user_id=user_id,
role=role,
)
return CollaboratorRead(
id=entry.id,
user_id=entry.user_id,
roles=[r.value for r in entry.roles],
user=entry.user,
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Collaborator not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
@router.delete(
"/{investigation_id}/collaborators/{user_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def remove_collaborator(
investigation_id: UUID,
user_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_investigation_service(db)
try:
service.remove_collaborator(
investigation_id=investigation_id,
user_id=current_user.id,
target_user_id=user_id,
)
return None
except NotFoundError:
raise HTTPException(status_code=404, detail="Collaborator not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")

View File

@@ -1,23 +1,29 @@
from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException, Depends, status
from typing import List
from flowsint_core.core.vault import Vault
from sqlalchemy.orm import Session
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from flowsint_core.core.models import Profile
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.models import Profile, Key
from flowsint_core.core.services import (
DatabaseError,
NotFoundError,
create_key_service,
)
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.api.schemas.key import KeyRead, KeyCreate
from app.api.schemas.key import KeyCreate, KeyExists, KeyRead
router = APIRouter()
# Get the list of all keys for a user, just the public method for viewing
@router.get("", response_model=List[KeyRead])
def get_keys(
db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
):
keys = db.query(Key).filter(Key.owner_id == current_user.id).all()
response_data = [
service = create_key_service(db)
keys = service.get_keys_for_user(current_user.id)
return [
KeyRead(
id=key.id,
owner_id=key.owner_id,
@@ -26,61 +32,74 @@ def get_keys(
)
for key in keys
]
return response_data
# Get a key by ID, just the public method for viewing
@router.get("/chat-key-exists", response_model=KeyExists)
def chat_key_exists(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""
A simple util route to know if any ai chat key exists in the vault for this user
"""
service = create_key_service(db)
try:
key_exists = service.chat_key_exist(current_user.id)
return KeyExists(exists=key_exists)
except NotFoundError as e:
print(e)
return KeyExists(exists=False)
@router.get("/{id}", response_model=KeyRead)
def get_key_by_id(
id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
key = db.query(Key).filter(Key.id == id, Key.owner_id == current_user.id).first()
if not key:
service = create_key_service(db)
try:
key = service.get_key_by_id(id, current_user.id)
return KeyRead(
id=key.id,
owner_id=key.owner_id,
name=key.name,
created_at=key.created_at,
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Key not found")
# Create a response with obfuscated key
response_data = KeyRead(
id=key.id,
owner_id=key.owner_id,
name=key.name,
created_at=key.created_at,
)
return response_data
# Create a new key
@router.post("/create", response_model=KeyRead, status_code=status.HTTP_201_CREATED)
def create_key(
payload: KeyCreate,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_key_service(db)
try:
vault = Vault(db=db, owner_id=current_user.id)
key = vault.set_secret(vault_ref=payload.name, plain_key=payload.key)
if not key:
raise HTTPException(
status_code=500, detail="An error occured creating the key."
)
return key
except Exception as e:
key = service.create_key(payload.name, payload.key, current_user.id)
return KeyRead(
id=key.id,
owner_id=key.owner_id,
name=key.name,
created_at=key.created_at,
)
except DatabaseError:
raise HTTPException(
status_code=500, detail="An error occured creating the key."
status_code=500, detail="An error occurred creating the key."
)
# Delete a key by ID
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_key(
id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
key = db.query(Key).filter(Key.id == id, Key.owner_id == current_user.id).first()
if not key:
service = create_key_service(db)
try:
service.delete_key(id, current_user.id)
return None
except NotFoundError:
raise HTTPException(status_code=404, detail="Key not found")
db.delete(key)
db.commit()
return None

View File

@@ -1,84 +1,59 @@
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends, status
from typing import List
from sqlalchemy import or_
from sqlalchemy.orm import Session
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from flowsint_core.core.models import Profile
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.models import Scan, Profile, Sketch, InvestigationUserRole
from flowsint_core.core.types import Role
from flowsint_core.core.services import (
NotFoundError,
PermissionDeniedError,
create_scan_service,
)
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.api.schemas.scan import ScanRead
from app.security.permissions import check_investigation_permission
router = APIRouter()
# Get the list of all scans
@router.get(
"",
response_model=List[ScanRead],
)
@router.get("/sketch/{id}", response_model=List[ScanRead])
def get_scans(
db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
# Get all scans from sketches in investigations where user has at least VIEWER role
allowed_roles_for_read = [Role.OWNER, Role.EDITOR, Role.VIEWER]
query = db.query(Scan).join(
Sketch, Sketch.id == Scan.sketch_id
).join(
InvestigationUserRole,
InvestigationUserRole.investigation_id == Sketch.investigation_id,
)
query = query.filter(InvestigationUserRole.user_id == current_user.id)
# Filter by allowed roles
conditions = [InvestigationUserRole.roles.any(role) for role in allowed_roles_for_read]
query = query.filter(or_(*conditions))
return query.distinct().all()
"""Get all scans accessible to the current user, linked to a sketch."""
service = create_scan_service(db)
return service.get_accessible_scans_by_sketch_id(current_user.id, id)
# Get a scan by ID
@router.get("/{id}", response_model=ScanRead)
def get_scan_by_id(
id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
scan = db.query(Scan).filter(Scan.id == id).first()
if not scan:
service = create_scan_service(db)
try:
return service.get_by_id(id, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Scan not found")
# Check investigation permission via sketch
sketch = db.query(Sketch).filter(Sketch.id == scan.sketch_id).first()
if sketch:
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["read"], db=db
)
return scan
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
# Delete a scan by ID
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_scan_by_id(
id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
scan = db.query(Scan).filter(Scan.id == id).first()
if not scan:
service = create_scan_service(db)
try:
service.delete(id, current_user.id)
return None
except NotFoundError:
raise HTTPException(status_code=404, detail="Scan not found")
# Check investigation permission via sketch
sketch = db.query(Sketch).filter(Sketch.id == scan.sketch_id).first()
if sketch:
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["delete"], db=db
)
db.delete(scan)
db.commit()
return None
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")

View File

@@ -11,23 +11,30 @@ from fastapi import (
UploadFile,
status,
)
from flowsint_core.core.graph import create_graph_service, GraphNode
from flowsint_core.core.models import Profile, Sketch
from flowsint_core.core.graph import GraphNode
from flowsint_core.core.models import Profile
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.services import (
create_sketch_service,
NotFoundError,
PermissionDeniedError,
ValidationError,
DatabaseError,
)
from flowsint_core.core.services.type_registry_service import create_type_registry_service
from flowsint_core.imports import (
EntityMapping,
ImportService,
create_import_service,
FileParseResult,
)
from flowsint_core.utils import flatten
from flowsint_core.core.graph import create_graph_service
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.api.schemas.sketch import SketchCreate, SketchRead, SketchUpdate
from app.api.sketch_utils import update_sketch_timestamp
from app.security.permissions import check_investigation_permission
router = APIRouter()
@@ -48,7 +55,6 @@ class RelationshipDeleteInput(BaseModel):
relationshipIds: List[str]
class NodeEditInput(BaseModel):
nodeId: str
updates: Dict[str, Any]
@@ -68,174 +74,6 @@ class NodeMergeInput(BaseModel):
)
@router.post("/create", response_model=SketchRead, status_code=status.HTTP_201_CREATED)
def create_sketch(
data: SketchCreate,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
sketch_data = data.model_dump()
investigation_id = sketch_data.get("investigation_id")
if not investigation_id:
raise HTTPException(status_code=404, detail="Investigation not found")
check_investigation_permission(
current_user.id, investigation_id, actions=["create"], db=db
)
sketch_data["owner_id"] = current_user.id
sketch = Sketch(**sketch_data)
db.add(sketch)
db.commit()
db.refresh(sketch)
return sketch
@router.get("", response_model=List[SketchRead])
def list_sketches(
db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
):
return db.query(Sketch).filter(Sketch.owner_id == current_user.id).all()
@router.get("/{sketch_id}")
def get_sketch_by_id(
sketch_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["read"], db=db
)
return sketch
@router.put("/{id}", response_model=SketchRead)
def update_sketch(
id: UUID,
payload: SketchUpdate,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
sketch = db.query(Sketch).filter(Sketch.id == id).first()
if not sketch:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["update"], db=db
)
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(sketch, key, value)
db.commit()
db.refresh(sketch)
return sketch
@router.delete("/{id}", status_code=204)
def delete_sketch(
id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
sketch = db.query(Sketch).filter(Sketch.id == id).first()
if not sketch:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["delete"], db=db
)
# Delete all nodes and relationships in Neo4j first using GraphService
try:
graph_service = create_graph_service(
sketch_id=str(id), enable_batching=False
)
graph_service.delete_all_sketch_nodes()
except Exception as e:
print(f"Neo4j cleanup error: {e}")
raise HTTPException(status_code=500, detail="Failed to clean up graph data")
# Then delete the sketch from PostgreSQL
db.delete(sketch)
db.commit()
@router.get("/{sketch_id}/graph")
async def get_sketch_nodes(
sketch_id: str,
format: str | None = None,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""
Get the nodes and edges for a sketch.
Args:
id: The ID of the sketch
format: Optional format parameter. If "inline", returns inline relationships
db: The database session
current_user: The current user
Returns:
A dictionary containing the nodes and relationships for the sketch
nds: []
rls: []
Or if format=inline: List of inline relationship strings
"""
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(status_code=404, detail="Graph not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["read"], db=db
)
# Get all nodes and relationships using GraphService
graph_service = create_graph_service(
sketch_id=sketch_id, enable_batching=False
)
graph_data = graph_service.get_sketch_graph()
if format == "inline":
from flowsint_core.utils import get_inline_relationships
return get_inline_relationships(graph_data.nodes, graph_data.edges)
graph = graph_data.model_dump(mode="json", serialize_as_any=True)
return {"nds": graph["nodes"], "rls": graph["edges"]}
@router.post("/{sketch_id}/nodes/add")
@update_sketch_timestamp
def add_node(
sketch_id: str,
node: GraphNode,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["update"], db=db
)
try:
graph_service = create_graph_service(
sketch_id=sketch_id,
enable_batching=False,
)
node_id = graph_service.create_node(node)
except Exception as e:
print(e)
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
if not node_id:
raise HTTPException(status_code=400, detail="Node creation failed")
node.id = node_id
return {
"status": "node added",
"node": node,
}
class RelationInput(BaseModel):
source: str
target: str
@@ -243,88 +81,6 @@ class RelationInput(BaseModel):
label: str = "RELATED_TO"
@router.post("/{sketch_id}/relations/add")
@update_sketch_timestamp
def add_edge(
sketch_id: str,
relation: RelationInput,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["update"], db=db
)
# Create relationship using GraphService
try:
graph_service = create_graph_service(
sketch_id=sketch_id,
enable_batching=False,
)
result = graph_service.create_relationship_by_element_id(
from_element_id=relation.source,
to_element_id=relation.target,
rel_label=relation.label,
)
except Exception as e:
print(f"Edge creation error: {e}")
raise HTTPException(status_code=500, detail="Failed to create edge")
if not result:
raise HTTPException(status_code=400, detail="Edge creation failed")
return {
"status": "edge added",
"edge": result,
}
@router.put("/{sketch_id}/nodes/edit")
@update_sketch_timestamp
def edit_node(
sketch_id: str,
node_edit: NodeEditInput,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
# First verify the sketch exists and belongs to the user
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["update"], db=db
)
updates = node_edit.updates
try:
graph_service = create_graph_service(
sketch_id=sketch_id,
enable_batching=False,
)
updated_element_id = graph_service.update_node(
element_id=node_edit.nodeId,
updates=updates,
)
except Exception as e:
print(f"Node update error: {e}")
raise HTTPException(status_code=500, detail="Failed to update node")
if not updated_element_id:
raise HTTPException(status_code=404, detail="Node not found or not accessible")
return {
"status": "node updated",
"node": {
"id": updated_element_id,
},
}
class NodePosition(BaseModel):
nodeId: str
x: float
@@ -335,6 +91,186 @@ class UpdatePositionsInput(BaseModel):
positions: List[NodePosition]
class EntityMappingInput(BaseModel):
"""Pydantic model for parsing entity mapping input from frontend."""
id: str
entity_type: str
include: bool = True
nodeLabel: str
node_id: Optional[str] = None
data: Dict[str, Any]
class ImportExecuteResponse(BaseModel):
"""Response model for import execution."""
status: str
nodes_created: int
nodes_skipped: int
errors: List[str]
@router.post("/create", response_model=SketchRead, status_code=status.HTTP_201_CREATED)
def create_sketch(
data: SketchCreate,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_sketch_service(db)
try:
sketch_data = data.model_dump()
return service.create(
title=sketch_data.get("title"),
description=sketch_data.get("description"),
investigation_id=sketch_data.get("investigation_id"),
owner_id=current_user.id,
)
except ValidationError as e:
raise HTTPException(status_code=404, detail=str(e))
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
@router.get("", response_model=List[SketchRead])
def list_sketches(
db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
):
service = create_sketch_service(db)
return service.list_sketches(current_user.id)
@router.get("/{sketch_id}")
def get_sketch_by_id(
sketch_id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_sketch_service(db)
try:
return service.get_by_id(sketch_id, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Sketch not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
@router.put("/{id}", response_model=SketchRead)
def update_sketch(
id: UUID,
payload: SketchUpdate,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_sketch_service(db)
try:
return service.update(id, current_user.id, payload.model_dump(exclude_unset=True))
except NotFoundError:
raise HTTPException(status_code=404, detail="Sketch not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
@router.delete("/{id}", status_code=204)
def delete_sketch(
id: UUID,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_sketch_service(db)
try:
service.delete(id, current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Sketch not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except DatabaseError:
raise HTTPException(status_code=500, detail="Failed to clean up graph data")
@router.get("/{sketch_id}/graph")
async def get_sketch_nodes(
sketch_id: str,
format: str | None = None,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""Get the nodes and edges for a sketch."""
service = create_sketch_service(db)
try:
return service.get_graph(UUID(sketch_id), current_user.id, format)
except NotFoundError:
raise HTTPException(status_code=404, detail="Graph not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
@router.post("/{sketch_id}/nodes/add")
@update_sketch_timestamp
def add_node(
sketch_id: str,
node: GraphNode,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_sketch_service(db)
try:
return service.add_node(UUID(sketch_id), current_user.id, node)
except NotFoundError:
raise HTTPException(status_code=404, detail="Sketch not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except ValidationError:
raise HTTPException(status_code=400, detail="Node creation failed")
except DatabaseError as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{sketch_id}/relations/add")
@update_sketch_timestamp
def add_edge(
sketch_id: str,
relation: RelationInput,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_sketch_service(db)
try:
return service.add_relationship(
UUID(sketch_id), current_user.id, relation.source, relation.target, relation.label
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Sketch not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except ValidationError:
raise HTTPException(status_code=400, detail="Edge creation failed")
except DatabaseError:
raise HTTPException(status_code=500, detail="Failed to create edge")
@router.put("/{sketch_id}/nodes/edit")
@update_sketch_timestamp
def edit_node(
sketch_id: str,
node_edit: NodeEditInput,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
service = create_sketch_service(db)
try:
return service.update_node(
UUID(sketch_id), current_user.id, node_edit.nodeId, node_edit.updates
)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except DatabaseError:
raise HTTPException(status_code=500, detail="Failed to update node")
@router.put("/{sketch_id}/nodes/positions")
@update_sketch_timestamp
def update_node_positions(
@@ -344,40 +280,18 @@ def update_node_positions(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""
Update positions (x, y) for multiple nodes in batch.
This is used to persist node positions after drag operations in the graph viewer.
"""
# Verify the sketch exists and user has access
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["update"], db=db
)
if not data.positions:
return {"status": "no positions to update", "count": 0}
# Convert Pydantic models to dicts for GraphService
positions = [pos.model_dump() for pos in data.positions]
# Update positions using GraphService
"""Update positions (x, y) for multiple nodes in batch."""
service = create_sketch_service(db)
try:
graph_service = create_graph_service(
sketch_id=sketch_id,
enable_batching=False,
)
updated_count = graph_service.update_nodes_positions(positions=positions)
except Exception as e:
print(f"Position update error: {e}")
positions = [pos.model_dump() for pos in data.positions]
return service.update_node_positions(UUID(sketch_id), current_user.id, positions)
except NotFoundError:
raise HTTPException(status_code=404, detail="Sketch not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except DatabaseError:
raise HTTPException(status_code=500, detail="Failed to update node positions")
return {
"status": "positions updated",
"count": updated_count,
}
@router.delete("/{sketch_id}/nodes")
@update_sketch_timestamp
@@ -388,27 +302,16 @@ def delete_nodes(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
# First verify the sketch exists and belongs to the user
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["update"], db=db
)
# Delete nodes and their relationships using GraphService
service = create_sketch_service(db)
try:
graph_service = create_graph_service(
sketch_id=sketch_id,
enable_batching=False,
)
deleted_count = graph_service.delete_nodes(nodes.nodeIds)
except Exception as e:
print(f"Node deletion error: {e}")
return service.delete_nodes(UUID(sketch_id), current_user.id, nodes.nodeIds)
except NotFoundError:
raise HTTPException(status_code=404, detail="Sketch not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except DatabaseError:
raise HTTPException(status_code=500, detail="Failed to delete nodes")
return {"status": "nodes deleted", "count": deleted_count}
@router.delete("/{sketch_id}/relationships")
@update_sketch_timestamp
@@ -419,29 +322,18 @@ def delete_relationships(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
# First verify the sketch exists and belongs to the user
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["update"], db=db
)
# Delete relationships using GraphService
service = create_sketch_service(db)
try:
graph_service = create_graph_service(
sketch_id=sketch_id,
enable_batching=False,
return service.delete_relationships(
UUID(sketch_id), current_user.id, relationships.relationshipIds
)
deleted_count = graph_service.delete_relationships(
relationships.relationshipIds
)
except Exception as e:
print(f"Relationship deletion error: {e}")
except NotFoundError:
raise HTTPException(status_code=404, detail="Sketch not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except DatabaseError:
raise HTTPException(status_code=500, detail="Failed to delete relationships")
return {"status": "relationships deleted", "count": deleted_count}
@router.put("/{sketch_id}/relationships/edit")
@update_sketch_timestamp
@@ -452,42 +344,21 @@ def edit_relationship(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
# First verify the sketch exists and belongs to the user
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["update"], db=db
)
# Update edge using GraphService
service = create_sketch_service(db)
try:
graph_service = create_graph_service(
sketch_id=sketch_id,
enable_batching=False,
return service.update_relationship(
UUID(sketch_id),
current_user.id,
relationship_edit.relationshipId,
relationship_edit.data,
)
result = graph_service.update_relationship(
element_id=relationship_edit.relationshipId,
properties=relationship_edit.data,
)
except Exception as e:
print(f"Relationship update error: {e}")
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except DatabaseError:
raise HTTPException(status_code=500, detail="Failed to update relationship")
if not result:
raise HTTPException(
status_code=404, detail="Relationship not found or not accessible"
)
return {
"status": "relationship updated",
"relationship": {
"id": result["id"],
"label": result["type"],
"data": result["data"],
},
}
@router.post("/{sketch_id}/nodes/merge")
@update_sketch_timestamp
@@ -499,49 +370,20 @@ def merge_nodes(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["update"], db=db
)
if not oldNodes or len(oldNodes) == 0:
raise HTTPException(status_code=400, detail="oldNodes cannot be empty")
node_data = newNode.data.model_dump() if newNode.data else {}
node_type = node_data.get("type", "Node")
properties = {
"type": node_type.lower(),
"label": node_data.get("label", "Merged Node"),
}
flattened_data = flatten(node_data)
properties.update(flattened_data)
service = create_sketch_service(db)
try:
graph_service = create_graph_service(
sketch_id=sketch_id,
enable_batching=False,
node_data = newNode.data.model_dump() if newNode.data else {}
return service.merge_nodes(
UUID(sketch_id), current_user.id, oldNodes, newNode.id, node_data
)
new_node_element_id = graph_service.merge_nodes(
old_node_ids=oldNodes,
new_node_data=properties,
new_node_id=newNode.id,
)
except Exception as e:
print(f"Node merge error: {e}")
raise HTTPException(status_code=500, detail=f"Failed to merge nodes: {str(e)}")
if not new_node_element_id:
raise HTTPException(status_code=500, detail="Failed to merge nodes")
return {
"status": "nodes merged",
"count": len(oldNodes),
"new_node_id": new_node_element_id,
}
except NotFoundError:
raise HTTPException(status_code=404, detail="Sketch not found")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except DatabaseError as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{sketch_id}/nodes/{node_id}")
@@ -551,26 +393,16 @@ def get_related_nodes(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["read"], db=db
)
service = create_sketch_service(db)
try:
graph_service = create_graph_service(sketch_id=sketch_id)
result = graph_service.get_neighbors(node_id)
except Exception as e:
print(e)
return service.get_neighbors(UUID(sketch_id), current_user.id, node_id)
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except DatabaseError:
raise HTTPException(status_code=500, detail="Failed to retrieve related nodes")
if not result.nodes:
raise HTTPException(status_code=404, detail="Node not found")
return {"nds": result.nodes, "rls": result.edges}
@router.post("/{sketch_id}/import/analyze", response_model=FileParseResult)
async def analyze_import_file(
@@ -579,34 +411,32 @@ async def analyze_import_file(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""
Analyze an uploaded TXT or JSON file for import.
Each line represents one entity. Detects entity types and provides preview.
"""
# Verify sketch exists and user has access
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
"""Analyze an uploaded TXT or JSON file for import."""
service = create_sketch_service(db)
try:
service.get_by_id(UUID(sketch_id), current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["read"], db=db
)
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
# Validate file extension
if not file.filename or not file.filename.lower().endswith((".txt", ".json")):
raise HTTPException(
status_code=400,
detail="Only .txt and .json files are supported. Please upload a correct format.",
)
# Read file content
try:
content = await file.read()
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to read file: {str(e)}")
# Analyze file using ImportService
try:
result = ImportService.analyze_file(
type_registry = create_type_registry_service(db)
resolver = type_registry.build_type_resolver(current_user.id)
graph_service = create_graph_service(sketch_id=sketch_id, enable_batching=False, type_resolver=resolver)
import_service = create_import_service(graph_service)
result = import_service.analyze_file(
file_content=content,
filename=file.filename or "unknown.txt",
)
@@ -618,26 +448,6 @@ async def analyze_import_file(
return result
class EntityMappingInput(BaseModel):
"""Pydantic model for parsing entity mapping input from frontend."""
id: str
entity_type: str
include: bool = True
nodeLabel: str
node_id: Optional[str] = None
data: Dict[str, Any]
class ImportExecuteResponse(BaseModel):
"""Response model for import execution."""
status: str
nodes_created: int
nodes_skipped: int
errors: List[str]
@router.post("/{sketch_id}/import/execute", response_model=ImportExecuteResponse)
@update_sketch_timestamp
async def execute_import(
@@ -647,26 +457,21 @@ async def execute_import(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""
Execute the import of entities into the sketch.
Uses the entity mappings provided by the frontend (no file re-parsing needed).
"""
"""Execute the import of entities into the sketch."""
import json
# Verify sketch exists and user has access
sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first()
if not sketch:
service = create_sketch_service(db)
try:
service.get_by_id(UUID(sketch_id), current_user.id)
except NotFoundError:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["update"], db=db
)
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
# Parse entity mappings JSON
try:
mappings = json.loads(entity_mappings_json)
nodes = mappings.get("nodes", [])
edges = mappings.get("edges", [])
print(nodes)
entity_mapping_inputs = [EntityMappingInput(**m) for m in nodes]
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid entity_mappings JSON")
@@ -675,7 +480,6 @@ async def execute_import(
status_code=400, detail=f"Failed to parse entity_mappings: {str(e)}"
)
# Convert Pydantic inputs to service dataclasses
entity_mappings = [
EntityMapping(
id=m.id,
@@ -688,10 +492,9 @@ async def execute_import(
for m in entity_mapping_inputs
]
# Execute import using ImportService
graph_service = create_graph_service(
sketch_id=sketch_id, enable_batching=False
)
type_registry = create_type_registry_service(db)
resolver = type_registry.build_type_resolver(current_user.id)
graph_service = create_graph_service(sketch_id=sketch_id, enable_batching=False, type_resolver=resolver)
import_service = create_import_service(graph_service)
try:
@@ -717,39 +520,13 @@ async def export_sketch(
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""
Export the sketch in the specified format.
Args:
id: The ID of the sketch
format: Export format - "json" (only format for now)
db: The database session
current_user: The current user
Returns:
The sketch data in the requested format
"""
sketch = db.query(Sketch).filter(Sketch.id == id).first()
if not sketch:
"""Export the sketch in the specified format."""
service = create_sketch_service(db)
try:
return service.export_sketch(UUID(id), current_user.id, format)
except NotFoundError:
raise HTTPException(status_code=404, detail="Sketch not found")
check_investigation_permission(
current_user.id, sketch.investigation_id, actions=["read"], db=db
)
# Get all nodes and relationships using GraphService
graph_service = create_graph_service(
sketch_id=id, enable_batching=False
)
graph_data = graph_service.get_sketch_graph()
if format == "json":
return {
"sketch": {
"id": str(sketch.id),
"title": sketch.title,
"description": sketch.description,
},
"nodes": [node.model_dump(mode="json") for node in graph_data.nodes],
"edges": [edge.model_dump(mode="json") for edge in graph_data.edges],
}
else:
raise HTTPException(status_code=400, detail=f"Unsupported format: {format}")
except PermissionDeniedError:
raise HTTPException(status_code=403, detail="Forbidden")
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -1,318 +1,38 @@
from typing import Any, Dict, Optional, Type
from uuid import uuid4
from fastapi import APIRouter, Depends
from flowsint_core.core.models import CustomType, Profile
from flowsint_core.core.postgre_db import get_db
from flowsint_types.registry import get_type
from pydantic import BaseModel, TypeAdapter
from pydantic import BaseModel
from sqlalchemy.orm import Session
from flowsint_core.core.models import Profile
from flowsint_core.core.postgre_db import get_db
from flowsint_core.core.services import create_type_registry_service
from app.api.deps import get_current_user
router = APIRouter()
# Helper function to get a type by name from the registry
def get_type_from_registry(type_name: str) -> Optional[Type[BaseModel]]:
"""Get a type from the TYPE_REGISTRY by name."""
return get_type(type_name, case_sensitive=True)
# Returns the "types" for the sketches
@router.get("/")
@router.get("")
async def get_types_list(
db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user)
):
# Define categories with type names to look up in TYPE_REGISTRY
# Format: (type_name, label_key, optional_icon)
category_definitions = [
{
"id": uuid4(),
"type": "global",
"key": "global_category",
"icon": "phrase",
"label": "Global",
"fields": [],
"children": [
("Phrase", "text", None),
("Location", "address", None),
],
},
{
"id": uuid4(),
"type": "person",
"key": "person_category",
"icon": "individual",
"label": "Identities & Entities",
"fields": [],
"children": [
("Individual", "full_name", None),
("Username", "value", "username"),
("Organization", "name", None),
],
},
{
"id": uuid4(),
"type": "organization",
"key": "organization_category",
"icon": "organization",
"label": "Organization",
"fields": [],
"children": [
("Organization", "name", None),
],
},
{
"id": uuid4(),
"type": "contact_category",
"key": "contact",
"icon": "phone",
"label": "Communication & Contact",
"fields": [],
"children": [
("Phone", "number", None),
("Email", "email", None),
("Username", "value", None),
("SocialAccount", "username", "socialaccount"),
("Message", "content", "message"),
],
},
{
"id": uuid4(),
"type": "network_category",
"key": "network",
"icon": "domain",
"label": "Network",
"fields": [],
"children": [
("ASN", "number", None),
("CIDR", "network", None),
("Domain", "domain", None),
("Website", "url", None),
("Ip", "address", None),
("Port", "number", None),
("DNSRecord", "name", "dns"),
("SSLCertificate", "subject", "ssl"),
("WebTracker", "name", "webtracker"),
],
},
{
"id": uuid4(),
"type": "security_category",
"key": "security",
"icon": "credential",
"label": "Security & Access",
"fields": [],
"children": [
("Credential", "username", "credential"),
("Session", "session_id", "session"),
("Device", "device_id", "device"),
("Malware", "name", "malware"),
("Weapon", "name", "weapon"),
],
},
{
"id": uuid4(),
"type": "files_category",
"key": "files",
"icon": "file",
"label": "Files & Documents",
"fields": [],
"children": [
("Document", "title", "document"),
("File", "filename", "file"),
],
},
{
"id": uuid4(),
"type": "financial_category",
"key": "financial",
"icon": "creditcard",
"label": "Financial Data",
"fields": [],
"children": [
("BankAccount", "account_number", "creditcard"),
("CreditCard", "card_number", "creditcard"),
],
},
{
"id": uuid4(),
"type": "leak_category",
"key": "leaks",
"icon": "breach",
"label": "Leaks",
"fields": [],
"children": [
("Leak", "name", "breach"),
],
},
{
"id": uuid4(),
"type": "crypto_category",
"key": "crypto",
"icon": "cryptowallet",
"label": "Crypto",
"fields": [],
"children": [
("CryptoWallet", "address", "cryptowallet"),
("CryptoWalletTransaction", "hash", "cryptowallet"),
("CryptoNFT", "name", "cryptowallet"),
],
},
]
# Build the types list by looking up each type in TYPE_REGISTRY
types = []
for category in category_definitions:
category_copy = category.copy()
children_schemas = []
for child_def in category["children"]:
type_name, label_key, icon = child_def
model = get_type_from_registry(type_name)
if model:
children_schemas.append(
extract_input_schema(model, label_key=label_key, icon=icon)
)
else:
# Log warning but continue - type might not be available
print(f"Warning: Type {type_name} not found in TYPE_REGISTRY")
category_copy["children"] = children_schemas
types.append(category_copy)
# Add custom types
custom_types = (
db.query(CustomType)
.filter(
CustomType.owner_id == current_user.id,
CustomType.status == "published", # Only show published custom types
)
.all()
)
if custom_types:
custom_types_children = []
for custom_type in custom_types:
# Extract the label_key from the schema (use first required field or first property)
schema = custom_type.schema
properties = schema.get("properties", {})
required = schema.get("required", [])
# Try to use the first required field, or the first property
label_key = (
required[0]
if required
else list(properties.keys())[0]
if properties
else "value"
)
custom_types_children.append(
{
"id": custom_type.id,
"type": custom_type.name,
"key": custom_type.name.lower(),
"label_key": label_key,
"icon": "custom",
"label": custom_type.name,
"description": custom_type.description or "",
"fields": [
{
"name": prop,
"label": info.get("title", prop),
"description": info.get("description", ""),
"type": "text",
"required": prop in required,
}
for prop, info in properties.items()
],
"custom": True, # Mark as custom type
}
)
types.append(
{
"id": uuid4(),
"type": "custom_types_category",
"key": "custom_types",
"icon": "custom",
"label": "Custom types",
"fields": [],
"children": custom_types_children,
}
)
return types
"""Get the complete types list for sketches."""
service = create_type_registry_service(db)
return service.get_types_list(current_user.id)
def extract_input_schema(
model: Type[BaseModel], label_key: str, icon: Optional[str] = None
) -> Dict[str, Any]:
adapter = TypeAdapter(model)
schema = adapter.json_schema()
# Use the main schema properties, not the $defs
type_name = model.__name__
details = schema
return {
"id": uuid4(),
"type": type_name,
"key": type_name.lower(),
"label_key": label_key,
"icon": icon or type_name.lower(),
"label": type_name,
"description": details.get("description", ""),
"fields": [
resolve_field(prop, details=info, schema=schema)
for prop, info in details.get("properties", {}).items()
# exclude nodeLabel from properties to fill
if prop != "nodeLabel"
],
}
class DetectRequest(BaseModel):
text: str
def resolve_field(prop: str, details: dict, schema: dict = None) -> Dict:
"""_summary_
The fields can sometimes contain nested complex objects, like:
- Organization having Individual[] as dirigeants, so we want to skip those.
Args:
details (dict): _description_
schema_context (dict, optional): _description_. Defaults to None.
@router.post("/detect")
async def detect_type(
body: DetectRequest,
db: Session = Depends(get_db),
current_user: Profile = Depends(get_current_user),
):
"""Detect the type of a given text input.
Returns:
str: _description_
Returns the detected type and its fields with the primary field pre-filled.
Falls back to Phrase if no type matches.
"""
field = {
"name": prop,
"label": details.get("title", prop),
"description": details.get("description", ""),
"type": "text",
}
if has_enum(details):
field["type"] = "select"
field["options"] = [
{"label": label, "value": label} for label in get_enum_values(details)
]
field["required"] = is_required(details)
return field
def has_enum(schema: dict) -> bool:
any_of = schema.get("anyOf", [])
return any(isinstance(entry, dict) and "enum" in entry for entry in any_of)
def is_required(schema: dict) -> bool:
any_of = schema.get("anyOf", [])
return not any(entry == {"type": "null"} for entry in any_of)
def get_enum_values(schema: dict) -> list:
enum_values = []
for entry in schema.get("anyOf", []):
if isinstance(entry, dict) and "enum" in entry:
enum_values.extend(entry["enum"])
return enum_values
service = create_type_registry_service(db)
return service.detect_type(body.text)

View File

@@ -1,20 +1,31 @@
from typing import Optional, Dict, Any
from pydantic import BaseModel, UUID4, Field, field_validator
from datetime import datetime
from typing import Any, Dict, Optional
from pydantic import UUID4, BaseModel, Field, field_validator
from .base import ORMBase
class CustomTypeCreate(BaseModel):
"""Schema for creating a new custom type."""
name: str = Field(..., min_length=1, max_length=255, description="Name of the custom type")
json_schema: Dict[str, Any] = Field(..., description="JSON Schema definition", alias="schema")
description: Optional[str] = Field(None, description="Optional description of the custom type")
status: Optional[str] = Field("draft", description="Status: draft or published")
name: str = Field(
..., min_length=1, max_length=255, description="Name of the custom type"
)
json_schema: Dict[str, Any] = Field(
..., description="JSON Schema definition", alias="schema"
)
description: Optional[str] = Field(
None, description="Optional description of the custom type"
)
status: str = Field("draft", description="Status of the custom type")
color: str = Field("#8E9E8C", description="Default color")
icon: str = Field("Minus", description="Default icon")
class Config:
populate_by_name = True
@field_validator('status')
@field_validator("status")
@classmethod
def validate_status(cls, v: str) -> str:
if v not in ["draft", "published", "archived"]:
@@ -24,15 +35,18 @@ class CustomTypeCreate(BaseModel):
class CustomTypeUpdate(BaseModel):
"""Schema for updating an existing custom type."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
json_schema: Optional[Dict[str, Any]] = Field(None, alias="schema")
description: Optional[str] = None
status: Optional[str] = None
color: Optional[str] = None
icon: Optional[str] = None
class Config:
populate_by_name = True
@field_validator('status')
@field_validator("status")
@classmethod
def validate_status(cls, v: Optional[str]) -> Optional[str]:
if v is not None and v not in ["draft", "published", "archived"]:
@@ -42,9 +56,12 @@ class CustomTypeUpdate(BaseModel):
class CustomTypeRead(ORMBase):
"""Schema for reading a custom type."""
id: UUID4
name: str
owner_id: UUID4
color: Optional[str]
icon: Optional[str]
json_schema: Dict[str, Any] = Field(..., alias="schema")
status: str
checksum: Optional[str]
@@ -58,10 +75,14 @@ class CustomTypeRead(ORMBase):
class CustomTypeValidatePayload(BaseModel):
"""Schema for validating a payload against a custom type schema."""
payload: Dict[str, Any] = Field(..., description="Data to validate against the schema")
payload: Dict[str, Any] = Field(
..., description="Data to validate against the schema"
)
class CustomTypeValidateResponse(BaseModel):
"""Response schema for validation."""
valid: bool
errors: Optional[list[str]] = None

View File

@@ -1,7 +1,8 @@
from .base import ORMBase
from typing import Any, Dict, List, Optional
from pydantic import UUID4, BaseModel
from typing import Optional
from typing import List, Optional, Dict, Any
from .base import ORMBase
class EnricherCreate(BaseModel):
@@ -21,6 +22,6 @@ class EnricherRead(ORMBase):
class EnricherUpdate(BaseModel):
name: Optional[str] = None
class_name: str = None
class_name: Optional[str] = None
description: Optional[str] = None
category: Optional[List[str]] = None

View File

@@ -0,0 +1,190 @@
"""Pydantic schemas for enricher templates."""
from datetime import datetime
from typing import Any, Dict, Optional
from pydantic import UUID4, BaseModel, Field, field_validator
from .base import ORMBase
class EnricherTemplateCreate(BaseModel):
"""Schema for creating a new enricher template."""
name: str = Field(
..., min_length=1, max_length=255, description="Name of the template"
)
description: Optional[str] = Field(
None, max_length=1000, description="Description of the template"
)
category: str = Field(
..., min_length=1, max_length=100, description="Category (e.g., Ip, Domain)"
)
version: float = Field(default=1.0, ge=0, description="Template version")
content: Dict[str, Any] = Field(
..., description="Template content as parsed YAML/JSON"
)
is_public: bool = Field(
default=False, description="Whether the template is publicly visible"
)
@field_validator("content")
@classmethod
def validate_content(cls, v: Dict[str, Any]) -> Dict[str, Any]:
"""Validate that content has required template fields."""
required_fields = [
"name",
"category",
"version",
"input",
"request",
"output",
"response",
]
missing = [f for f in required_fields if f not in v]
if missing:
raise ValueError(
f"Missing required fields in content: {', '.join(missing)}"
)
# Validate input
if "input" in v and "type" not in v.get("input", {}):
raise ValueError("input.type is required")
# Validate request
request = v.get("request", {})
if "method" not in request:
raise ValueError("request.method is required")
if request.get("method") not in ["GET", "POST"]:
raise ValueError("request.method must be GET or POST")
if "url" not in request:
raise ValueError("request.url is required")
# Validate output
if "output" in v and "type" not in v.get("output", {}):
raise ValueError("output.type is required")
# Validate response
response = v.get("response", {})
if "expect" not in response:
raise ValueError("response.expect is required")
if response.get("expect") not in ["json", "xml", "text"]:
raise ValueError("response.expect must be json, xml, or text")
return v
class EnricherTemplateUpdate(BaseModel):
"""Schema for updating an existing enricher template."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=1000)
category: Optional[str] = Field(None, min_length=1, max_length=100)
version: Optional[float] = Field(None, ge=0)
content: Optional[Dict[str, Any]] = None
is_public: Optional[bool] = None
@field_validator("content")
@classmethod
def validate_content(cls, v: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Validate content if provided."""
if v is None:
return v
required_fields = [
"name",
"category",
"version",
"input",
"request",
"output",
"response",
]
missing = [f for f in required_fields if f not in v]
if missing:
raise ValueError(
f"Missing required fields in content: {', '.join(missing)}"
)
return v
class EnricherTemplateRead(ORMBase):
"""Schema for reading an enricher template."""
id: UUID4
name: str
description: Optional[str]
category: str
version: float
content: Dict[str, Any]
is_public: bool
owner_id: UUID4
created_at: datetime
updated_at: datetime
class EnricherTemplateList(ORMBase):
"""Schema for listing enricher templates (minimal fields)."""
id: UUID4
name: str
description: Optional[str]
category: str
version: float
is_public: bool
owner_id: UUID4
created_at: datetime
updated_at: datetime
class EnricherTemplateTestRequest(BaseModel):
"""Schema for testing an enricher template by ID."""
input_value: str = Field(
..., min_length=1, description="The value to test the template with"
)
class EnricherTemplateTestContentRequest(BaseModel):
"""Schema for testing template content directly (without saving)."""
input_value: str = Field(
..., min_length=1, description="The value to test the template with"
)
content: Dict[str, Any] = Field(..., description="Template content to test")
class EnricherTemplateTestResponse(BaseModel):
"""Schema for test response."""
success: bool
data: Optional[Dict[str, Any]] = None
error: Optional[str] = None
status_code: Optional[int] = None
url: str
class EnricherTemplateGenerateRequest(BaseModel):
"""Schema for AI-assisted template generation."""
prompt: str = Field(
...,
min_length=10,
max_length=16000,
description="Free-text description of the desired enricher template",
)
input_type: Optional[str] = Field(
None, description="Flowsint input type name (e.g. 'Ip', 'Domain')"
)
output_type: Optional[str] = Field(
None, description="Flowsint output type name (e.g. 'Ip', 'SocialAccount')"
)
class EnricherTemplateGenerateResponse(BaseModel):
"""Schema for the generated template response."""
yaml_content: str = Field(
..., description="Raw YAML string of the generated template"
)

View File

@@ -25,20 +25,7 @@ class InvestigationRead(ORMBase):
owner: Optional[ProfileRead] = None
sketches: list[SketchRead] = []
analyses: list[AnalysisRead] = []
class InvestigationProfileCreate(BaseModel):
investigation_id: UUID4
profile_id: UUID4
role: Optional[str] = "member"
class InvestigationProfileRead(ORMBase):
id: int
created_at: datetime
investigation_id: UUID4
profile_id: UUID4
role: str
current_user_role: Optional[str] = None
class InvestigationUpdate(BaseModel):
@@ -46,3 +33,22 @@ class InvestigationUpdate(BaseModel):
description: Optional[str] = None
last_updated_at: datetime
status: str
# ── Collaborator schemas ─────────────────────────────────────────────
class CollaboratorAdd(BaseModel):
email: str
role: str # "admin", "editor", "viewer"
class CollaboratorUpdate(BaseModel):
role: str # "admin", "editor", "viewer"
class CollaboratorRead(ORMBase):
id: UUID4
user_id: UUID4
roles: list[str] = []
user: Optional[ProfileRead] = None

View File

@@ -1,7 +1,9 @@
from .base import ORMBase
from pydantic import UUID4, BaseModel
from datetime import datetime
from pydantic import UUID4, BaseModel
from .base import ORMBase
class KeyCreate(BaseModel):
key: str
@@ -12,4 +14,8 @@ class KeyRead(ORMBase):
id: UUID4
owner_id: UUID4
name: str
created_at: datetime
created_at: datetime | str
class KeyExists(BaseModel):
exists: bool

View File

@@ -1,5 +1,5 @@
from .base import ORMBase
from pydantic import UUID4, BaseModel, EmailStr
from pydantic import UUID4, BaseModel, ConfigDict, EmailStr
from typing import Optional
@@ -13,3 +13,12 @@ class ProfileRead(ORMBase):
first_name: Optional[str]
last_name: Optional[str]
avatar_url: Optional[str]
email: Optional[str] = None
class ProfileUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
first_name: Optional[str] = None
last_name: Optional[str] = None
avatar_url: Optional[str] = None

View File

@@ -8,7 +8,7 @@ class ScanCreate(BaseModel):
values: Optional[List[str]] = None
sketch_id: Optional[UUID4] = None
status: Optional[str] = None
results: Optional[Any] = None
details: Optional[Any] = None
class ScanRead(ORMBase):

View File

@@ -1,3 +1,5 @@
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -14,13 +16,18 @@ from app.api.routes import scan
from app.api.routes import keys
from app.api.routes import types
from app.api.routes import custom_types
from app.api.routes import enricher_templates
# Comma-separated list of allowed origins, e.g. "https://app.example.com,https://staging.example.com"
# Falls back to localhost dev origin when unset. Never use "*" with allow_credentials=True.
origins = [
"*",
o.strip()
for o in os.getenv("ALLOWED_ORIGINS", "http://localhost:5173").split(",")
if o.strip()
]
app = FastAPI()
app = FastAPI(ignore_trailing_slash=True, redirect_slashes=False)
app.add_middleware(
CORSMiddleware,
@@ -51,3 +58,4 @@ app.include_router(scan.router, prefix="/api/scans", tags=["scans"])
app.include_router(keys.router, prefix="/api/keys", tags=["keys"])
app.include_router(types.router, prefix="/api/types", tags=["types"])
app.include_router(custom_types.router, prefix="/api/custom-types", tags=["custom-types"])
app.include_router(enricher_templates.router, prefix="/api/enrichers/templates", tags=["enricher-templates"])

View File

@@ -1,6 +1,9 @@
#!/bin/sh
set -e
# Ensure virtualenv binaries are in PATH
export PATH="/app/flowsint-api/.venv/bin:$PATH"
if [ "$SKIP_MIGRATIONS" != "true" ]; then
echo "Running database migrations..."
alembic upgrade head

6887
flowsint-api/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,57 @@
[tool.poetry]
[project]
name = "flowsint-api"
version = "1.0.0"
license = "AGPL-3.0-or-later"
version = "1.2.8"
description = "API server for flowsint"
authors = ["dextmorgn <contact@flowsint.io>"]
packages = [{ include = "app" }]
license = "Apache-2.0"
authors = [{ name = "dextmorgn", email = "contact@flowsint.io" }]
requires-python = ">=3.12,<4.0"
dependencies = [
"flowsint-core",
"flowsint-types",
"flowsint-enrichers",
"fastapi[standard]>=0.115.0,<1.0.0",
"uvicorn>=0.32.0,<0.33.0",
"redis>=5.0,<6.0",
"celery>=5.3,<6.0",
"python-dotenv>=1.0,<2.0",
"python-jose[cryptography]>=3.4,<4.0",
"requests>=2.31,<3.0",
"pydantic>=2.0,<3.0",
"neo4j>=5.0,<6.0",
"sqlalchemy>=2.0,<3.0",
"psycopg2-binary>=2.9,<3.0",
"asyncpg>=0.30,<0.31",
"alembic==1.13.0",
"passlib[bcrypt]>=1.7,<2.0",
"bcrypt>=4.0.0,<5.0.0",
"sse-starlette>=1.8,<2.0",
"networkx>=2.6.3,<3.0.0",
"email-validator>=2.2.0,<3.0.0",
"mistralai>=1.9.3,<2.0.0",
"python-multipart>=0.0.27,<0.1.0",
"openpyxl>=3.1.2,<4.0.0",
"jsonschema>=4.25.1,<5.0.0",
]
[tool.poetry.dependencies]
python = ">=3.12,<4.0"
flowsint-core = { path = "../flowsint-core", develop = true }
flowsint-types = { path = "../flowsint-types", develop = true }
flowsint-enrichers = { path = "../flowsint-enrichers", develop = true }
fastapi = "^0.115.0"
uvicorn = "^0.32.0"
redis = "^5.0"
celery = "^5.3"
python-dotenv = "^1.0"
python-jose = {extras = ["cryptography"], version = "^3.4"}
requests = "^2.31"
pydantic = "^2.0"
neo4j = "^5.0"
sqlalchemy = "^2.0"
psycopg2-binary = "^2.9"
asyncpg = "^0.30"
alembic = "1.13.0"
passlib = {extras = ["bcrypt"], version = "^1.7"}
bcrypt = ">=4.0.0,<5.0.0"
sse-starlette = "^1.8"
networkx = "^2.6.3"
email-validator = "^2.2.0"
mistralai = "^1.9.3"
python-multipart = "^0.0.20"
openpyxl = "^3.1.2"
jsonschema = "^4.25.1"
[tool.poetry.group.dev.dependencies]
black = "^23.0"
isort = "^5.12"
flake8 = "^6.0"
mypy = "^1.5"
[dependency-groups]
dev = [
"black>=26.3.1,<27.0",
"isort>=6.0,<7.0",
"flake8>=7.0,<8.0",
"mypy>=1.17,<2.0",
]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.uv.sources]
flowsint-core = { workspace = true }
flowsint-types = { workspace = true }
flowsint-enrichers = { workspace = true }
[tool.black]
line-length = 88

View File

@@ -0,0 +1,38 @@
"""Pytest harness for flowsint-api. Provides a TestClient backed by an
in-memory SQLite database, overriding the real Postgres get_db dependency."""
import os
# Env required at import time by flowsint-core modules. Set before importing app.
os.environ.setdefault("AUTH_SECRET", "test-secret-please-ignore")
os.environ.setdefault(
"MASTER_VAULT_KEY_V1", "base64:qnHTmwYb+uoygIw9MsRMY22vS5YPchY+QOi/E79GAvM="
)
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from flowsint_core.core.models import Base
from flowsint_core.core.postgre_db import get_db
from app.main import app
@pytest.fixture
def db_session():
engine = create_engine(
"sqlite:///:memory:", connect_args={"check_same_thread": False}
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
@pytest.fixture
def client(db_session):
app.dependency_overrides[get_db] = lambda: db_session
yield TestClient(app)
app.dependency_overrides.clear()

View File

@@ -0,0 +1,50 @@
"""Auth and shape contract for the SSE event endpoints."""
import importlib
import pytest
from flowsint_core.core.auth import create_access_token
from flowsint_core.core.models import Profile
def test_get_current_user_sse_is_removed():
"""The query-param SSE auth helper must no longer exist."""
deps = importlib.import_module("app.api.deps")
assert not hasattr(deps, "get_current_user_sse")
def test_log_stream_requires_auth_header(client):
"""No Authorization header -> 401 (no token in URL accepted)."""
res = client.get("/api/events/sketch/abc/stream")
assert res.status_code == 401
def test_status_stream_requires_auth_header(client):
res = client.get("/api/events/sketch/abc/status/stream")
assert res.status_code == 401
def test_log_stream_rejects_token_query_param(client):
"""A token in the URL must NOT authenticate the request anymore."""
token = create_access_token({"sub": "user@example.com"})
res = client.get(f"/api/events/sketch/abc/stream?token={token}")
assert res.status_code == 401
def test_dead_scan_stream_endpoint_removed(client):
"""The unused scan status stream endpoint must be gone."""
res = client.get("/api/events/status/scan/abc/stream")
assert res.status_code == 404
def test_get_current_user_accepts_valid_bearer(db_session):
"""The dependency the streams now use authenticates via a valid JWT."""
from app.api.deps import get_current_user
db_session.add(Profile(email="user@example.com", hashed_password="x"))
db_session.commit()
token = create_access_token({"sub": "user@example.com"})
user = get_current_user(token=token, db=db_session)
assert user.email == "user@example.com"

BIN
flowsint-app/.DS_Store vendored

Binary file not shown.

View File

@@ -1,8 +1,67 @@
# Dependencies (will be installed in container)
node_modules
npm-debug.log
.DS_Store
# Build output (will be built in container)
dist
.env.local
.env.*.local
build
# Development files
*.dev
Dockerfile.dev
Dockerfile.optimized
# Environment files (secrets - NEVER include)
.env
.env.*
!.env.example
# IDE and editor files
.vscode
.idea
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Git
.git
.gitignore
.gitattributes
# Testing
coverage
.nyc_output
*.test.ts
*.test.tsx
*.spec.ts
*.spec.tsx
__tests__
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Cache
.cache
.vite
.eslintcache
.parcel-cache
*.md
!README.md
docs
# CI/CD
.github
.gitlab-ci.yml
.circleci
# Misc
*.bak
*.tmp
.husky

View File

@@ -1,4 +1,12 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true
}
},
extends: [
'eslint:recommended',
'plugin:react/recommended',

View File

@@ -1,13 +1,43 @@
FROM node:24
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN yarn install
RUN apk add --no-cache python3 make g++
COPY package.json yarn.lock ./
RUN yarn install --network-timeout 100000
COPY . .
RUN yarn build
EXPOSE 5173
# Build argument for API URL (injected at build time)
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
CMD ["yarn", "preview", "--", "--port", "5173"]
RUN yarn build
FROM nginx:1.27-alpine AS production
LABEL org.opencontainers.image.source="https://github.com/reconurge/flowsint"
LABEL org.opencontainers.image.description="Flowsint Frontend"
LABEL org.opencontainers.image.licenses="Apache-2.0"
RUN addgroup -g 1001 -S flowsint && \
adduser -u 1001 -S flowsint -G flowsint && \
# Set ownership for nginx html directory
chown -R flowsint:flowsint /usr/share/nginx/html && \
# Remove default config
rm -f /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=builder --chown=flowsint:flowsint /app/dist /usr/share/nginx/html
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/health || exit 1
USER flowsint
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,12 +1,17 @@
FROM node:24
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN yarn install
RUN apk add --no-cache python3 make g++
# Copy dependency files
COPY package.json yarn.lock ./
RUN yarn install --network-timeout 100000
COPY . .
EXPOSE 5173
# Vite dev server with hot-reload
CMD ["yarn", "dev", "--host", "0.0.0.0"]

View File

@@ -1,38 +1,92 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
worker_processes auto;
error_log /tmp/nginx_error.log warn;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Temp directories for non-root
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /tmp/nginx_access.log main;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 10240;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
gzip_disable "MSIE [1-6]\.";
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml
application/rss+xml application/atom+xml image/svg+xml;
# Proxy API requests to backend
location /api {
proxy_pass http://flowsint_api:5001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Security
server_tokens off;
# Handle SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Static assets with aggressive caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# SPA fallback - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# No cache for index.html
location = /index.html {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# Deny access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "flowsint-app",
"productName": "Flowsint",
"license": "AGPL-3.0-or-later",
"license": "Apache-2.0",
"version": "1.0.0",
"type": "module",
"description": "Graph analysis and intelligence platform to help you discover hidden relationships and insights.",
@@ -11,13 +11,17 @@
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@ai-sdk/react": "^3.0.79",
"@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^5.0.1",
"@microsoft/fetch-event-source": "^2.0.1",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
@@ -76,6 +80,7 @@
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.6.4",
"ai": "^6.0.77",
"babel-plugin-react-compiler": "^19.1.0-rc.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -87,16 +92,16 @@
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.0.6",
"input-otp": "^1.4.2",
"leaflet": "^1.9.4",
"lowlight": "^3.3.0",
"lucide-react": "^0.511.0",
"maplibre-gl": "^5.1.1",
"marked": "^15.0.12",
"next-themes": "^0.4.6",
"pixi.js": "^8.14.1",
"react-day-picker": "8.10.1",
"react-force-graph-2d": "^1.27.1",
"react-hook-form": "^7.56.4",
"react-joyride": "^2.9.3",
"react-map-gl": "^8.1.0",
"react-markdown": "^10.1.0",
"react-medium-image-zoom": "^5.2.14",
"react-resizable-panels": "^3.0.2",
@@ -110,6 +115,7 @@
"usehooks-ts": "^3.1.1",
"uuid": "^13.0.0",
"vaul": "^1.1.2",
"yaml": "^2.8.2",
"zod": "^3.25.42",
"zustand": "^5.0.3"
},
@@ -128,12 +134,14 @@
"eslint-plugin-react": "^7.34.3",
"graphology-types": "^0.24.8",
"husky": "^9.1.7",
"monaco-editor": "^0.55.1",
"postcss": "^8.4.35",
"prettier": "^3.3.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"standard-version": "^9.5.0",
"typescript": "^5.5.2",
"vite": "^5.3.1"
"vite": "^5.3.1",
"vitest": "^2.1.9"
}
}

View File

@@ -49,6 +49,17 @@ export const authService = {
},
getCurrentUser: async () => {
return fetchWithAuth('/api/users/me')
return fetchWithAuth('/api/auth/me')
},
updateProfile: async (data: { first_name?: string; last_name?: string; avatar_url?: string }) => {
return fetchWithAuth('/api/auth/me', {
method: 'PUT',
body: JSON.stringify(data)
})
},
searchUsers: async (query: string) => {
return fetchWithAuth(`/api/auth/users/search?q=${encodeURIComponent(query)}`)
}
}

View File

@@ -1,98 +1,4 @@
import { fetchWithAuth } from './api'
import { useAuthStore } from '@/stores/auth-store'
export interface ChatResponse {
content: string
}
export class ChatService {
private static instance: ChatService
public static getInstance(): ChatService {
if (!ChatService.instance) {
ChatService.instance = new ChatService()
}
return ChatService.instance
}
async streamChat(
chatId: string,
prompt: string,
onChunk: (content: string) => void,
context?: any
): Promise<string> {
const token = useAuthStore.getState().token
const API_URL = import.meta.env.VITE_API_URL
const headers: HeadersInit = {
'Content-Type': 'application/json'
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(`${API_URL}/api/chats/stream/${chatId}`, {
method: 'POST',
headers,
body: JSON.stringify({
prompt,
context
})
})
if (response.status === 401) {
useAuthStore.getState().logout()
window.location.href = '/login'
throw new Error('Session expired, login again.')
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || `Error ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
console.error('No reader available')
throw new Error('No reader available')
}
let accumulatedContent = ''
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
const text = new TextDecoder().decode(value)
const lines = text.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') {
return accumulatedContent
}
try {
const parsed = JSON.parse(data)
if (parsed.content) {
accumulatedContent += parsed.content
onChunk(accumulatedContent)
}
} catch (e) {
console.error('Error parsing SSE data:', data, e)
}
}
}
}
return accumulatedContent
}
}
export const chatService = ChatService.getInstance()
export const chatCRUDService = {
get: async (): Promise<any> => {

View File

@@ -0,0 +1,27 @@
import { DefaultChatTransport } from 'ai'
import { useAuthStore } from '@/stores/auth-store'
const API_URL = import.meta.env.VITE_API_URL
export function createChatTransport(chatId: string) {
return new DefaultChatTransport({
api: `${API_URL}/api/chats/stream/${chatId}`,
headers: (): Record<string, string> => {
const token = useAuthStore.getState().token
if (token) return { Authorization: `Bearer ${token}` }
return {}
},
prepareSendMessagesRequest: ({ messages, body }) => {
const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user')
const prompt = lastUserMessage?.parts
?.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.map((p) => p.text)
.join('') ?? ''
const context = (body as Record<string, unknown> | undefined)?.context
return {
body: { prompt, ...(context ? { context } : {}) },
}
},
})
}

View File

@@ -8,6 +8,8 @@ export interface CustomType {
status: 'draft' | 'published' | 'archived'
checksum?: string
description?: string
icon?: string
color?: string
created_at: string
updated_at: string
}
@@ -16,6 +18,8 @@ export interface CustomTypeCreate {
name: string
schema: Record<string, any>
description?: string
icon?: string
color?: string
status?: 'draft' | 'published'
}
@@ -23,6 +27,8 @@ export interface CustomTypeUpdate {
name?: string
schema?: Record<string, any>
description?: string
icon?: string
color?: string
status?: 'draft' | 'published' | 'archived'
}

View File

@@ -7,6 +7,18 @@ export const enricherService = {
method: 'GET'
})
},
getTemplates: async (): Promise<any> => {
const url = '/api/enrichers/templates'
return fetchWithAuth(url, {
method: 'GET'
})
},
getTemplateById: async (templateId: string): Promise<any> => {
const url = `/api/enrichers/templates/${templateId}`
return fetchWithAuth(url, {
method: 'GET'
})
},
launch: async (enricherName: string, body: BodyInit): Promise<any> => {
return fetchWithAuth(`/api/enrichers/${enricherName}/launch`, {
method: 'POST',

View File

@@ -21,5 +21,29 @@ export const investigationService = {
return fetchWithAuth(`/api/investigations/${investigationId}`, {
method: 'DELETE'
})
},
// Collaborator management
getCollaborators: async (investigationId: string): Promise<any> => {
return fetchWithAuth(`/api/investigations/${investigationId}/collaborators`, {
method: 'GET'
})
},
addCollaborator: async (investigationId: string, body: { email: string; role: string }): Promise<any> => {
return fetchWithAuth(`/api/investigations/${investigationId}/collaborators`, {
method: 'POST',
body: JSON.stringify(body)
})
},
updateCollaboratorRole: async (investigationId: string, userId: string, body: { role: string }): Promise<any> => {
return fetchWithAuth(`/api/investigations/${investigationId}/collaborators/${userId}`, {
method: 'PUT',
body: JSON.stringify(body)
})
},
removeCollaborator: async (investigationId: string, userId: string): Promise<any> => {
return fetchWithAuth(`/api/investigations/${investigationId}/collaborators/${userId}`, {
method: 'DELETE'
})
}
}

View File

@@ -1,6 +1,6 @@
import { fetchWithAuth } from './api'
export const KeyService = {
export const keyService = {
get: () => fetchWithAuth(`/api/keys`, { method: 'GET' }),
getById: (key_id: string) => fetchWithAuth(`/api/keys/${key_id}`, { method: 'GET' }),
create: (data: { name: string; key: string }) =>
@@ -8,5 +8,6 @@ export const KeyService = {
method: 'POST',
body: JSON.stringify(data)
}),
chatKeyExists: () => fetchWithAuth(`/api/keys/chat-key-exists`, { method: 'GET' }),
deleteById: (key_id: string) => fetchWithAuth(`/api/keys/${key_id}`, { method: 'DELETE' })
}

View File

@@ -7,7 +7,7 @@
// import { investigationService } from './investigation-service'
// import { sketchService } from './sketch-service'
// import { chatCRUDService } from './chat-service'
// import { KeyService } from './key-service'
// import { keyService } from './key-service'
// import { logService } from './log-service'
// import { scanService } from './scan-service'
// import { enricherService } from './enricher-service'
@@ -112,14 +112,14 @@
// export const useKeysList = () => {
// return useQuery({
// queryKey: queryKeys.keys.list,
// queryFn: KeyService.get
// queryFn: keyService.get
// })
// }
// export const useKeyDetail = (keyId: string) => {
// return useQuery({
// queryKey: queryKeys.keys.detail(keyId),
// queryFn: () => KeyService.getById(keyId),
// queryFn: () => keyService.getById(keyId),
// enabled: !!keyId
// })
// }

View File

@@ -13,6 +13,7 @@ export const queryKeys = {
sketches: (investigationId: string) => [investigationId, 'sketches'],
analyses: (investigationId: string) => [investigationId, 'analyses'],
flows: (investigationId: string) => [investigationId, 'flows'],
collaborators: (investigationId: string) => ['investigations', investigationId, 'collaborators'],
dashboard: ['investigations', 'dashboard'],
selector: (investigationId: string) => ['dashboard', 'selector', investigationId]
},

View File

@@ -1,7 +1,7 @@
import { fetchWithAuth } from './api'
export const scanService = {
get: (scan_id: string) => fetchWithAuth(`/api/scans/${scan_id}`, { method: 'GET' }),
getById: (scan_id: string) => fetchWithAuth(`/api/scans/${scan_id}`, { method: 'GET' }),
delete: (scan_id: string) => fetchWithAuth(`/api/scans/${scan_id}`, { method: 'DELETE' }),
getSketchScans: (sketch_id: string) =>
fetchWithAuth(`/api/scans/sketch/${sketch_id}`, { method: 'GET' }),

View File

@@ -79,6 +79,12 @@ export const sketchService = {
method: 'GET'
})
},
detectType: async (text: string): Promise<any> => {
return fetchWithAuth(`/api/types/detect`, {
method: 'POST',
body: JSON.stringify({ text })
})
},
update: async (sketchId: string, body: BodyInit): Promise<any> => {
return fetchWithAuth(`/api/sketches/${sketchId}`, {
method: 'PUT',

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Capture the options passed to fetchEventSource so we can drive its callbacks.
const calls: any[] = []
const fetchEventSourceMock = vi.fn((url: string, opts: any) => {
calls.push({ url, opts })
return new Promise(() => {}) // never resolves; we drive callbacks manually
})
vi.mock('@microsoft/fetch-event-source', () => ({
fetchEventSource: (url: string, opts: any) => fetchEventSourceMock(url, opts),
}))
vi.mock('@/stores/auth-store', () => ({
useAuthStore: { getState: () => ({ token: 'tok123' }) },
}))
import { connectSSE } from './sse'
const lastOpts = () => calls[calls.length - 1].opts
const fakeResponse = (status: number, contentType: string) =>
({ ok: status >= 200 && status < 300, status, headers: { get: () => contentType } } as any)
beforeEach(() => {
calls.length = 0
fetchEventSourceMock.mockClear()
})
describe('connectSSE', () => {
it('passes the URL and Authorization header from the store', () => {
connectSSE({ url: 'http://x/stream', onMessage: () => {} })
expect(calls[0].url).toBe('http://x/stream')
expect(lastOpts().headers).toEqual({ Authorization: 'Bearer tok123' })
})
it('forwards parsed envelopes and skips "connected"', () => {
const received: any[] = []
connectSSE({ url: 'http://x', onMessage: (m) => received.push(m) })
const o = lastOpts()
o.onmessage({ data: JSON.stringify({ event: 'connected', data: 'hi' }) })
o.onmessage({ data: JSON.stringify({ event: 'log', data: '{"msg":"a"}' }) })
expect(received).toEqual([{ event: 'log', data: '{"msg":"a"}' }])
})
it('ignores empty and malformed messages without throwing', () => {
const received: any[] = []
connectSSE({ url: 'http://x', onMessage: (m) => received.push(m) })
const o = lastOpts()
o.onmessage({ data: '' })
o.onmessage({ data: 'not-json' })
expect(received).toEqual([])
})
it('aborts the request when the disposer is called', () => {
const dispose = connectSSE({ url: 'http://x', onMessage: () => {} })
expect(lastOpts().signal.aborted).toBe(false)
dispose()
expect(lastOpts().signal.aborted).toBe(true)
})
it('throws (no retry) on a non-event-stream open', async () => {
connectSSE({ url: 'http://x', onMessage: () => {} })
await expect(lastOpts().onopen(fakeResponse(401, 'application/json'))).rejects.toThrow()
})
it('accepts a valid event-stream open', async () => {
connectSSE({ url: 'http://x', onMessage: () => {} })
await expect(
lastOpts().onopen(fakeResponse(200, 'text/event-stream'))
).resolves.toBeUndefined()
})
it('returns increasing backoff for transient errors', () => {
connectSSE({ url: 'http://x', onMessage: () => {} })
const o = lastOpts()
const first = o.onerror(new Error('network'))
const second = o.onerror(new Error('network'))
expect(first).toBe(2000)
expect(second).toBe(4000)
})
it('rethrows fatal errors from onerror to stop retrying', async () => {
connectSSE({ url: 'http://x', onMessage: () => {} })
const o = lastOpts()
let fatal: unknown
try {
await o.onopen(fakeResponse(403, 'application/json'))
} catch (e) {
fatal = e
}
expect(() => o.onerror(fatal)).toThrow()
})
it('ignores valid JSON that is not an envelope', () => {
const received: any[] = []
connectSSE({ url: 'http://x', onMessage: (m) => received.push(m) })
const o = lastOpts()
o.onmessage({ data: '42' })
o.onmessage({ data: 'null' })
o.onmessage({ data: '{"data":"no-event"}' })
expect(received).toEqual([])
})
it('calls onError once when the connection promise rejects (non-fatal)', async () => {
const errors: unknown[] = []
fetchEventSourceMock.mockImplementationOnce((url: string, opts: any) => {
calls.push({ url, opts })
return Promise.reject(new Error('boom'))
})
connectSSE({ url: 'http://x', onMessage: () => {}, onError: (e) => errors.push(e) })
await Promise.resolve()
await Promise.resolve()
expect(errors).toHaveLength(1)
expect((errors[0] as Error).message).toBe('boom')
})
})

View File

@@ -0,0 +1,72 @@
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { useAuthStore } from '@/stores/auth-store'
/**
* Outer SSE envelope from the server. `data` is forwarded exactly as received;
* for the log/status streams it is itself a JSON-encoded string that the
* consumer parses (e.g. `JSON.parse(envelope.data)`).
*/
export type SSEEnvelope = { event: string; data: unknown }
/** Thrown to tell fetch-event-source to stop retrying (e.g. auth failure). */
class FatalSSEError extends Error {}
const MAX_BACKOFF_MS = 30000
/**
* Open an authenticated SSE connection.
* Reads the bearer token once at connect time and sends it via the
* Authorization header. Returns a disposer that aborts the connection.
*/
export function connectSSE(opts: {
url: string
onMessage: (msg: SSEEnvelope) => void
onError?: (err: unknown) => void
}): () => void {
const controller = new AbortController()
let retry = 0
const token = useAuthStore.getState().token
const headers: Record<string, string> = token
? { Authorization: `Bearer ${token}` }
: {}
fetchEventSource(opts.url, {
signal: controller.signal,
openWhenHidden: true,
headers,
onopen: async (res) => {
const contentType = res.headers.get('content-type') ?? ''
if (!res.ok || !contentType.includes('text/event-stream')) {
throw new FatalSSEError(`SSE open failed: ${res.status}`)
}
retry = 0
},
onmessage: (ev) => {
if (!ev.data) return
let envelope: SSEEnvelope
try {
envelope = JSON.parse(ev.data) as SSEEnvelope
} catch (error) {
console.error('[connectSSE] malformed envelope:', error, ev.data)
return
}
if (typeof envelope?.event !== 'string') return
if (envelope.event === 'connected') return
opts.onMessage(envelope)
},
onerror: (err) => {
if (err instanceof FatalSSEError) {
opts.onError?.(err)
throw err // stop: no retry on auth/protocol failure
}
// transient failure: retry silently with exponential backoff (onError is not called)
retry += 1
return Math.min(1000 * 2 ** retry, MAX_BACKOFF_MS)
},
}).catch((err) => {
if (!(err instanceof FatalSSEError)) opts.onError?.(err)
})
return () => controller.abort()
}

View File

@@ -0,0 +1,108 @@
import { fetchWithAuth } from './api'
import type { TemplateData } from '@/components/templates/template-schema'
export interface Template {
id: string
name: string
category: string
version: number
content: TemplateData
is_public: boolean
owner_id: string
created_at: string
updated_at: string
description: string
}
export interface CreateTemplatePayload {
name: string
category: string
version?: number
content: TemplateData
is_public?: boolean
}
export interface UpdateTemplatePayload {
name?: string
category?: string
version?: number
content?: TemplateData
is_public?: boolean
}
export interface TestTemplateResponse {
success: boolean
data?: Record<string, unknown>
error?: string
duration_ms: number
status_code?: number
url: string
raw_results?: Record<string, unknown>
}
export interface GenerateTemplateResponse {
yaml_content: string
}
export const templateService = {
getAll: async (): Promise<Template[]> => {
return fetchWithAuth('/api/enrichers/templates', {
method: 'GET'
})
},
getById: async (templateId: string): Promise<Template> => {
return fetchWithAuth(`/api/enrichers/templates/${templateId}`, {
method: 'GET'
})
},
create: async (payload: CreateTemplatePayload): Promise<Template> => {
return fetchWithAuth('/api/enrichers/templates', {
method: 'POST',
body: JSON.stringify(payload)
})
},
update: async (templateId: string, payload: UpdateTemplatePayload): Promise<Template> => {
return fetchWithAuth(`/api/enrichers/templates/${templateId}`, {
method: 'PUT',
body: JSON.stringify(payload)
})
},
delete: async (templateId: string): Promise<void> => {
return fetchWithAuth(`/api/enrichers/templates/${templateId}`, {
method: 'DELETE'
})
},
test: async (templateId: string, inputValue: string): Promise<TestTemplateResponse> => {
return fetchWithAuth(`/api/enrichers/templates/${templateId}/test`, {
method: 'POST',
body: JSON.stringify({ input_value: inputValue })
})
},
testContent: async (inputValue: string, content: TemplateData): Promise<TestTemplateResponse> => {
return fetchWithAuth('/api/enrichers/templates/test', {
method: 'POST',
body: JSON.stringify({ input_value: inputValue, content })
})
},
generate: async (
prompt: string,
inputType?: string,
outputType?: string
): Promise<GenerateTemplateResponse> => {
return fetchWithAuth('/api/enrichers/templates/generate', {
method: 'POST',
body: JSON.stringify({
prompt,
input_type: inputType || null,
output_type: outputType || null
})
})
}
}

View File

@@ -13,6 +13,7 @@ import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
import { queryKeys } from '@/api/query-keys'
import ErrorState from '../shared/error-state'
import { usePermissions } from '@/hooks/use-can'
const AnalysisItem = ({ analysis, active }: { analysis: Analysis; active: boolean }) => {
return (
@@ -46,6 +47,7 @@ const AnalysisItem = ({ analysis, active }: { analysis: Analysis; active: boolea
}
const AnalysisList = () => {
const { canEdit } = usePermissions()
const { investigationId, id, type } = useParams({ strict: false })
const queryClient = useQueryClient()
const navigate = useNavigate()
@@ -112,16 +114,18 @@ const AnalysisList = () => {
return (
<div className="w-full h-full bg-card flex flex-col overflow-hidden">
<div className="p-2 flex items-center h-11 gap-2 border-b shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
title="Create New analysis"
>
<PlusIcon className="h-4 w-4" />
</Button>
{canEdit && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
title="Create New analysis"
>
<PlusIcon className="h-4 w-4" />
</Button>
)}
<Input
type="search"
className="!border border-border h-7"

View File

@@ -19,6 +19,7 @@ import { useConfirm } from '../use-confirm-dialog'
import { Editor } from '@tiptap/core'
import { Link, useParams } from '@tanstack/react-router'
import { useLayoutStore } from '@/stores/layout-store'
import { usePermissions } from '@/hooks/use-can'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
DropdownMenu,
@@ -78,6 +79,7 @@ export const AnalysisEditor = ({
showToolbar = false
}: AnalysisEditorProps) => {
const { confirm } = useConfirm()
const { canEdit } = usePermissions()
const toggleAnalysis = useLayoutStore((s) => s.toggleAnalysis)
const { investigationId: routeInvestigationId, type } = useParams({ strict: false }) as {
investigationId: string
@@ -273,7 +275,7 @@ export const AnalysisEditor = ({
<div className="flex items-center gap-3 flex-1 min-w-0">
{showNavigation && type !== 'analysis' && (
<Button className="h-8 w-8" variant="ghost" onClick={toggleAnalysis}>
<ChevronsRight />
<ChevronsRight className="opacity-70" />
</Button>
)}
@@ -283,7 +285,7 @@ export const AnalysisEditor = ({
<PopoverTrigger asChild>
<div>
<Button variant="ghost" size="icon" className="h-8 w-8">
<ChevronDown className="h-4 w-4" />
<ChevronDown className="h-4 w-4 opacity-70" />
</Button>
</div>
</PopoverTrigger>
@@ -318,7 +320,7 @@ export const AnalysisEditor = ({
{/* Title section */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{isEditingTitle ? (
{canEdit && isEditingTitle ? (
<input
className="text-md font-medium bg-transparent outline-none border-none p-0 m-0 w-full"
value={titleValue}
@@ -340,8 +342,8 @@ export const AnalysisEditor = ({
/>
) : (
<span
className={`text-md font-medium truncate min-w-0 flex-1 ${'cursor-pointer hover:text-primary'}`}
onClick={() => setIsEditingTitle(true)}
className={`text-md font-medium truncate min-w-0 flex-1 ${canEdit ? 'cursor-pointer hover:text-primary' : ''}`}
onClick={canEdit ? () => setIsEditingTitle(true) : undefined}
>
{titleValue || 'Untitled Analysis'}
</span>
@@ -350,14 +352,14 @@ export const AnalysisEditor = ({
</div>
{/* Action buttons */}
{showActions && (
{showActions && canEdit && (
<div className="flex items-center gap-1">
<SaveStatusBadge status={saveStatus} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div>
<Button size="icon" variant="ghost" className="h-8 w-8">
<MoreVertical className="w-4 h-4" strokeWidth={1.5} />
<MoreVertical className="w-4 h-4 opacity-70" strokeWidth={1.5} />
</Button>
</div>
</DropdownMenuTrigger>
@@ -426,29 +428,32 @@ export const AnalysisEditor = ({
}
return content || ''
})()}
onChange={handleEditorChange}
onChange={canEdit ? handleEditorChange : undefined}
className="w-full h-full"
editorContentClassName="p-5 min-h-[300px]"
output="json"
placeholder={'Enter your analysis...'}
autofocus={true}
showToolbar={showToolbar}
placeholder={canEdit ? 'Enter your analysis...' : ''}
autofocus={canEdit}
showToolbar={canEdit && showToolbar}
editorClassName="focus:outline-hidden"
onEditorReady={setEditor}
editable={canEdit}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground space-y-3">
<div>No analysis selected.</div>
<Button
className="shadow-none"
variant="outline"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
>
<PlusIcon className="w-4 h-4 mr-2" strokeWidth={1.5} />
Create your first analysis
</Button>
{canEdit && (
<Button
className="shadow-none"
variant="outline"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
>
<PlusIcon className="w-4 h-4 mr-2" strokeWidth={1.5} />
Create your first analysis
</Button>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,246 @@
import * as React from 'react'
import type { Editor } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
import {
Bold,
Italic,
Underline,
Strikethrough,
Code,
Heading1,
Heading2,
Heading3,
Pilcrow,
Link as LinkIcon,
Highlighter,
ChevronDown
} from 'lucide-react'
import { Separator } from '@/components/ui/separator'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { LinkEditBlock } from '../link/link-edit-block'
import { cn } from '@/lib/utils'
interface TextBubbleMenuProps {
editor: Editor
}
const prevent = (e: React.MouseEvent) => e.preventDefault()
const BubbleButton = ({
active,
onClick,
disabled,
children,
}: {
active?: boolean
onClick: () => void
disabled?: boolean
children: React.ReactNode
}) => (
<button
type="button"
className={cn(
'flex h-7 w-7 items-center justify-center rounded-sm text-sm transition-colors',
'hover:bg-accent hover:text-accent-foreground',
active && 'bg-accent text-accent-foreground',
disabled && 'pointer-events-none opacity-50'
)}
onMouseDown={prevent}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
)
export const TextBubbleMenu: React.FC<TextBubbleMenuProps> = ({ editor }) => {
const [showLinkEditor, setShowLinkEditor] = React.useState(false)
const shouldShow = React.useCallback(
({ editor: ed, from, to }: { editor: Editor; from: number; to: number }) => {
if (from === to) return false
if (!ed.isEditable) return false
if (ed.isActive('codeBlock')) return false
if (ed.isActive('link')) return false
return true
},
[]
)
const handleSetLink = React.useCallback(
(url: string, text?: string, openInNewTab?: boolean) => {
if (text) {
editor
.chain()
.focus()
.insertContent({
type: 'text',
text,
marks: [
{
type: 'link',
attrs: { href: url, target: openInNewTab ? '_blank' : '' }
}
]
})
.run()
} else {
editor
.chain()
.focus()
.setLink({ href: url, target: openInNewTab ? '_blank' : '' })
.run()
}
setShowLinkEditor(false)
},
[editor]
)
const activeHeading = editor.isActive('heading', { level: 1 })
? 'H1'
: editor.isActive('heading', { level: 2 })
? 'H2'
: editor.isActive('heading', { level: 3 })
? 'H3'
: 'P'
return (
<BubbleMenu
editor={editor}
pluginKey="textBubbleMenu"
shouldShow={shouldShow}
options={{
placement: 'top',
offset: 8
}}
>
<div
className="flex items-center gap-0.5 rounded-lg border bg-popover p-1 shadow-md"
onMouseDown={prevent}
>
<BubbleButton
active={editor.isActive('bold')}
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
>
<Bold className="size-4" />
</BubbleButton>
<BubbleButton
active={editor.isActive('italic')}
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
>
<Italic className="size-4" />
</BubbleButton>
<BubbleButton
active={editor.isActive('underline')}
onClick={() => editor.chain().focus().toggleUnderline().run()}
disabled={!editor.can().chain().focus().toggleUnderline().run()}
>
<Underline className="size-4" />
</BubbleButton>
<BubbleButton
active={editor.isActive('strike')}
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
>
<Strikethrough className="size-4" />
</BubbleButton>
<BubbleButton
active={editor.isActive('code')}
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
>
<Code className="size-4" />
</BubbleButton>
<Separator orientation="vertical" className="mx-0.5 h-6" />
{/* Heading dropdown */}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="flex h-7 items-center gap-0.5 rounded-sm px-2 text-xs font-semibold hover:bg-accent hover:text-accent-foreground"
onMouseDown={prevent}
>
{activeHeading}
<ChevronDown className="size-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-1" align="start">
<div className="flex flex-col gap-0.5">
<button
type="button"
className="flex items-center gap-2 rounded-sm px-2 py-1 text-sm hover:bg-accent"
onMouseDown={prevent}
onClick={() => editor.chain().focus().setParagraph().run()}
>
<Pilcrow className="size-4" />
Paragraph
</button>
<button
type="button"
className="flex items-center gap-2 rounded-sm px-2 py-1 text-sm hover:bg-accent"
onMouseDown={prevent}
onClick={() => editor.chain().focus().setHeading({ level: 1 }).run()}
>
<Heading1 className="size-4" />
Heading 1
</button>
<button
type="button"
className="flex items-center gap-2 rounded-sm px-2 py-1 text-sm hover:bg-accent"
onMouseDown={prevent}
onClick={() => editor.chain().focus().setHeading({ level: 2 }).run()}
>
<Heading2 className="size-4" />
Heading 2
</button>
<button
type="button"
className="flex items-center gap-2 rounded-sm px-2 py-1 text-sm hover:bg-accent"
onMouseDown={prevent}
onClick={() => editor.chain().focus().setHeading({ level: 3 }).run()}
>
<Heading3 className="size-4" />
Heading 3
</button>
</div>
</PopoverContent>
</Popover>
<Separator orientation="vertical" className="mx-0.5 h-6" />
{/* Link */}
<Popover open={showLinkEditor} onOpenChange={setShowLinkEditor}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'flex h-7 w-7 items-center justify-center rounded-sm hover:bg-accent hover:text-accent-foreground',
editor.isActive('link') && 'bg-accent text-accent-foreground'
)}
onMouseDown={prevent}
>
<LinkIcon className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent className="w-full min-w-80 p-0" align="start">
<LinkEditBlock onSave={handleSetLink} className="p-4" />
</PopoverContent>
</Popover>
<Separator orientation="vertical" className="mx-0.5 h-6" />
{/* Highlight */}
<BubbleButton
active={editor.isActive('highlight')}
onClick={() => editor.chain().focus().toggleHighlight().run()}
>
<Highlighter className="size-4" />
</BubbleButton>
</div>
</BubbleMenu>
)
}

View File

@@ -0,0 +1,242 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
const dragHandleKey = new PluginKey("dragHandle");
function findBlockAt(
view: any,
y: number,
): { pos: number; dom: HTMLElement } | null {
const editorRect = view.dom.getBoundingClientRect();
const paddingLeft = parseFloat(getComputedStyle(view.dom).paddingLeft) || 0;
const coords = { left: editorRect.left + paddingLeft + 1, top: y };
const posInfo = view.posAtCoords(coords);
if (!posInfo) return null;
const $pos = view.state.doc.resolve(posInfo.pos);
const wrapperTypes = new Set([
"bulletList", "orderedList", "taskList",
]);
for (let d = $pos.depth; d >= 1; d--) {
const node = $pos.node(d);
if (!node.isBlock) continue;
if (d === 1 && $pos.before(1) === 0) continue;
if (wrapperTypes.has(node.type.name)) continue;
if (node.isTextblock && d > 1) {
const parent = $pos.node(d - 1);
if (parent.type.name === "listItem" || parent.type.name === "taskItem") {
continue;
}
}
const pos = $pos.before(d);
const dom = view.nodeDOM(pos);
if (dom instanceof HTMLElement) {
return { pos, dom };
}
}
return null;
}
export const DragHandle = Extension.create({
name: "dragHandle",
addProseMirrorPlugins() {
const handle = document.createElement("div");
handle.className = "flowsint-drag-handle";
handle.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><circle cx="4" cy="2" r="1.5"/><circle cx="10" cy="2" r="1.5"/><circle cx="4" cy="7" r="1.5"/><circle cx="10" cy="7" r="1.5"/><circle cx="4" cy="12" r="1.5"/><circle cx="10" cy="12" r="1.5"/></svg>`;
const indicator = document.createElement("div");
indicator.className = "flowsint-drop-indicator";
let currentBlockPos: number | null = null;
let dragging: { pos: number; ghost: HTMLElement } | null = null;
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
return [
new Plugin({
key: dragHandleKey,
view(view) {
const wrapper = view.dom.parentElement!;
wrapper.style.position = "relative";
wrapper.appendChild(handle);
wrapper.appendChild(indicator);
const positionHandle = (blockDom: HTMLElement, pos: number) => {
if (hideTimeout) clearTimeout(hideTimeout);
currentBlockPos = pos;
const wr = wrapper.getBoundingClientRect();
const br = blockDom.getBoundingClientRect();
handle.style.top = `${br.top - wr.top}px`;
handle.style.left = `${br.left - wr.left - 24}px`;
handle.style.height = `${br.height}px`;
handle.style.display = "flex";
};
const hideAll = () => {
handle.style.display = "none";
indicator.style.display = "none";
currentBlockPos = null;
};
const scheduleHide = () => {
if (hideTimeout) clearTimeout(hideTimeout);
hideTimeout = setTimeout(hideAll, 150);
};
const cancelHide = () => {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
};
const onEditorMouseMove = (e: MouseEvent) => {
if (dragging || e.buttons > 0) return;
const block = findBlockAt(view, e.clientY);
if (block) {
if (block.pos !== currentBlockPos) {
positionHandle(block.dom, block.pos);
} else {
cancelHide();
}
} else {
scheduleHide();
}
};
const onEditorLeave = () => {
if (!dragging) scheduleHide();
};
const onHandleEnter = () => cancelHide();
const onHandleLeave = () => {
if (!dragging) scheduleHide();
};
const onHandleMouseDown = (e: MouseEvent) => {
e.preventDefault();
if (currentBlockPos === null) return;
const pos = currentBlockPos;
const blockDom = view.nodeDOM(pos) as HTMLElement;
if (!blockDom) return;
const ghost = blockDom.cloneNode(true) as HTMLElement;
ghost.className = "flowsint-drag-ghost";
ghost.style.width = `${blockDom.offsetWidth}px`;
ghost.style.left = `${e.clientX}px`;
ghost.style.top = `${e.clientY}px`;
document.body.appendChild(ghost);
blockDom.classList.add("flowsint-dragging-source");
dragging = { pos, ghost };
hideAll();
const onMouseMove = (ev: MouseEvent) => {
if (!dragging) return;
dragging.ghost.style.left = `${ev.clientX}px`;
dragging.ghost.style.top = `${ev.clientY}px`;
const block = findBlockAt(view, ev.clientY);
if (block) {
const wr = wrapper.getBoundingClientRect();
const br = block.dom.getBoundingClientRect();
const above = ev.clientY < br.top + br.height / 2;
indicator.style.top = `${(above ? br.top : br.bottom) - wr.top - 1}px`;
indicator.style.left = `${br.left - wr.left}px`;
indicator.style.width = `${br.width}px`;
indicator.style.display = "block";
} else {
indicator.style.display = "none";
}
};
const onMouseUp = (ev: MouseEvent) => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
if (!dragging) return;
dragging.ghost.remove();
blockDom.classList.remove("flowsint-dragging-source");
indicator.style.display = "none";
const originPos = dragging.pos;
dragging = null;
const originNode = view.state.doc.nodeAt(originPos);
if (!originNode) return;
const target = findBlockAt(view, ev.clientY);
if (!target) return;
if (
originPos < target.pos &&
originPos + originNode.nodeSize > target.pos
)
return;
const targetNode = view.state.doc.nodeAt(target.pos);
if (!targetNode) return;
const targetRect = target.dom.getBoundingClientRect();
const above = ev.clientY < targetRect.top + targetRect.height / 2;
const insertPos = above
? target.pos
: target.pos + targetNode.nodeSize;
if (
insertPos === originPos ||
insertPos === originPos + originNode.nodeSize
)
return;
const tr = view.state.tr;
if (originPos < insertPos) {
const adjusted = insertPos - originNode.nodeSize;
tr.delete(originPos, originPos + originNode.nodeSize);
tr.insert(adjusted, originNode);
} else {
tr.insert(insertPos, originNode);
tr.delete(
originPos + originNode.nodeSize,
originPos + originNode.nodeSize * 2,
);
}
view.dispatch(tr);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};
view.dom.addEventListener("mousemove", onEditorMouseMove);
view.dom.addEventListener("mouseleave", onEditorLeave);
handle.addEventListener("mouseenter", onHandleEnter);
handle.addEventListener("mouseleave", onHandleLeave);
handle.addEventListener("mousedown", onHandleMouseDown);
return {
destroy() {
if (hideTimeout) clearTimeout(hideTimeout);
view.dom.removeEventListener("mousemove", onEditorMouseMove);
view.dom.removeEventListener("mouseleave", onEditorLeave);
handle.removeEventListener("mouseenter", onHandleEnter);
handle.removeEventListener("mouseleave", onHandleLeave);
handle.removeEventListener("mousedown", onHandleMouseDown);
handle.remove();
indicator.remove();
if (dragging) {
dragging.ghost.remove();
dragging = null;
}
},
};
},
}),
];
},
});

View File

@@ -0,0 +1 @@
export { DragHandle } from './drag-handle'

View File

@@ -0,0 +1 @@
export { PasteMarkdown } from './markdown-paste'

View File

@@ -0,0 +1,62 @@
import { Extension } from '@tiptap/core'
import { Plugin } from '@tiptap/pm/state'
import { MarkdownManager } from '@tiptap/markdown'
function looksLikeMarkdown(text: string): boolean {
return (
/^#{1,6}\s/m.test(text) ||
/\*\*[^*]+\*\*/.test(text) ||
/\[.+\]\(.+\)/.test(text) ||
/^[-*+]\s/m.test(text) ||
/^\d+\.\s/m.test(text) ||
/^>\s/m.test(text) ||
/^```/m.test(text) ||
/^---$/m.test(text) ||
/!\[.*\]\(.*\)/.test(text) ||
/^- \[[ x]\]/m.test(text)
)
}
export const PasteMarkdown = Extension.create({
name: 'pasteMarkdown',
addStorage() {
return {
markdownManager: null as MarkdownManager | null
}
},
onCreate() {
this.storage.markdownManager = new MarkdownManager({
extensions: this.editor.extensionManager.baseExtensions
})
},
addProseMirrorPlugins() {
const { editor } = this
const storage = this.storage
return [
new Plugin({
props: {
handlePaste(_view, event) {
const text = event.clipboardData?.getData('text/plain')
if (!text) return false
if (storage.markdownManager && looksLikeMarkdown(text)) {
try {
const json = storage.markdownManager.parse(text)
editor.chain().focus().insertContent(json).run()
return true
} catch (e) {
console.error('[PasteMarkdown]', e)
return false
}
}
return false
}
}
})
]
}
})

View File

@@ -4,11 +4,10 @@ import { ItemType } from '@/stores/node-display-settings'
import { useIcon } from '@/hooks/use-icon'
export interface MentionItem {
value: string
nodeLabel: string
nodeType: ItemType
nodeImage: string | null
nodeIcon: string | any | null
nodeColor: string | null
nodeId: string
}
@@ -95,7 +94,6 @@ type MentionItemProps = {
}
const MentionListItem = ({ item, index, selectedIndex, selectItem }: MentionItemProps) => {
const SourceIcon = useIcon(item.nodeType, {
nodeColor: item.nodeColor,
// @ts-ignore
nodeIcon: item.nodeIcon,
nodeImage: item.nodeImage
@@ -113,7 +111,7 @@ const MentionListItem = ({ item, index, selectedIndex, selectItem }: MentionItem
type="button"
>
{SourceIcon && <SourceIcon size={14} />}
<span className="flex-1 text-left truncate text-ellipsis">{item.value}</span>
<span className="flex-1 text-left truncate text-ellipsis">{item.nodeLabel}</span>
</button>
)
}

View File

@@ -40,7 +40,7 @@ const getMentionItemsFromNodes = (): MentionItem[] => {
return nodes
.map((node) => {
return {
value: node.nodeLabel,
nodeLabel: node.nodeLabel,
nodeType: node.nodeType,
nodeId: node.id,
nodeImage: node.nodeImage,
@@ -70,27 +70,25 @@ const updatePosition = (editor: Editor, element: HTMLElement) => {
// Composant React custom pour le rendu de la mention
const MentionComponent = memo((props: any) => {
const nodeId = props.node.attrs.nodeId
const node = props.node as GraphNode
const attrs = props.node.attrs
const { nodeId, nodeType, nodeIcon, nodeImage, nodeLabel } = attrs
const SourceIcon = useIcon(node.nodeType, {
nodeColor: node.nodeColor,
nodeIcon: node.nodeIcon,
nodeImage: node.nodeImage
const SourceIcon = useIcon(nodeType, {
nodeIcon,
nodeImage
})
const colors = useNodesDisplaySettings((s) => s.colors)
const color =
(node.nodeColor ?? node.nodeType)
? // @ts-ignore
(colors[node.nodeType] ?? GRAPH_COLORS.NODE_DEFAULT)
: GRAPH_COLORS.NODE_DEFAULT
const color = nodeType
? // @ts-ignore
(colors[nodeType] ?? GRAPH_COLORS.NODE_DEFAULT)
: GRAPH_COLORS.NODE_DEFAULT
const centerOnNode = useGraphControls((state) => state.centerOnNode)
const setCurrentNodeFromId = useGraphStore((state) => state.setCurrentNodeFromId)
const handleClick = useCallback(() => {
const node = setCurrentNodeFromId(nodeId)
if (node) {
const { x, y } = node
const currentNode = setCurrentNodeFromId(nodeId)
if (currentNode) {
const { x, y } = currentNode
// Auto-zoom if enabled and node has coordinates
if (x !== undefined && y !== undefined) {
setTimeout(() => {
@@ -98,16 +96,13 @@ const MentionComponent = memo((props: any) => {
}, 200)
}
}
}, [nodeId, centerOnNode])
}, [nodeId, centerOnNode, setCurrentNodeFromId])
return (
<NodeViewWrapper
style={{ display: 'inline-flex', justifyItems: 'center', padding: 0, margin: 0 }}
>
<Button
variant={'ghost'}
<NodeViewWrapper className="inline-flex">
<button
onClick={handleClick}
className="h-5 px-.5 gap-1 cursor-pointer items-center text-foreground"
className="px-.5 gap-1 pr-1 py-.5 flex items-center cursor-pointer text-xs rounded-full text-foreground"
style={{
backgroundColor: hexWithOpacity(color, 0.2),
//@ts-ignore
@@ -115,8 +110,8 @@ const MentionComponent = memo((props: any) => {
}}
>
{SourceIcon && <SourceIcon size={12} className="opacity-60" />}
{props.node.nodeLabel}
</Button>
{nodeLabel}
</button>
</NodeViewWrapper>
)
})
@@ -127,7 +122,7 @@ export const Mention = TiptapMention.extend({
},
addAttributes() {
return {
label: {
nodeLabel: {
default: null,
parseHTML: (element) => element.getAttribute('data-label'),
renderHTML: (attributes) => {
@@ -135,19 +130,43 @@ export const Mention = TiptapMention.extend({
return {}
}
return {
'data-label': attributes.label
'data-label': attributes.nodeLabel
}
}
},
type: {
nodeType: {
default: null,
parseHTML: (element) => element.getAttribute('data-type'),
renderHTML: (attributes) => {
if (!attributes.type) {
if (!attributes.nodeType) {
return {}
}
return {
'data-type': attributes.type
'data-type': attributes.nodeType
}
}
},
nodeImage: {
default: null,
parseHTML: (element) => element.getAttribute('data-image'),
renderHTML: (attributes) => {
if (!attributes.nodeImage) {
return {}
}
return {
'data-image': attributes.nodeImage
}
}
},
nodeIcon: {
default: null,
parseHTML: (element) => element.getAttribute('data-icon'),
renderHTML: (attributes) => {
if (!attributes.nodeIcon) {
return {}
}
return {
'data-icon': attributes.nodeIcon
}
}
},
@@ -182,7 +201,7 @@ export const Mention = TiptapMention.extend({
items: ({ query }: { query: string }) => {
const items = getMentionItemsFromNodes()
return items
.filter((item) => item.value.toLowerCase().includes(query.toLowerCase()))
.filter((item) => item.nodeLabel.toLowerCase().includes(query.toLowerCase()))
.slice(0, 10)
},

View File

@@ -0,0 +1,2 @@
export { SlashCommand } from './slash-command'
export type { SlashCommandItem } from './slash-command'

View File

@@ -0,0 +1,131 @@
import { forwardRef, useEffect, useImperativeHandle, useState, useCallback } from 'react'
import type { SlashCommandItem } from './slash-command'
import {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
ListTodo,
Quote,
Code,
Minus,
ImageIcon
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
const iconMap: Record<string, LucideIcon> = {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
ListTodo,
Quote,
Code,
Minus,
ImageIcon
}
export interface SlashCommandListProps {
items: SlashCommandItem[]
command: (item: SlashCommandItem) => void
}
export interface SlashCommandListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean
}
const SlashCommandList = forwardRef<SlashCommandListRef, SlashCommandListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const selectItem = useCallback(
(index: number) => {
const item = props.items[index]
if (item) {
props.command(item)
}
},
[props]
)
useEffect(() => setSelectedIndex(0), [props.items])
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === 'ArrowUp') {
setSelectedIndex((prev) => (prev + props.items.length - 1) % props.items.length)
return true
}
if (event.key === 'ArrowDown') {
setSelectedIndex((prev) => (prev + 1) % props.items.length)
return true
}
if (event.key === 'Enter') {
selectItem(selectedIndex)
return true
}
return false
}
}))
// Group items by category
const grouped = props.items.reduce<Record<string, SlashCommandItem[]>>((acc, item) => {
if (!acc[item.category]) acc[item.category] = []
acc[item.category].push(item)
return acc
}, {})
// Flat index tracking
let flatIndex = -1
if (!props.items.length) {
return (
<div className="z-50 w-[280px] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md">
<div className="px-2 py-1.5 text-sm text-muted-foreground">No results</div>
</div>
)
}
return (
<div className="z-50 w-[280px] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md">
<div className="overflow-y-auto max-h-[300px] p-1">
{Object.entries(grouped).map(([category, items]) => (
<div key={category}>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">{category}</div>
{items.map((item) => {
flatIndex++
const currentIndex = flatIndex
const Icon = iconMap[item.icon]
return (
<button
key={item.title}
className={`relative flex w-full cursor-pointer select-none items-center gap-3 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground ${
currentIndex === selectedIndex ? 'bg-accent text-accent-foreground' : ''
}`}
onClick={() => selectItem(currentIndex)}
type="button"
>
{Icon && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border bg-background">
<Icon size={16} />
</div>
)}
<div className="flex flex-col text-left">
<span className="font-medium">{item.title}</span>
<span className="text-xs text-muted-foreground">{item.description}</span>
</div>
</button>
)
})}
</div>
))}
</div>
</div>
)
})
SlashCommandList.displayName = 'SlashCommandList'
export { SlashCommandList }
export default SlashCommandList

View File

@@ -0,0 +1,211 @@
import { Extension } from '@tiptap/core'
import { computePosition, flip, shift } from '@floating-ui/dom'
import { posToDOMRect, ReactRenderer } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
import Suggestion from '@tiptap/suggestion'
import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'
import { SlashCommandList } from './slash-command-list'
import type { SlashCommandListRef } from './slash-command-list'
export interface SlashCommandItem {
title: string
description: string
category: string
icon: string
command: (editor: Editor) => void
}
const updatePosition = (editor: Editor, element: HTMLElement) => {
const virtualElement = {
getBoundingClientRect: () =>
posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to)
}
computePosition(virtualElement, element, {
placement: 'bottom-start',
strategy: 'absolute',
middleware: [shift(), flip()]
}).then(({ x, y, strategy }) => {
element.style.width = 'max-content'
element.style.position = strategy
element.style.left = `${x}px`
element.style.top = `${y}px`
})
}
const slashCommandItems: SlashCommandItem[] = [
{
title: 'Heading 1',
description: 'Large section heading',
category: 'Hierarchy',
icon: 'Heading1',
command: (editor) => editor.chain().focus().setHeading({ level: 1 }).run()
},
{
title: 'Heading 2',
description: 'Medium section heading',
category: 'Hierarchy',
icon: 'Heading2',
command: (editor) => editor.chain().focus().setHeading({ level: 2 }).run()
},
{
title: 'Heading 3',
description: 'Small section heading',
category: 'Hierarchy',
icon: 'Heading3',
command: (editor) => editor.chain().focus().setHeading({ level: 3 }).run()
},
{
title: 'Bullet List',
description: 'Create a simple bullet list',
category: 'Lists',
icon: 'List',
command: (editor) => editor.chain().focus().toggleBulletList().run()
},
{
title: 'Numbered List',
description: 'Create a numbered list',
category: 'Lists',
icon: 'ListOrdered',
command: (editor) => editor.chain().focus().toggleOrderedList().run()
},
{
title: 'Task List',
description: 'Create a task checklist',
category: 'Lists',
icon: 'ListTodo',
command: (editor) => editor.chain().focus().toggleTaskList().run()
},
{
title: 'Blockquote',
description: 'Add a quote block',
category: 'Blocks',
icon: 'Quote',
command: (editor) => editor.chain().focus().toggleBlockquote().run()
},
{
title: 'Code Block',
description: 'Add a code snippet',
category: 'Blocks',
icon: 'Code',
command: (editor) => editor.chain().focus().toggleCodeBlock().run()
},
{
title: 'Divider',
description: 'Add a horizontal divider',
category: 'Blocks',
icon: 'Minus',
command: (editor) => editor.chain().focus().setHorizontalRule().run()
},
{
title: 'Image',
description: 'Upload or embed an image',
category: 'Media',
icon: 'ImageIcon',
command: (editor) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = () => {
const file = input.files?.[0]
if (file) {
const blobUrl = URL.createObjectURL(file)
editor.commands.insertContent({
type: 'image',
attrs: {
src: blobUrl,
alt: file.name,
title: file.name
}
})
}
}
input.click()
}
}
]
export const SlashCommand = Extension.create({
name: 'slashCommand',
addOptions() {
return {
suggestion: {
char: '/',
startOfLine: false,
items: ({ query }: { query: string }): SlashCommandItem[] => {
return slashCommandItems.filter(
(item) =>
item.title.toLowerCase().includes(query.toLowerCase()) ||
item.category.toLowerCase().includes(query.toLowerCase())
)
},
render: () => {
let component: ReactRenderer<SlashCommandListRef> | undefined
return {
onStart: (props: SuggestionProps) => {
component = new ReactRenderer(SlashCommandList, {
props,
editor: props.editor
})
if (!props.clientRect) return
const element = component.element as HTMLElement
element.style.position = 'absolute'
element.style.zIndex = '9999'
document.body.appendChild(element)
updatePosition(props.editor, element)
},
onUpdate(props: SuggestionProps) {
if (!component) return
component.updateProps(props)
if (!props.clientRect) return
const element = component.element as HTMLElement
updatePosition(props.editor, element)
},
onKeyDown(props: { event: KeyboardEvent }) {
if (props.event.key === 'Escape') {
component?.destroy()
return true
}
return component?.ref?.onKeyDown(props) ?? false
},
onExit() {
if (!component) return
component.element.remove()
component.destroy()
}
}
},
command: ({
editor,
range,
props
}: {
editor: Editor
range: { from: number; to: number }
props: SlashCommandItem
}) => {
editor.chain().focus().deleteRange(range).run()
props.command(editor)
}
} as Partial<SuggestionOptions>
}
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion
})
]
}
})
export default SlashCommand

View File

@@ -21,7 +21,7 @@ const ChatHistory = ({
const { confirm } = useConfirm()
const setCurrentChatId = useChatState((s) => s.setCurrentChatId)
const { data: chats, isLoading } = useQuery({
queryKey: ['chats'],
queryKey: ['chats', 'list'],
queryFn: () => chatCRUDService.get()
})

View File

@@ -0,0 +1,31 @@
import { Link } from '@tanstack/react-router'
import { KeyRound, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
export default function ChatInactive({ onClose }: { onClose: any }) {
return (
<div className="flex flex-col h-full items-center justify-center relative py-16 px-4">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 absolute top-3 right-3"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
<div className="w-14 h-14 mb-5 text-muted-foreground/30">
<KeyRound className="w-full h-full" strokeWidth={1.5} />
</div>
<h3 className="text-sm font-medium text-foreground mb-1">API key required</h3>
<p className="text-sm text-muted-foreground text-center max-w-[260px] mb-5">
Add a <code className="text-xs bg-muted px-1.5! py-0.5! rounded">MISTRAL_API_KEY</code> in
your vault to start using the chat assistant
</p>
<Link to="/dashboard/vault">
<Button size="sm" className="h-8 text-xs gap-1.5">
Go to Vault
</Button>
</Link>
</div>
)
}

View File

@@ -3,25 +3,25 @@ import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { XIcon, ArrowUp } from 'lucide-react'
import { useGraphStore } from '@/stores/graph-store'
import { useRef, useEffect, memo } from 'react'
import { useRef, useEffect, memo, useState } from 'react'
import { useNodesDisplaySettings } from '@/stores/node-display-settings'
import { ChatContextFormat } from '@/types'
interface ChatPanelProps {
customPrompt: string
setCustomPrompt: (prompt: string) => void
handleCustomPrompt: (editorValue: any) => void
isAiLoading: boolean
editorValue: any
onSend: (text: string, context?: string[]) => void
isLoading: boolean
}
export const ChatPanel = ({
customPrompt,
setCustomPrompt,
handleCustomPrompt,
isAiLoading,
editorValue
}: ChatPanelProps) => {
const selectedNodes = useGraphStore((s) => s.selectedNodes)
const formatContext = (context: ChatContextFormat[]): string[] => {
return context.map((item) => {
if (item.type === 'relation') return `${item.fromLabel} -> ${item.label} -> ${item.toLabel}`
return item.nodeLabel
})
}
export const ChatPanel = ({ onSend, isLoading }: ChatPanelProps) => {
const [input, setInput] = useState('')
const selectedNodes = useGraphStore((s) => s.selectedNodesWithEdgesAsList)
const clearSelectedNodes = useGraphStore((s) => s.clearSelectedNodes)
const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -40,11 +40,12 @@ export const ChatPanel = ({
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`
}
}, [customPrompt])
}, [input])
const handleSubmit = () => {
if (customPrompt.trim() && !isAiLoading) {
handleCustomPrompt(editorValue)
if (input.trim() && !isLoading) {
onSend(input.trim(), selectedNodes.length > 0 ? formatContext(selectedNodes) : undefined)
setInput('')
}
}
@@ -79,21 +80,21 @@ export const ChatPanel = ({
ref={textareaRef}
autoFocus
placeholder="Ask me anything about your investigation..."
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
className={`
min-h-[44px] max-h-[120px] resize-none
border border-border bg-background
min-h-11 max-h-[120px] resize-none
border border-border bg-background
focus:ring-2 focus:ring-primary/20 focus:border-primary
transition-all duration-200
pr-12 py-3 px-4 rounded-xl
`}
disabled={isAiLoading}
disabled={isLoading}
/>
<Button
onClick={handleSubmit}
disabled={!customPrompt.trim() || isAiLoading}
disabled={!input.trim() || isLoading}
size="icon"
variant={'ghost'}
className={`
@@ -101,48 +102,57 @@ export const ChatPanel = ({
h-8 w-8 rounded-full
transition-all duration-200
${
customPrompt.trim() && !isAiLoading
input.trim() && !isLoading
? 'text-primary-foreground'
: 'bg-muted text-muted-foreground'
}
`}
>
{isAiLoading ? (
{isLoading ? (
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<ArrowUp className="w-4 h-4" />
)}
</Button>
</div>
{/* <div className="text-xs text-muted-foreground flex items-center gap-2">
{isAiLoading && (
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
<span>Processing...</span>
</div>
)}
</div> */}
</div>
</div>
</div>
)
}
export const ContextList = memo(({ context }: { context: any }) => {
export const ContextList = memo(({ context }: { context: ChatContextFormat[] }) => {
const colors = useNodesDisplaySettings((s) => s.colors)
return (
<div className="flex flex-nowrap overflow-x-auto hide-scrollbar gap-1.5 items-center px-1">
{context.map((item: any, index: number) => {
const color = colors[item?.data?.type || item?.type]
{context.map((item: ChatContextFormat, index: number) => {
if (item.type === 'node') {
const color = item.nodeColor ?? colors[item.nodeType]
return (
<Badge
key={index}
variant="secondary"
className="flex items-center border border-border gap-1.5 text-xs"
>
<span style={{ background: color }} className="w-1.5 h-1.5 rounded-full" />
{item.nodeLabel || 'Unknown'}
</Badge>
)
}
const fromColor = item.fromColor ?? colors[item.fromType]
const toColor = item.toColor ?? colors[item.toType]
return (
<Badge
key={index}
variant="secondary"
className="flex items-center border border-border gap-1.5 text-xs"
>
<span style={{ background: color }} className="w-1.5 h-1.5 rounded-full" />
{item.data?.label || 'Unknown'}
<span style={{ background: fromColor }} className="w-1.5 h-1.5 rounded-full" />
{item.fromLabel || 'Unknown'} {'->'}
<span style={{ background: toColor }} className="w-1.5 h-1.5 rounded-full" />
{item.toLabel || 'Unknown'}
</Badge>
)
})}

View File

@@ -1,15 +1,11 @@
import { useChat } from '@/hooks/use-chat'
import { memo, useEffect, useState, useRef } from 'react'
import { ChatPanel, ContextList } from './chat-prompt'
import { memo, useEffect, useRef, useState } from 'react'
import { ChatPanel } from './chat-prompt'
import { Button } from '../ui/button'
import { X, Plus, History } from 'lucide-react'
import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut'
import { Card } from '../ui/card'
import { useLayoutStore } from '@/stores/layout-store'
import { useChatState } from '@/stores/use-chat-store'
import { useQuery } from '@tanstack/react-query'
import { chatCRUDService } from '@/api/chat-service'
import { ChatMessage } from '@/types'
import { cn } from '@/lib/utils'
import { MemoizedMarkdown } from './memoized-markdown'
import { ChatSkeleton } from './chat-skeleton'
@@ -17,47 +13,31 @@ import { ResizableChat } from './resizable-chat'
import ChatHistory from './chat-history'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { CopyButton } from '../copy'
import type { UIMessage } from 'ai'
import { useIsChatActive } from '@/hooks/use-is-chat-active'
import ChatInactive from './chat-inactive'
function FloatingChat() {
const [editorValue, setEditorValue] = useState<any>('')
const bottomRef = useRef<HTMLDivElement>(null)
const currentChatId = useChatState((s) => s.currentChatId)
const isOpenChat = useLayoutStore((s) => s.isOpenChat)
const toggleChat = useLayoutStore((s) => s.toggleChat)
const closeChat = useLayoutStore((s) => s.closeChat)
const messages = useChatState((s) => s.messages)
const setMessages = useChatState((s) => s.setMessages)
const [view, setView] = useState<'chat' | 'history'>('chat')
const { data: chat, isLoading } = useQuery({
queryKey: ['chat', currentChatId],
enabled: Boolean(currentChatId) && isOpenChat,
queryFn: () => chatCRUDService.getById(currentChatId as string)
})
useEffect(() => {
setEditorValue('')
setMessages([])
}, [currentChatId, setMessages])
useEffect(() => {
if (chat?.messages) {
setMessages(chat.messages)
}
}, [chat, setMessages])
// Chat hook
const {
isAiLoading,
customPrompt,
setCustomPrompt,
handleCustomPrompt,
messages,
status,
sendMessage,
currentChat,
isLoadingChat,
createNewChat,
deleteChatMutation
} = useChat({
onContentUpdate: setEditorValue,
onSuccess: () => {}
})
deleteChatMutation,
currentChatId
} = useChat()
const { exists: isChatActive } = useIsChatActive()
const isStreaming = status === 'streaming' || status === 'submitted'
const { isMac } = useKeyboardShortcut({
key: 'e',
@@ -71,24 +51,18 @@ function FloatingChat() {
callback: closeChat
})
const handlecreateNewChat = () => {
const handleCreateNewChat = () => {
createNewChat()
setView('chat')
}
// Check if there's content in the editor
const hasContent =
editorValue &&
(typeof editorValue === 'string'
? editorValue.trim() !== ''
: editorValue.content && editorValue.content.length > 0)
const hasMessages = messages.length > 0
useEffect(() => {
if (bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [messages, editorValue])
}, [messages])
const keyboardShortcut = isMac ? '⌘+E' : 'Ctrl+E'
@@ -105,7 +79,6 @@ function FloatingChat() {
variant="outline"
onClick={toggleChat}
>
{/* <Sparkles className='!h-5 !w-5' /> */}
<img src="/icon.png" alt="Flowsint" className="h-12 w-12 object-contain" />
</Button>
</div>
@@ -123,172 +96,149 @@ function FloatingChat() {
{/* Chat Panel */}
{isOpenChat && (
<div className="fixed bottom-12 overflow-hidden rounded-2xl right-4 shadow z-21">
<div className="fixed bottom-12 overflow-hidden rounded-2xl right-4 shadow-xl z-50">
<ResizableChat minWidth={400} minHeight={400} maxWidth={800} maxHeight={800}>
<Card className="overflow-hidden !backdrop-blur bg-background/90 rounded-2xl gap-0 py-0 h-full">
{view === 'history' ? (
<ChatHistory
setView={setView}
deleteChatMutation={deleteChatMutation}
handleCreateNewChat={handlecreateNewChat}
/>
) : (
<>
<div className="flex items-center justify-between p-3 border-b">
<div className="flex w-full items-center justify-between gap-1">
<div className="flex items-center gap-2 truncate text-ellipsis">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
//@ts-ignore
onClick={handlecreateNewChat}
title="Create new chat"
>
<Plus className="h-3 w-3" />
</Button>
<span className="text-sm opacity-60 truncate text-ellipsis">
{chat?.title}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
//@ts-ignore
onClick={() => setView('history')}
title="Create new chat"
>
<History className="h-3 w-3 opacity-60" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={closeChat}>
<X className="h-4 w-4" />
</Button>
{isChatActive ? (
<Card className="overflow-hidden backdrop-blur! bg-background/90 rounded-2xl gap-0 py-0 h-full">
{view === 'history' ? (
<ChatHistory
setView={setView}
deleteChatMutation={deleteChatMutation}
handleCreateNewChat={handleCreateNewChat}
/>
) : (
<>
<div className="flex items-center justify-between h-10 p-3 border-b">
<div className="flex w-full items-center justify-between gap-1">
<div className="flex items-center gap-2 truncate text-ellipsis">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
//@ts-ignore
onClick={handleCreateNewChat}
title="Create new chat"
>
<Plus className="h-3 w-3" />
</Button>
<span className="text-sm opacity-60 truncate text-ellipsis">
{currentChat?.title}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
//@ts-ignore
onClick={() => setView('history')}
title="Create new chat"
>
<History className="h-3 w-3 opacity-60" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={closeChat}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
{/* Content */}
<div className="flex flex-col flex-1 overflow-auto">
{isLoading ? (
<ChatSkeleton />
) : hasMessages ? (
<div className="grow p-4 flex flex-col gap-2">
{messages.map((message: ChatMessage) => (
<ChatMessageComponent key={message.id} message={message} />
))}
{hasContent && (
<ChatMessageComponent
key="draft"
message={{
content: editorValue,
is_bot: true,
id: new Date().getTime().toString(),
created_at: new Date().toISOString(),
chatId: currentChatId || undefined
}}
/>
)}
<div ref={bottomRef} />
</div>
) : null}
{!hasMessages && !hasContent && !isLoading && (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center text-muted-foreground">
{!currentChatId ? (
// No currentChatId - Empty state with sparkles
<>
<div className="relative mb-6">
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-primary/10 rounded-full blur-xl animate-pulse"></div>
<div className="relative bg-gradient-to-br from-primary/10 to-primary/5 rounded-full p-4 border border-primary/20">
<img
src="/icon.png"
alt="Flowsint"
className="h-12 w-12 object-cover"
/>
{/* Content */}
<div className="flex flex-col flex-1 overflow-auto">
{isLoadingChat ? (
<ChatSkeleton />
) : hasMessages ? (
<div className="grow p-4 flex flex-col gap-2">
{messages.map((message: UIMessage) => (
<ChatMessageComponent key={message.id} message={message} />
))}
<div ref={bottomRef} />
</div>
) : null}
{!hasMessages && !isLoadingChat && (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center text-muted-foreground">
{!currentChatId ? (
<>
<div className="relative mb-6">
<div className="absolute inset-0 bg-linear-to-r from-primary/20 to-primary/10 rounded-full blur-xl animate-pulse"></div>
<div className="relative bg-linear-to-br from-primary/10 to-primary/5 rounded-full p-4 border border-primary/20">
<img
src="/icon.png"
alt="Flowsint"
className="h-12 w-12 object-cover"
/>
</div>
</div>
</div>
<div className="space-y-2 max-w-sm">
<h3 className="text-lg font-semibold text-foreground">
No conversations yet
</h3>
<p className="text-sm opacity-70">
Start chatting with AI to analyze your investigation data and get
insights. Your conversation history will appear here.
</p>
</div>
<div className="mt-6">
<Button
variant="outline"
size="sm"
onClick={handlecreateNewChat}
className="bg-gradient-to-r from-primary/10 to-primary/5 border-primary/20 hover:from-primary/20 hover:to-primary/10"
>
Start your first chat
</Button>
</div>
</>
) : (
// Has currentChatId but no messages - Examples of things to ask
<>
{/* <div className="relative mb-6">
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-primary/10 rounded-full blur-xl animate-pulse"></div>
<div className="relative bg-gradient-to-br from-primary/10 to-primary/5 rounded-full p-6 border border-primary/20">
<Sparkles className="h-8 w-8 text-primary/60" />
</div>
</div> */}
<div className="space-y-2 max-w-sm">
<h3 className="text-lg font-semibold text-foreground">
Start your conversation with{' '}
<span className="text-primary">Flo</span>
</h3>
<p className="text-sm opacity-70">
Ask me anything about your investigation. Here are some examples:
</p>
</div>
<div className="mt-6 space-y-3 max-w-md">
<div className="text-xs space-y-1">
<p className="font-medium text-left">Analysis & Insights</p>
<ul className="space-y-1 text-left opacity-60">
<li> "Analyze the connections between these entities"</li>
<li> "What patterns do you see in this data?"</li>
<li> "Summarize the key findings from this investigation"</li>
</ul>
<div className="space-y-2 max-w-sm">
<h3 className="text-lg font-semibold text-foreground">
No conversations yet
</h3>
<p className="text-sm opacity-70">
Start chatting with AI to analyze your investigation data and get
insights. Your conversation history will appear here.
</p>
</div>
{/* <div className="text-xs opacity-60 space-y-1">
<p className="font-medium">Data Exploration:</p>
<ul className="space-y-1 text-left">
<li>• "Find all IP addresses related to this domain"</li>
<li>• "Show me all crypto wallets connected to this address"</li>
<li>• "What social media accounts are linked to this email?"</li>
</ul>
</div> */}
<div className="text-xs space-y-1">
<p className="font-medium text-left">Investigation Help</p>
<ul className="space-y-1 text-left opacity-60">
<li> "Suggest next steps for this investigation"</li>
<li> "What should I look for next?"</li>
<li> "Help me organize this investigation"</li>
</ul>
<div className="mt-6">
<Button
variant="outline"
size="sm"
onClick={handleCreateNewChat}
className="bg-linear-to-r from-primary/10 to-primary/5 border-primary/20 hover:from-primary/20 hover:to-primary/10"
>
Start your first chat
</Button>
</div>
</div>
</>
)}
</div>
)}
</div>
<div className="border-t">
<ChatPanel
customPrompt={customPrompt}
setCustomPrompt={setCustomPrompt}
handleCustomPrompt={handleCustomPrompt}
isAiLoading={isAiLoading}
editorValue={editorValue}
/>
</div>
</>
)}
</Card>
</>
) : (
<>
<div className="space-y-2 max-w-sm">
<h3 className="text-lg font-semibold text-foreground">
Start your conversation with{' '}
<span className="text-primary">Flo</span>
</h3>
<p className="text-sm opacity-70">
Ask me anything about your investigation. Here are some examples:
</p>
</div>
<div className="mt-6 space-y-3 max-w-md">
<div className="text-xs space-y-1">
<p className="font-medium text-left">Analysis & Insights</p>
<ul className="space-y-1 text-left opacity-60">
<li> "Analyze the connections between these entities"</li>
<li> "What patterns do you see in this data?"</li>
<li> "Summarize the key findings from this investigation"</li>
</ul>
</div>
<div className="text-xs space-y-1">
<p className="font-medium text-left">Investigation Help</p>
<ul className="space-y-1 text-left opacity-60">
<li> "Suggest next steps for this investigation"</li>
<li> "What should I look for next?"</li>
<li> "Help me organize this investigation"</li>
</ul>
</div>
</div>
</>
)}
</div>
)}
</div>
<div className="border-t">
<ChatPanel onSend={sendMessage} isLoading={isStreaming} />
</div>
</>
)}
</Card>
) : (
<Card className="overflow-hidden backdrop-blur! bg-background/90 rounded-2xl gap-0 py-0 h-full">
<ChatInactive onClose={closeChat} />
</Card>
)}
</ResizableChat>
</div>
)}
@@ -296,20 +246,29 @@ function FloatingChat() {
)
}
const ChatMessageComponent = ({ message }: { message: ChatMessage }) => {
if (message.is_bot)
function getMessageText(message: UIMessage): string {
return message.parts
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.map((p) => p.text)
.join('')
}
const ChatMessageComponent = ({ message }: { message: UIMessage }) => {
const content = getMessageText(message)
if (message.role === 'assistant')
return (
<MessageContainer copyContent={message.content}>
<div className={cn('justify-start', 'flex w-full')}>
<div className={cn('w-full', 'p-3 rounded-xl max-w-full', 'flex flex-col gap-2')}>
<MemoizedMarkdown id={message.id} content={message.content} />
<MessageContainer copyContent={content}>
<div className={cn('justify-start', 'flex w-full border rounded-lg')}>
<div className={cn('w-full', 'p-3 rounded-xl max-w-full', 'flex flex-col')}>
<MemoizedMarkdown id={message.id} content={content} />
</div>
</div>
</MessageContainer>
)
return (
<MessageContainer copyContent={message.content}>
<MessageContainer copyContent={content}>
<div className={cn('items-end', 'flex w-full flex-col gap-2')}>
<div
className={cn(
@@ -318,9 +277,8 @@ const ChatMessageComponent = ({ message }: { message: ChatMessage }) => {
'flex flex-col items-end overflow-x-hidden'
)}
>
{/* {message?.context && message.context.length > 0 && <div className='flex items-center w-full overflow-x-auto justify-end mb-2'><ContextList context={message.context} /></div>} */}
<span className="px-3">
<MemoizedMarkdown id={message.id} content={message.content} />
<MemoizedMarkdown id={message.id} content={content} />
</span>
</div>
</div>
@@ -331,10 +289,10 @@ const ChatMessageComponent = ({ message }: { message: ChatMessage }) => {
const MessageContainer = memo(
({ children, copyContent }: { children: React.ReactNode; copyContent?: string }) => {
return (
<div className="relative group">
<div className="relative group/msg">
{children}
<div className="-mt-1 group-hover:opacity-100 opacity-0 flex items-center justify-end z-1">
<div className="border rounded-md bg-background ">
<div className="flex items-center justify-end z-1 ">
<div className="group-hover/msg:opacity-100 border -mt-1 opacity-0 bg-background rounded">
<CopyButton content={copyContent ?? ''} />
</div>
</div>

View File

@@ -26,7 +26,7 @@ const MemoizedMarkdownBlock = memo(
style={dracula}
language={match[1]}
PreTag="div"
className="rounded border border-border bg-muted px-1 py-0.5 !m-0 text-sm w-auto not-prose"
className="rounded border border-border bg-muted px-1 py-0.5 m-0! text-sm w-auto not-prose"
{...props}
/>
<div className="absolute top-2 right-2">
@@ -64,7 +64,7 @@ export const MemoizedMarkdown = memo(({ content, id }: { content: string; id: st
const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content])
return (
<div className="prose max-w-none dark:prose-invert">
<div className="prose max-w-none dark:prose-invert text-sm">
{blocks.map((block, index) => (
<MemoizedMarkdownBlock content={block} key={`${id}-block_${index}`} />
))}

View File

@@ -1,6 +1,6 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { cn } from '@/lib/utils'
import React, { useCallback } from 'react'
import { useLayoutStore } from '@/stores/layout-store'
import { ResizableContainer } from '@/components/ui/resizable-container'
interface ResizableChatProps {
children: React.ReactNode
@@ -18,146 +18,27 @@ export const ResizableChat: React.FC<ResizableChatProps> = ({
maxHeight = 800
}) => {
const { chatWidth, chatHeight, setChatDimensions } = useLayoutStore()
const [isResizing, setIsResizing] = useState(false)
const [resizeDirection, setResizeDirection] = useState<'se' | 'sw' | 'ne' | 'nw' | null>(null)
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
const [startDimensions, setStartDimensions] = useState({ width: 0, height: 0 })
const containerRef = useRef<HTMLDivElement>(null)
const handleMouseDown = useCallback(
(direction: 'se' | 'sw' | 'ne' | 'nw') => (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setIsResizing(true)
setResizeDirection(direction)
setStartPos({ x: e.clientX, y: e.clientY })
setStartDimensions({ width: chatWidth, height: chatHeight })
document.body.style.cursor = direction.includes('e') ? 'e-resize' : 'w-resize'
document.body.style.userSelect = 'none'
const handleResize = useCallback(
(width: number, height: number) => {
setChatDimensions(width, height)
},
[chatWidth, chatHeight]
[setChatDimensions]
)
const handleMouseMove = useCallback(
(e: MouseEvent): void => {
if (!isResizing || !resizeDirection) return
const deltaX = e.clientX - startPos.x
const deltaY = e.clientY - startPos.y
let newWidth = startDimensions.width
let newHeight = startDimensions.height
// Calculate new dimensions based on resize direction
if (resizeDirection.includes('e')) {
newWidth = Math.max(minWidth, Math.min(maxWidth, startDimensions.width + deltaX))
} else if (resizeDirection.includes('w')) {
newWidth = Math.max(minWidth, Math.min(maxWidth, startDimensions.width - deltaX))
}
if (resizeDirection.includes('s')) {
newHeight = Math.max(minHeight, Math.min(maxHeight, startDimensions.height + deltaY))
} else if (resizeDirection.includes('n')) {
newHeight = Math.max(minHeight, Math.min(maxHeight, startDimensions.height - deltaY))
}
setChatDimensions(newWidth, newHeight)
},
[
isResizing,
resizeDirection,
startPos,
startDimensions,
minWidth,
minHeight,
maxWidth,
maxHeight,
setChatDimensions
]
)
const handleMouseUp = useCallback(() => {
setIsResizing(false)
setResizeDirection(null)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}, [])
useEffect(() => {
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}
return undefined
}, [isResizing, handleMouseMove, handleMouseUp])
return (
<div
ref={containerRef}
className={cn('relative group overflow-hidden', isResizing && 'pointer-events-none')}
style={{
width: chatWidth,
height: chatHeight
}}
<ResizableContainer
defaultWidth={chatWidth}
defaultHeight={chatHeight}
onResize={handleResize}
minWidth={minWidth}
minHeight={minHeight}
maxWidth={maxWidth}
maxHeight={maxHeight}
storageKey="resizable-chat"
className="max-h-[calc(100vh-60px)]"
>
{children}
{/* Resize overlay when active */}
{isResizing && (
<div className="absolute inset-0 bg-primary/5 border-2 border-primary/30 rounded-2xl pointer-events-none" />
)}
{/* Resize handles */}
<div
className={cn(
'absolute bottom-0 right-0 w-4 h-4 cursor-se-resize',
'bg-transparent hover:bg-primary/20 transition-colors',
'rounded-tl-md border-r border-b border-primary/20',
'group-hover:border-primary/40',
isResizing && 'bg-primary/30 border-primary/50'
)}
onMouseDown={handleMouseDown('se')}
/>
<div
className={cn(
'absolute bottom-0 left-0 w-4 h-4 cursor-sw-resize',
'bg-transparent hover:bg-primary/20 transition-colors',
'rounded-tr-md border-l border-b border-primary/20',
'group-hover:border-primary/40',
isResizing && 'bg-primary/30 border-primary/50'
)}
onMouseDown={handleMouseDown('sw')}
/>
<div
className={cn(
'absolute top-0 right-0 w-4 h-4 cursor-ne-resize',
'bg-transparent hover:bg-primary/20 transition-colors',
'rounded-bl-md border-r border-t border-primary/20',
'group-hover:border-primary/40',
isResizing && 'bg-primary/30 border-primary/50'
)}
onMouseDown={handleMouseDown('ne')}
/>
<div
className={cn(
'absolute top-0 left-0 w-4 h-4 cursor-nw-resize',
'bg-transparent hover:bg-primary/20 transition-colors',
'rounded-br-md border-l border-t border-primary/20',
'group-hover:border-primary/40',
isResizing && 'bg-primary/30 border-primary/50'
)}
onMouseDown={handleMouseDown('nw')}
/>
</div>
</ResizableContainer>
)
}

Some files were not shown because too many files have changed in this diff Show More