mirror of
https://github.com/reconurge/flowsint.git
synced 2026-07-11 10:02:31 -05:00
Compare commits
134 Commits
v1.2.4
...
fix/phase1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8ca7b39bf | ||
|
|
1d409d7929 | ||
|
|
ee06d6cf3e | ||
|
|
dd00bae9ce | ||
|
|
c9d594a8e7 | ||
|
|
67ba8cfcc6 | ||
|
|
8fbcf0c025 | ||
|
|
d431ef4fae | ||
|
|
5be480d4f5 | ||
|
|
431d3997f4 | ||
|
|
d4dbe8d0fe | ||
|
|
f79403d361 | ||
|
|
0d04423b59 | ||
|
|
62e34c6e9b | ||
|
|
2a4878c8fc | ||
|
|
6a14484ccd | ||
|
|
47b0c05115 | ||
|
|
8cd6b237bb | ||
|
|
ce1f50bb72 | ||
|
|
f5e6fb79ae | ||
|
|
bb502379bd | ||
|
|
1a5d164805 | ||
|
|
400b21a7d1 | ||
|
|
85a85ed541 | ||
|
|
00ec9d6f26 | ||
|
|
cfaece5af2 | ||
|
|
6f3af239b2 | ||
|
|
5569a2f9b7 | ||
|
|
c5525a8f38 | ||
|
|
a38a773657 | ||
|
|
6852ee3213 | ||
|
|
609c957677 | ||
|
|
976bd0ae99 | ||
|
|
4f5c24295c | ||
|
|
57e72cb337 | ||
|
|
1d376b8ac0 | ||
|
|
def29144eb | ||
|
|
3ef942a2bc | ||
|
|
1ee06f5c8a | ||
|
|
37cdd93257 | ||
|
|
5292a3c541 | ||
|
|
972763343e | ||
|
|
97ef7e4695 | ||
|
|
3e8e4c8423 | ||
|
|
cd67ae369b | ||
|
|
ff9d270ddd | ||
|
|
aacbd5ac85 | ||
|
|
6b35cccddb | ||
|
|
2ea40c4eae | ||
|
|
611bc1a563 | ||
|
|
a2cc352235 | ||
|
|
16df6b9928 | ||
|
|
ffd44968ad | ||
|
|
bb7c955eab | ||
|
|
fd8ddcfd77 | ||
|
|
ddc36eac4d | ||
|
|
5cdd8e70af | ||
|
|
15878019fa | ||
|
|
523a387661 | ||
|
|
171c606e07 | ||
|
|
22400f2e20 | ||
|
|
04e1c2f542 | ||
|
|
ae81c216b0 | ||
|
|
f49e05eafb | ||
|
|
86584ba84e | ||
|
|
ab13a8e2b0 | ||
|
|
7a2ea266d5 | ||
|
|
a574d4ebea | ||
|
|
b2cfc67117 | ||
|
|
5ee7bf24e0 | ||
|
|
da0280506e | ||
|
|
d52669740b | ||
|
|
606bcced4b | ||
|
|
6494cee2d0 | ||
|
|
c7275594cb | ||
|
|
4d97932604 | ||
|
|
0fa48d3fc1 | ||
|
|
517b342fc2 | ||
|
|
c05ad758d8 | ||
|
|
c481a14747 | ||
|
|
635f342aba | ||
|
|
d6324208a3 | ||
|
|
490878ab06 | ||
|
|
9ddb708744 | ||
|
|
9d1d5c1416 | ||
|
|
9d1991f45f | ||
|
|
eef214bdfa | ||
|
|
1db16ec13a | ||
|
|
2032aeee0b | ||
|
|
92e5a7097a | ||
|
|
b9fa1bbde9 | ||
|
|
dcce008b8d | ||
|
|
b6f2f8be68 | ||
|
|
ad6a31a7ab | ||
|
|
6293c90511 | ||
|
|
ae070c14c7 | ||
|
|
599fc8353d | ||
|
|
11df9e6fde | ||
|
|
af590675ec | ||
|
|
89f32e2182 | ||
|
|
3f252ea6ab | ||
|
|
a764704fbe | ||
|
|
19ee40c46d | ||
|
|
137d16e1fc | ||
|
|
38cee3759e | ||
|
|
07f32c983a | ||
|
|
fbb9666c53 | ||
|
|
a461f9997d | ||
|
|
e4cf6ac518 | ||
|
|
ac397f6714 | ||
|
|
53a03575cd | ||
|
|
5ff6ba2b1c | ||
|
|
327123a729 | ||
|
|
2bb1fdbfc1 | ||
|
|
1bf3dae185 | ||
|
|
8a01f353b8 | ||
|
|
9e07429e7b | ||
|
|
2bac67e2a8 | ||
|
|
ad85a5c419 | ||
|
|
177953b216 | ||
|
|
dd0c53f089 | ||
|
|
1ba3c0bd6c | ||
|
|
2fec5b82e9 | ||
|
|
1cc88d1033 | ||
|
|
2372678bda | ||
|
|
fd20468c79 | ||
|
|
427035fd41 | ||
|
|
152be112d0 | ||
|
|
01908e682b | ||
|
|
20a3d35909 | ||
|
|
16c9497268 | ||
|
|
12ef177127 | ||
|
|
14f79fecc5 | ||
|
|
73bb4a0e7a |
178
.claude/skills/flowsint-transform-builder/SKILL.md
Normal file
178
.claude/skills/flowsint-transform-builder/SKILL.md
Normal file
@@ -0,0 +1,178 @@
|
||||
---
|
||||
name: flowsint-transform-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 Transform 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 transform, 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 1–2 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.
|
||||
@@ -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
|
||||
|
||||
32
.github/workflows/images.yml
vendored
32
.github/workflows/images.yml
vendored
@@ -5,9 +5,9 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
REGISTRY_DOCKERHUB: docker.io
|
||||
REGISTRY_GHCR: ghcr.io
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -69,16 +69,17 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@master
|
||||
id: trivy
|
||||
uses: aquasecurity/trivy-action@v0.35.0
|
||||
with:
|
||||
image-ref: ghcr.io/${{ github.repository_owner }}/flowsint-app:${{ github.ref_name }}
|
||||
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@v3
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
if: always() && steps.trivy.outcome == 'success'
|
||||
with:
|
||||
sarif_file: "trivy-frontend.sarif"
|
||||
|
||||
@@ -93,7 +94,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -124,7 +125,7 @@ jobs:
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -142,16 +143,17 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@master
|
||||
id: trivy
|
||||
uses: aquasecurity/trivy-action@v0.35.0
|
||||
with:
|
||||
image-ref: ghcr.io/${{ github.repository_owner }}/flowsint-api:${{ github.ref_name }}
|
||||
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@v3
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
if: always() && steps.trivy.outcome == 'success'
|
||||
with:
|
||||
sarif_file: "trivy-backend.sarif"
|
||||
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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-transform-builder/
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
83
Makefile
83
Makefile
@@ -10,16 +10,14 @@ COMPOSE_DEPLOY := docker compose -f docker-compose.deploy.yml
|
||||
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-dev open-browser-prod \
|
||||
logs-dev logs-prod logs-deploy status
|
||||
logs-dev logs-prod logs-deploy status \
|
||||
regenerate-router
|
||||
|
||||
ENV_DIRS := . flowsint-api flowsint-core flowsint-app
|
||||
|
||||
# =============================================================================
|
||||
# Environment Setup
|
||||
# =============================================================================
|
||||
|
||||
check-env:
|
||||
@echo "Checking .env files..."
|
||||
@for dir in $(ENV_DIRS); do \
|
||||
@@ -31,10 +29,6 @@ check-env:
|
||||
fi; \
|
||||
done
|
||||
|
||||
# =============================================================================
|
||||
# Development
|
||||
# =============================================================================
|
||||
|
||||
dev:
|
||||
@echo "Starting DEV environment..."
|
||||
$(MAKE) check-env
|
||||
@@ -68,10 +62,6 @@ open-browser-dev:
|
||||
xdg-open http://localhost:5173 2>/dev/null || \
|
||||
echo "Frontend ready at http://localhost:5173"
|
||||
|
||||
# =============================================================================
|
||||
# Production
|
||||
# =============================================================================
|
||||
|
||||
prod:
|
||||
@echo "Starting PROD environment..."
|
||||
$(MAKE) check-env
|
||||
@@ -107,10 +97,6 @@ open-browser-prod:
|
||||
xdg-open http://localhost 2>/dev/null || \
|
||||
echo "Frontend ready at http://localhost"
|
||||
|
||||
# =============================================================================
|
||||
# Deploy (GHCR images)
|
||||
# =============================================================================
|
||||
|
||||
deploy:
|
||||
@echo "Starting DEPLOY environment (GHCR images)..."
|
||||
$(MAKE) check-env
|
||||
@@ -127,10 +113,6 @@ up-deploy:
|
||||
logs-deploy:
|
||||
$(COMPOSE_DEPLOY) logs -f
|
||||
|
||||
# =============================================================================
|
||||
# Migrations
|
||||
# =============================================================================
|
||||
|
||||
migrate-dev:
|
||||
@echo "Running DEV migrations..."
|
||||
@if ! $(COMPOSE_DEV) ps -q neo4j | grep -q .; then \
|
||||
@@ -148,42 +130,42 @@ migrate-prod:
|
||||
fi
|
||||
yarn migrate
|
||||
|
||||
# =============================================================================
|
||||
# Local Development (without Docker)
|
||||
# =============================================================================
|
||||
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 && \
|
||||
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
|
||||
|
||||
frontend:
|
||||
cd $(PROJECT_ROOT)/flowsint-app && yarn dev
|
||||
|
||||
celery:
|
||||
cd $(PROJECT_ROOT)/flowsint-api && \
|
||||
poetry run celery -A flowsint_core.core.celery \
|
||||
uv run celery -A flowsint_core.core.celery \
|
||||
worker --loglevel=info --pool=threads --concurrency=10
|
||||
|
||||
# =============================================================================
|
||||
# Testing & Installation
|
||||
# =============================================================================
|
||||
|
||||
test:
|
||||
cd flowsint-types && poetry run pytest
|
||||
cd flowsint-core && poetry run pytest
|
||||
cd flowsint-enrichers && poetry run pytest
|
||||
cd flowsint-types && uv run pytest
|
||||
cd flowsint-core && uv run pytest
|
||||
cd flowsint-enrichers && uv 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
|
||||
|
||||
# =============================================================================
|
||||
# Utilities
|
||||
# =============================================================================
|
||||
uv sync
|
||||
cd flowsint-api && uv run alembic upgrade head
|
||||
|
||||
status:
|
||||
@echo "=== DEV Containers ==="
|
||||
@@ -205,13 +187,11 @@ clean:
|
||||
-$(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
|
||||
|
||||
# =============================================================================
|
||||
# Help
|
||||
# =============================================================================
|
||||
regenerate-router:
|
||||
@echo "Regenerating flowsint-app/src/routeTree.gen.ts"
|
||||
cd $(PROJECT_ROOT)/flowsint-app && npx tsr generate
|
||||
|
||||
help:
|
||||
@echo "Flowsint Makefile"
|
||||
@@ -239,6 +219,13 @@ help:
|
||||
@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"
|
||||
|
||||
18
README.md
18
README.md
@@ -1,7 +1,11 @@
|
||||
# Flowsint
|
||||
|
||||
[](./LICENSE)
|
||||
[](./LICENSE)
|
||||
[](./ETHICS.md)
|
||||
[](https://www.buymeacoffee.com/dextmorgn)
|
||||
[](https://ko-fi.com/P5P01W3GPJ)
|
||||
[](https://discord.gg/aST9HMQr)
|
||||
|
||||
|
||||
Flowsint is an open-source OSINT graph exploration tool designed for ethical investigation, transparency, and verification.
|
||||
|
||||
@@ -206,19 +210,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 +254,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
|
||||
|
||||
[](https://www.buymeacoffee.com/dextmorgn)
|
||||
[](https://ko-fi.com/P5P01W3GPJ)
|
||||
|
||||
@@ -122,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
|
||||
|
||||
@@ -122,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
|
||||
|
||||
18
docs/developers/getting-started.mdx
Normal file
18
docs/developers/getting-started.mdx
Normal 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.
|
||||
244
docs/developers/graph-format.mdx
Normal file
244
docs/developers/graph-format.mdx
Normal 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
|
||||
715
docs/developers/managing-enrichers.mdx
Normal file
715
docs/developers/managing-enrichers.mdx
Normal 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 !
|
||||
512
docs/developers/managing-tools.mdx
Normal file
512
docs/developers/managing-tools.mdx
Normal 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.
|
||||
1253
docs/developers/managing-types.mdx
Normal file
1253
docs/developers/managing-types.mdx
Normal file
File diff suppressed because it is too large
Load Diff
53
docs/getting-started/enrichers.mdx
Normal file
53
docs/getting-started/enrichers.mdx
Normal 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.
|
||||
159
docs/getting-started/flows.mdx
Normal file
159
docs/getting-started/flows.mdx
Normal 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": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
42
docs/getting-started/quickstart.mdx
Normal file
42
docs/getting-started/quickstart.mdx
Normal 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.
|
||||
58
docs/getting-started/vault.mdx
Normal file
58
docs/getting-started/vault.mdx
Normal 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
59
docs/overview.mdx
Normal 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 !
|
||||
163
docs/sources/available-enrichers.mdx
Normal file
163
docs/sources/available-enrichers.mdx
Normal 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
73
docs/syllabus.mdx
Normal 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.
|
||||
@@ -2,17 +2,12 @@ FROM python:3.12-slim AS builder
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
POETRY_VIRTUALENVS_IN_PROJECT=true \
|
||||
POETRY_NO_INTERACTION=1 \
|
||||
POETRY_HOME="/opt/poetry"
|
||||
|
||||
ENV PATH="$POETRY_HOME/bin:$PATH"
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# build deps
|
||||
# build deps + uv
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
curl \
|
||||
@@ -20,23 +15,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
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
|
||||
|
||||
# poetry
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
COPY flowsint-core/pyproject.toml flowsint-core/poetry.lock* ./flowsint-core/
|
||||
COPY flowsint-types/pyproject.toml flowsint-types/poetry.lock* ./flowsint-types/
|
||||
COPY flowsint-enrichers/pyproject.toml flowsint-enrichers/poetry.lock* ./flowsint-enrichers/
|
||||
COPY flowsint-api/pyproject.toml flowsint-api/poetry.lock* ./flowsint-api/
|
||||
# Copy workspace config
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
COPY flowsint-core ./flowsint-core
|
||||
# Copy all workspace members
|
||||
COPY flowsint-types ./flowsint-types
|
||||
COPY flowsint-core ./flowsint-core
|
||||
COPY flowsint-enrichers ./flowsint-enrichers
|
||||
COPY flowsint-api ./flowsint-api
|
||||
|
||||
WORKDIR /app/flowsint-api
|
||||
RUN poetry install --no-root
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
# DEV
|
||||
FROM python:3.12-slim AS dev
|
||||
@@ -44,7 +37,7 @@ FROM python:3.12-slim AS dev
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
APP_ENV=development \
|
||||
PATH="/app/flowsint-api/.venv/bin:$PATH"
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -56,7 +49,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
WORKDIR /app
|
||||
|
||||
# Copy virtual environment from builder
|
||||
COPY --from=builder /app/flowsint-api/.venv ./flowsint-api/.venv
|
||||
COPY --from=builder /app/.venv ./.venv
|
||||
|
||||
# Copy application code
|
||||
COPY flowsint-core ./flowsint-core
|
||||
@@ -86,7 +79,7 @@ LABEL org.opencontainers.image.licenses="Apache-2.0"
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
APP_ENV=production \
|
||||
PATH="/app/flowsint-api/.venv/bin:$PATH"
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Install runtime dependencies only
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -102,8 +95,8 @@ RUN groupadd -g 1001 flowsint && \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy virtual environment from builder (production deps only would require separate install)
|
||||
COPY --from=builder --chown=flowsint:flowsint /app/flowsint-api/.venv ./flowsint-api/.venv
|
||||
# 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
|
||||
@@ -127,4 +120,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
# Production command (no reload)
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5001"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5001"]
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -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 ###
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -2,19 +2,11 @@ from fastapi import Depends, HTTPException, status, Request
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
206
flowsint-api/app/api/routes/enricher_templates.py
Normal file
206
flowsint-api/app/api/routes/enricher_templates.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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, get_current_user_sse
|
||||
|
||||
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,26 +42,22 @@ 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_sse),
|
||||
):
|
||||
"""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'
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
@@ -111,15 +65,12 @@ async def stream_events(
|
||||
|
||||
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_sse),
|
||||
):
|
||||
"""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)
|
||||
|
||||
@@ -231,29 +167,21 @@ async def stream_status(
|
||||
request: Request,
|
||||
scan_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Profile = Depends(get_current_user_sse)
|
||||
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
|
||||
)
|
||||
"""Stream status updates for a specific scan in real-time."""
|
||||
service = create_log_service(db)
|
||||
try:
|
||||
service.get_scan_with_permission(scan_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")
|
||||
|
||||
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:
|
||||
@@ -265,9 +193,7 @@ async def stream_status(
|
||||
yield f"data: {data}\n\n"
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print(
|
||||
f"[EventEmitter] Client disconnected from status stream for scan_id: {scan_id}"
|
||||
)
|
||||
print(f"[EventEmitter] Client disconnected from status stream for scan_id: {scan_id}")
|
||||
finally:
|
||||
await event_emitter.unsubscribe(f"scan_{scan_id}_status")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 l’investigation
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
190
flowsint-api/app/api/schemas/enricher_template.py
Normal file
190
flowsint-api/app/api/schemas/enricher_template.py
Normal 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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -1,48 +1,57 @@
|
||||
[tool.poetry]
|
||||
[project]
|
||||
name = "flowsint-api"
|
||||
version = "1.0.0"
|
||||
license = "Apache-2.0"
|
||||
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,<0.116.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.20,<0.0.21",
|
||||
"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>=25.0,<26.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
|
||||
|
||||
BIN
flowsint-app/.DS_Store
vendored
BIN
flowsint-app/.DS_Store
vendored
Binary file not shown.
@@ -1,4 +1,12 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true
|
||||
}
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.79",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@hookform/resolvers": "^5.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 +78,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 +90,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 +113,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,6 +132,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
27
flowsint-app/src/api/chat-transport.ts
Normal file
27
flowsint-app/src/api/chat-transport.ts
Normal 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 } : {}) },
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// })
|
||||
// }
|
||||
|
||||
@@ -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]
|
||||
},
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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',
|
||||
|
||||
108
flowsint-app/src/api/template-service.ts
Normal file
108
flowsint-app/src/api/template-service.ts
Normal 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
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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,7 +352,7 @@ export const AnalysisEditor = ({
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{showActions && (
|
||||
{showActions && canEdit && (
|
||||
<div className="flex items-center gap-1">
|
||||
<SaveStatusBadge status={saveStatus} />
|
||||
<DropdownMenu>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { DragHandle } from './drag-handle'
|
||||
@@ -0,0 +1 @@
|
||||
export { PasteMarkdown } from './markdown-paste'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SlashCommand } from './slash-command'
|
||||
export type { SlashCommandItem } from './slash-command'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
31
flowsint-app/src/components/chat/chat-inactive.tsx
Normal file
31
flowsint-app/src/components/chat/chat-inactive.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
186
flowsint-app/src/components/custom-types/field-row.tsx
Normal file
186
flowsint-app/src/components/custom-types/field-row.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { GripVertical, Trash2, ChevronRight, Asterisk } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useState } from 'react'
|
||||
import { Reorder, useDragControls } from 'framer-motion'
|
||||
|
||||
export interface SchemaField {
|
||||
id: string
|
||||
key: string
|
||||
title: string
|
||||
type: string
|
||||
format?: string
|
||||
description?: string
|
||||
required: boolean
|
||||
}
|
||||
|
||||
const FIELD_TYPES = [
|
||||
{ value: 'string', label: 'Text' },
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'integer', label: 'Integer' },
|
||||
{ value: 'boolean', label: 'Boolean' },
|
||||
{ value: 'array', label: 'List' },
|
||||
{ value: 'object', label: 'Object' }
|
||||
] as const
|
||||
|
||||
const FIELD_FORMATS = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'uri', label: 'URL' },
|
||||
{ value: 'date', label: 'Date' },
|
||||
{ value: 'date-time', label: 'Date & Time' },
|
||||
{ value: 'ipv4', label: 'IPv4' },
|
||||
{ value: 'ipv6', label: 'IPv6' }
|
||||
] as const
|
||||
|
||||
interface FieldRowProps {
|
||||
field: SchemaField
|
||||
onUpdate: (updates: Partial<SchemaField>) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function FieldRow({ field, onUpdate, onDelete }: FieldRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const dragControls = useDragControls()
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={field}
|
||||
dragListener={false}
|
||||
dragControls={dragControls}
|
||||
className="list-none"
|
||||
// whileDrag={{ scsale: 1.01, boxShadow: '0 4px 16px rgba(0,0,0,0.08)', zIndex: 50 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
||||
<div
|
||||
className={cn(
|
||||
'group rounded-lg border bg-background relative',
|
||||
field.required && 'border-primary/20 bg-primary/3'
|
||||
)}
|
||||
>
|
||||
{field.required && (
|
||||
<div className="absolute -top-2 -left-2 rounded-full border border-primary/50 bg-card">
|
||||
<Asterisk className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
className="touch-none p-0.5 rounded hover:bg-muted transition-colors cursor-grab active:cursor-grabbing"
|
||||
onPointerDown={(e) => dragControls.start(e)}
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground/70 transition-colors" />
|
||||
</button>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="p-1 rounded hover:bg-muted transition-colors">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-muted-foreground transition-transform',
|
||||
expanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid grid-cols-[1fr_1fr_120px_120px] gap-2 items-center">
|
||||
<Input
|
||||
value={field.key}
|
||||
onChange={(e) =>
|
||||
onUpdate({ key: e.target.value.toLowerCase().replace(/\s+/g, '_') })
|
||||
}
|
||||
placeholder="field_key"
|
||||
className="h-8 text-sm border-transparent bg-transparent hover:border-border focus:border-border focus:bg-background transition-colors"
|
||||
/>
|
||||
<Input
|
||||
value={field.title}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
placeholder="Display name"
|
||||
className="h-8 text-sm border-transparent bg-transparent hover:border-border focus:border-border focus:bg-background transition-colors"
|
||||
/>
|
||||
<Select value={field.type} onValueChange={(v: string) => onUpdate({ type: v })}>
|
||||
<SelectTrigger className="h-8 text-sm border-transparent bg-transparent hover:border-border transition-colors">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={field.format || 'none'}
|
||||
onValueChange={(v: string) => onUpdate({ format: v === 'none' ? undefined : v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm border-transparent bg-transparent hover:border-border transition-colors">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_FORMATS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
<CollapsibleContent>
|
||||
<div className="px-10 pb-3 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Description</label>
|
||||
<Textarea
|
||||
value={field.description || ''}
|
||||
onChange={(e) => onUpdate({ description: e.target.value })}
|
||||
placeholder="Describe what this field is for..."
|
||||
rows={2}
|
||||
className="text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Required field
|
||||
</label>
|
||||
<Switch
|
||||
checked={field.required}
|
||||
onCheckedChange={(checked: boolean) => onUpdate({ required: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</Reorder.Item>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react'
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import IconPicker from '@/components/shared/icon-picker/popup'
|
||||
|
||||
interface IconPickerTriggerProps {
|
||||
icon: string
|
||||
color: string
|
||||
onIconChange: (icon: string) => void
|
||||
}
|
||||
|
||||
export function IconPickerTrigger({ icon, color, onIconChange }: IconPickerTriggerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const Icon = (LucideIcons as any)[icon] || LucideIcons.FileQuestion
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
'group relative flex items-center justify-center rounded-full transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
width: 52,
|
||||
height: 52
|
||||
}}
|
||||
>
|
||||
<Icon className="text-white" size={26} />
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 rounded-xl bg-black/0 transition-colors',
|
||||
'group-hover:bg-black/10'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<IconPicker
|
||||
iconType={null}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
onIconChange={(_type, iconName) => {
|
||||
onIconChange(iconName as string)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
111
flowsint-app/src/components/custom-types/type-preview.tsx
Normal file
111
flowsint-app/src/components/custom-types/type-preview.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { SchemaField } from './field-row'
|
||||
|
||||
interface TypePreviewProps {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
color: string
|
||||
fields: SchemaField[]
|
||||
status: 'draft' | 'published'
|
||||
}
|
||||
|
||||
export function TypePreview({ name, description, icon, color, fields, status }: TypePreviewProps) {
|
||||
const Icon = (LucideIcons as any)[icon] || LucideIcons.FileQuestion
|
||||
const validFields = fields.filter((f) => f.key.trim())
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Entity card preview */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">Entity card</p>
|
||||
<div className="border border-border/60 rounded-xl p-5 bg-card max-w-sm">
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div
|
||||
className="rounded-full flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: color, width: 44, height: 44 }}
|
||||
>
|
||||
<Icon className="text-white" size={22} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold text-sm truncate">{name || 'Untitled type'}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{description || 'No description'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validFields.length > 0 && (
|
||||
<div className="mt-4 pt-3 border-t border-border/40 space-y-2">
|
||||
{validFields.slice(0, 4).map((field) => (
|
||||
<div key={field.id} className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{field.title || field.key}</span>
|
||||
<span className="text-xs text-muted-foreground/60 font-mono">
|
||||
{field.type}
|
||||
{field.format && field.format !== 'none' ? `:${field.format}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{validFields.length > 4 && (
|
||||
<p className="text-[10px] text-muted-foreground/50">
|
||||
+{validFields.length - 4} more fields
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graph node preview */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">Graph node</p>
|
||||
<div className="flex items-center gap-3 p-4 border border-border/60 rounded-xl bg-card max-w-[200px]">
|
||||
<div
|
||||
className="rounded-full flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: color, width: 36, height: 36 }}
|
||||
>
|
||||
<Icon className="text-white" size={18} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium truncate">{name || 'Untitled'}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{validFields.length} field{validFields.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schema preview */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">JSON Schema</p>
|
||||
<pre className="p-4 bg-muted/50 rounded-lg text-xs font-mono overflow-x-auto text-muted-foreground leading-relaxed">
|
||||
{JSON.stringify(buildSchema(name, fields), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildSchema(name: string, fields: SchemaField[]) {
|
||||
const properties: Record<string, any> = {}
|
||||
const required: string[] = []
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (!field.key.trim()) return
|
||||
const prop: any = { type: field.type, title: field.title || field.key }
|
||||
if (field.description) prop.description = field.description
|
||||
if (field.format && field.format !== 'none') prop.format = field.format
|
||||
properties[field.key] = prop
|
||||
if (field.required) required.push(field.key)
|
||||
})
|
||||
|
||||
return {
|
||||
title: name || 'MyCustomType',
|
||||
type: 'object',
|
||||
properties,
|
||||
required
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import NewAnalysis from "../analyses/new-analysis"
|
||||
|
||||
interface EmptyStateProps {
|
||||
onAction?: () => void
|
||||
canCreate?: boolean
|
||||
}
|
||||
|
||||
export function EmptyInvestigations({ onAction }: EmptyStateProps) {
|
||||
@@ -70,7 +71,7 @@ export function EmptySketches({ onAction }: EmptyStateProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyAnalyses({ onAction }: EmptyStateProps) {
|
||||
export function EmptyAnalyses({ onAction, canCreate = true }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4">
|
||||
{/* Minimal document illustration */}
|
||||
@@ -93,12 +94,14 @@ export function EmptyAnalyses({ onAction }: EmptyStateProps) {
|
||||
<p className="text-xs text-muted-foreground text-center max-w-[200px] mb-4">
|
||||
Document findings, patterns, and conclusions from your investigation
|
||||
</p>
|
||||
<NewAnalysis>
|
||||
<Button onClick={onAction} variant="ghost" size="sm" className="h-7 text-xs gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Write analysis
|
||||
</Button>
|
||||
</NewAnalysis>
|
||||
{canCreate && (
|
||||
<NewAnalysis>
|
||||
<Button onClick={onAction} variant="ghost" size="sm" className="h-7 text-xs gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Write analysis
|
||||
</Button>
|
||||
</NewAnalysis>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,24 +8,27 @@ import { Link } from "@tanstack/react-router"
|
||||
|
||||
interface AnalysesSectionProps {
|
||||
analyses: Analysis[]
|
||||
canCreate?: boolean
|
||||
}
|
||||
|
||||
export function AnalysesSection({ analyses }: AnalysesSectionProps) {
|
||||
export function AnalysesSection({ analyses, canCreate = true }: AnalysesSectionProps) {
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-medium text-foreground">Analyses</h2>
|
||||
<NewAnalysis>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground hover:text-foreground gap-1">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
New
|
||||
</Button>
|
||||
</NewAnalysis>
|
||||
{canCreate && (
|
||||
<NewAnalysis>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground hover:text-foreground gap-1">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
New
|
||||
</Button>
|
||||
</NewAnalysis>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{analyses.length === 0 ?
|
||||
<div className="border border-dashed rounded-md">
|
||||
<EmptyAnalyses />
|
||||
<EmptyAnalyses canCreate={canCreate} />
|
||||
</div> :
|
||||
<div className="space-y-1">
|
||||
{analyses.map((analysis) => (
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import type React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type React from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { MoreHorizontal, Star, Share, Trash2, Clock } from "lucide-react"
|
||||
import { Investigation } from "@/types"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { investigationService } from "@/api/investigation-service"
|
||||
import { useConfirm } from "@/components/use-confirm-dialog"
|
||||
import { queryKeys } from "@/api/query-keys"
|
||||
import { toast } from "sonner"
|
||||
import { Link, useRouter } from "@tanstack/react-router"
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { MoreHorizontal, Star, Share, Trash2, Clock, Crown, Shield, Pencil, Eye } from 'lucide-react'
|
||||
import { Investigation, Collaborator } from '@/types'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AvatarGroup } from '@/components/ui/avatar'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { investigationService } from '@/api/investigation-service'
|
||||
import { useConfirm } from '@/components/use-confirm-dialog'
|
||||
import { queryKeys } from '@/api/query-keys'
|
||||
import { toast } from 'sonner'
|
||||
import { Link, useRouter } from '@tanstack/react-router'
|
||||
import { usePermissions } from '@/hooks/use-can'
|
||||
import type { InvestigationRole } from '@/types'
|
||||
import { ShareDialog } from './share-dialog'
|
||||
|
||||
const ROLE_CONFIG: Record<InvestigationRole, { label: string; icon: typeof Eye; className: string }> = {
|
||||
owner: { label: 'Owner', icon: Crown, className: 'bg-amber-500/15 text-amber-700 border-amber-500/30 dark:text-amber-400' },
|
||||
admin: { label: 'Admin', icon: Shield, className: 'bg-purple-500/15 text-purple-700 border-purple-500/30 dark:text-purple-400' },
|
||||
editor: { label: 'Editor', icon: Pencil, className: 'bg-blue-500/15 text-blue-700 border-blue-500/30 dark:text-blue-400' },
|
||||
viewer: { label: 'Viewer', icon: Eye, className: 'bg-zinc-500/15 text-zinc-700 border-zinc-500/30 dark:text-zinc-400' },
|
||||
}
|
||||
|
||||
type CaseOverviewPageProps = {
|
||||
investigation: Investigation
|
||||
@@ -28,10 +39,16 @@ export function CaseHeader({ investigation }: CaseOverviewPageProps) {
|
||||
addSuffix: true
|
||||
})
|
||||
const router = useRouter()
|
||||
const { canDelete, canManage, role } = usePermissions()
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: collaborators = [] } = useQuery<Collaborator[]>({
|
||||
queryKey: queryKeys.investigations.collaborators(investigation.id),
|
||||
queryFn: () => investigationService.getCollaborators(investigation.id)
|
||||
})
|
||||
|
||||
const handleDeleteInvestigation = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete Investigation',
|
||||
@@ -41,13 +58,9 @@ export function CaseHeader({ investigation }: CaseOverviewPageProps) {
|
||||
if (confirmed) {
|
||||
const deletePromise = () =>
|
||||
investigationService.delete(investigation.id).then(() => {
|
||||
''
|
||||
// Invalidate the investigations list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.investigations.list
|
||||
})
|
||||
|
||||
// Also remove related data from cache
|
||||
queryClient.removeQueries({
|
||||
queryKey: queryKeys.investigations.detail(investigation.id)
|
||||
})
|
||||
@@ -60,7 +73,7 @@ export function CaseHeader({ investigation }: CaseOverviewPageProps) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: queryKeys.investigations.flows(investigation.id)
|
||||
})
|
||||
router.navigate({ to: "/dashboard" })
|
||||
router.navigate({ to: '/dashboard' })
|
||||
})
|
||||
|
||||
toast.promise(deletePromise, {
|
||||
@@ -73,9 +86,11 @@ export function CaseHeader({ investigation }: CaseOverviewPageProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6 border-b border-border">
|
||||
{/* Breadcrumb - subtle */}
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Link to={"/dashboard"} className="hover:text-foreground cursor-pointer transition-colors">Cases</Link>
|
||||
<Link to={'/dashboard'} className="hover:text-foreground cursor-pointer transition-colors">
|
||||
Cases
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{investigation.name}</span>
|
||||
</div>
|
||||
@@ -84,63 +99,76 @@ export function CaseHeader({ investigation }: CaseOverviewPageProps) {
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">{investigation.name}</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||
{investigation.name}
|
||||
</h1>
|
||||
<FavoriteButton />
|
||||
</div>
|
||||
|
||||
{/* Inline properties - Notion style */}
|
||||
{/* Role badge */}
|
||||
{role && (() => {
|
||||
const config = ROLE_CONFIG[role]
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<Badge className={cn('gap-1 text-xs font-medium shadow-none', config.className)}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Inline properties */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<PropertyPill label="Status" value={investigation.status} valueClass="text-success" />
|
||||
<PropertyPill label="Priority" value="Medium" valueClass="text-primary" />
|
||||
<PropertyPill label="Updated" value={lastUpdated} icon={<Clock className="w-3 h-3" />} />
|
||||
<PropertyPill
|
||||
label="Updated"
|
||||
value={lastUpdated}
|
||||
icon={<Clock className="w-3 h-3" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions - minimal */}
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* <Button variant="ghost" size="sm" className="text-muted-foreground h-8 px-2">
|
||||
<Share className="w-4 h-4" />
|
||||
</Button> */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{canManage && (
|
||||
<ShareDialog investigationId={investigation.id}>
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground h-8 px-2">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
<Share className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{/* <DropdownMenuItem>Export as PDF</DropdownMenuItem>
|
||||
<DropdownMenuItem>Duplicate</DropdownMenuItem> */}
|
||||
{/* <DropdownMenuSeparator /> */}
|
||||
<DropdownMenuItem onClick={handleDeleteInvestigation} className="text-destructive">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ShareDialog>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground h-8 px-2">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={handleDeleteInvestigation} className="text-destructive">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags row */}
|
||||
{/* <div className="flex items-center gap-2 flex-wrap">
|
||||
<Tag>APT</Tag>
|
||||
<Tag>Nation-State</Tag>
|
||||
<Tag>Supply Chain</Tag>
|
||||
<Tag>SolarWinds</Tag>
|
||||
<button className="text-xs text-muted-foreground hover:text-foreground transition-colors">+ Add tag</button>
|
||||
</div> */}
|
||||
|
||||
{/* Team */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex -space-x-1.5">
|
||||
<Avatar className="h-5 w-5">
|
||||
<AvatarImage src="https://cherry.img.pmdstatic.net/fit/https.3A.2F.2Fimg.2Egamesider.2Ecom.2Fs3.2Ffrgsg.2F1280.2Fthe-last-of-us.2Fdefault_2023-11-27_291826c8-5b2b-4928-a167-259dd0b18a7c.2Ejpeg/1200x675/quality/80/the-last-of-us-saison-2-mauvaise-nouvelle-pedro-pascal.jpg" />
|
||||
<AvatarFallback>U</AvatarFallback>
|
||||
</Avatar>
|
||||
{/* <Avatar initials="SK" />
|
||||
<Avatar initials="MR" /> */}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">1 investigator</span>
|
||||
{/* <button disabled className="text-xs text-muted-foreground hover:text-foreground transition-colors ml-1">+ Invite</button> */}
|
||||
<AvatarGroup users={collaborators.map((c) => c.user)} size="md" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{collaborators.length} investigator{collaborators.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{canManage && (
|
||||
<ShareDialog investigationId={investigation.id}>
|
||||
<button className="text-xs text-muted-foreground hover:text-foreground transition-colors ml-1">
|
||||
+ Invite
|
||||
</button>
|
||||
</ShareDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -149,8 +177,8 @@ export function CaseHeader({ investigation }: CaseOverviewPageProps) {
|
||||
function PropertyPill({
|
||||
label,
|
||||
value,
|
||||
valueClass = "text-foreground",
|
||||
icon,
|
||||
valueClass = 'text-foreground',
|
||||
icon
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
@@ -172,12 +200,14 @@ function PropertyPill({
|
||||
// return <span className="px-2 py-0.5 rounded bg-secondary text-xs text-secondary-foreground">{children}</span>
|
||||
// }
|
||||
|
||||
|
||||
const FavoriteButton = () => {
|
||||
const [fav, setFav] = useState<boolean>(false)
|
||||
return (
|
||||
<button onClick={() => setFav(!fav)} className={cn("text-muted-foreground hover:text-warning transition-colors")}>
|
||||
<Star className={cn("w-4 h-4", fav && "text-warning fill-warning")} />
|
||||
<button
|
||||
onClick={() => setFav(!fav)}
|
||||
className={cn('text-muted-foreground hover:text-warning transition-colors')}
|
||||
>
|
||||
<Star className={cn('w-4 h-4', fav && 'text-warning fill-warning')} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,24 @@
|
||||
|
||||
import { CaseHeader } from "./case-header"
|
||||
// import { MetricsGrid } from "./metrics-grid"
|
||||
// import { ActivityTimeline } from "./activity-timeline"
|
||||
import { SketchesSection } from "./sketches-section"
|
||||
import { AnalysesSection } from "./analyses-section"
|
||||
// import { EvidenceSection } from "./evidence-section"
|
||||
// import { TasksSection } from "./tasks-section"
|
||||
import { Investigation } from "@/types"
|
||||
import { usePermissions } from "@/hooks/use-can"
|
||||
|
||||
type CaseOverviewPageProps = {
|
||||
investigation: Investigation
|
||||
}
|
||||
export function CaseOverviewPage({ investigation }: CaseOverviewPageProps) {
|
||||
const { canCreate } = usePermissions()
|
||||
|
||||
return (
|
||||
<main className="flex-1 h-full overflow-auto">
|
||||
<div className="max-w-7xl mx-auto px-8 py-8">
|
||||
<CaseHeader investigation={investigation} />
|
||||
{/* <MetricsGrid investigation={investigation} /> */}
|
||||
|
||||
<div className="space-y-8">
|
||||
<SketchesSection sketches={investigation.sketches} />
|
||||
<AnalysesSection analyses={investigation.analyses} />
|
||||
|
||||
{/* Two column for smaller sections */}
|
||||
{/* <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<EvidenceSection />
|
||||
<div className="space-y-8">
|
||||
<TasksSection />
|
||||
<ActivityTimeline />
|
||||
</div>
|
||||
</div> */}
|
||||
<SketchesSection sketches={investigation.sketches} canCreate={canCreate} />
|
||||
<AnalysesSection analyses={investigation.analyses} canCreate={canCreate} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -126,7 +126,8 @@ export function InvestigationsList({
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div style={{ containerType: 'inline-size' }}>
|
||||
<div className="grid grid-cols-1 cq-sm:grid-cols-2 cq-md:grid-cols-3 gap-3">
|
||||
{filteredInvestigations.length === 0 && <div>No investigation found.</div>}
|
||||
{filteredInvestigations.map((inv) => (
|
||||
<Link
|
||||
@@ -168,6 +169,7 @@ export function InvestigationsList({
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Network, FileText, Users, Shield, Paperclip, CheckCircle } from "lucide-react"
|
||||
import { Network, FileText, Users, Shield } from "lucide-react"
|
||||
import { Investigation } from "@/types"
|
||||
import { useMemo } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type CaseOverviewPageProps = {
|
||||
investigation: Investigation
|
||||
@@ -19,7 +18,8 @@ export function MetricsGrid({ investigation }: CaseOverviewPageProps) {
|
||||
], [sketchCount, analysisCount])
|
||||
|
||||
return (
|
||||
<div className={cn("grid border grid-cols-3 gap-px bg-border rounded-lg overflow-hidden my-6", `md:grid-cols-${metrics.length}`)} >
|
||||
<div className="my-6" style={{ containerType: 'inline-size' }}>
|
||||
<div className="grid border grid-cols-2 cq-sm:grid-cols-4 gap-px bg-border rounded-lg overflow-hidden">
|
||||
{
|
||||
metrics.map((metric) => (
|
||||
<div
|
||||
@@ -32,6 +32,7 @@ export function MetricsGrid({ investigation }: CaseOverviewPageProps) {
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</ div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { investigationService } from '@/api/investigation-service'
|
||||
import { authService } from '@/api/auth-service'
|
||||
import { queryKeys } from '@/api/query-keys'
|
||||
import { toast } from 'sonner'
|
||||
import type { Collaborator, InvestigationRole, Profile } from '@/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { UserAvatar } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { X, UserPlus, Crown, Shield, Pencil, Eye, Users, Check } from 'lucide-react'
|
||||
import { getDisplayName } from '@/lib/user-display'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ROLE_OPTIONS: {
|
||||
value: InvestigationRole
|
||||
label: string
|
||||
icon: typeof Eye
|
||||
className: string
|
||||
}[] = [
|
||||
{
|
||||
value: 'admin',
|
||||
label: 'Admin',
|
||||
icon: Shield,
|
||||
className: 'bg-purple-500/15 text-purple-700 border-purple-500/30 dark:text-purple-400'
|
||||
},
|
||||
{
|
||||
value: 'editor',
|
||||
label: 'Editor',
|
||||
icon: Pencil,
|
||||
className: 'bg-blue-500/15 text-blue-700 border-blue-500/30 dark:text-blue-400'
|
||||
},
|
||||
{
|
||||
value: 'viewer',
|
||||
label: 'Viewer',
|
||||
icon: Eye,
|
||||
className: 'bg-zinc-500/15 text-zinc-700 border-zinc-500/30 dark:text-zinc-400'
|
||||
}
|
||||
]
|
||||
|
||||
const ROLE_BADGE: Record<
|
||||
InvestigationRole,
|
||||
{ label: string; icon: typeof Eye; className: string }
|
||||
> = {
|
||||
owner: {
|
||||
label: 'Owner',
|
||||
icon: Crown,
|
||||
className: 'bg-amber-500/15 text-amber-700 border-amber-500/30 dark:text-amber-400'
|
||||
},
|
||||
admin: {
|
||||
label: 'Admin',
|
||||
icon: Shield,
|
||||
className: 'bg-purple-500/15 text-purple-700 border-purple-500/30 dark:text-purple-400'
|
||||
},
|
||||
editor: {
|
||||
label: 'Editor',
|
||||
icon: Pencil,
|
||||
className: 'bg-blue-500/15 text-blue-700 border-blue-500/30 dark:text-blue-400'
|
||||
},
|
||||
viewer: {
|
||||
label: 'Viewer',
|
||||
icon: Eye,
|
||||
className: 'bg-zinc-500/15 text-zinc-700 border-zinc-500/30 dark:text-zinc-400'
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleFromCollaborator(collab: Collaborator): InvestigationRole {
|
||||
return (collab.roles[0] ?? 'viewer') as InvestigationRole
|
||||
}
|
||||
|
||||
const RoleBadge = ({
|
||||
role,
|
||||
className,
|
||||
...props
|
||||
}: { role: InvestigationRole; className?: string } & React.ComponentProps<'div'>) => {
|
||||
const config = ROLE_BADGE[role]
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'gap-1 text-[11px] font-medium shadow-none px-1.5 py-0 cursor-default',
|
||||
config.className,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
interface ShareDialogProps {
|
||||
investigationId: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function ShareDialog({ investigationId, children }: ShareDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedEmail, setSelectedEmail] = useState('')
|
||||
const [role, setRole] = useState<string>('editor')
|
||||
const [suggestions, setSuggestions] = useState<Profile[]>([])
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: collaborators = [], isLoading } = useQuery<Collaborator[]>({
|
||||
queryKey: queryKeys.investigations.collaborators(investigationId),
|
||||
queryFn: () => investigationService.getCollaborators(investigationId),
|
||||
enabled: open
|
||||
})
|
||||
|
||||
const handleQueryChange = useCallback((value: string) => {
|
||||
setQuery(value)
|
||||
setSelectedEmail(value)
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
if (value.length < 2) {
|
||||
setSuggestions([])
|
||||
setShowSuggestions(false)
|
||||
return
|
||||
}
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const results = await authService.searchUsers(value)
|
||||
setSuggestions(results)
|
||||
setShowSuggestions(results.length > 0)
|
||||
} catch {
|
||||
setSuggestions([])
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
}, 300)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSelectUser = (user: Profile) => {
|
||||
setSelectedEmail(user.email ?? '')
|
||||
setQuery(getDisplayName(user))
|
||||
setShowSuggestions(false)
|
||||
setSuggestions([])
|
||||
}
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (body: { email: string; role: string }) =>
|
||||
investigationService.addCollaborator(investigationId, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.investigations.collaborators(investigationId)
|
||||
})
|
||||
setQuery('')
|
||||
setSelectedEmail('')
|
||||
toast.success('Collaborator added')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const message = error?.message || 'Failed to add collaborator'
|
||||
toast.error(message)
|
||||
}
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ userId, role }: { userId: string; role: string }) =>
|
||||
investigationService.updateCollaboratorRole(investigationId, userId, { role }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.investigations.collaborators(investigationId)
|
||||
})
|
||||
toast.success('Role updated')
|
||||
},
|
||||
onError: () => toast.error('Failed to update role')
|
||||
})
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
investigationService.removeCollaborator(investigationId, userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.investigations.collaborators(investigationId)
|
||||
})
|
||||
toast.success('Collaborator removed')
|
||||
},
|
||||
onError: () => toast.error('Failed to remove collaborator')
|
||||
})
|
||||
|
||||
const handleInvite = () => {
|
||||
if (!selectedEmail.trim()) return
|
||||
addMutation.mutate({ email: selectedEmail.trim(), role })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl gap-0 p-0 overflow-hidden">
|
||||
<DialogHeader className="px-5 pt-5 pb-4">
|
||||
<DialogTitle className="text-base">Share investigation</DialogTitle>
|
||||
<DialogDescription className="text-sm text-muted-foreground">
|
||||
Invite collaborators and manage access.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Invite form */}
|
||||
<div className="px-5 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
placeholder="Search by name or email..."
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleInvite()
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
if (e.key === 'Escape') setShowSuggestions(false)
|
||||
}}
|
||||
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
||||
className="h-9"
|
||||
/>
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className="absolute z-50 top-full mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden">
|
||||
{suggestions.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-accent text-left transition-colors"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleSelectUser(user)
|
||||
}}
|
||||
>
|
||||
<UserAvatar user={user} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{getDisplayName(user)}</p>
|
||||
{user.email && (
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Select value={role} onValueChange={setRole}>
|
||||
<SelectTrigger className="w-[100px] h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLE_OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon
|
||||
return (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon className="w-3.5 h-3.5 opacity-60" />
|
||||
{opt.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 px-3"
|
||||
onClick={handleInvite}
|
||||
disabled={!selectedEmail.trim() || addMutation.isPending}
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-1.5" />
|
||||
Invite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Collaborators list */}
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-5 space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-28" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-14 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : collaborators.length === 0 ? (
|
||||
<div className="py-10 flex flex-col items-center gap-2 text-center">
|
||||
<Users className="w-8 h-8 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">No collaborators yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{collaborators.map((collab) => {
|
||||
const collabRole = getRoleFromCollaborator(collab)
|
||||
const isOwner = collabRole === 'owner'
|
||||
return (
|
||||
<div
|
||||
key={collab.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<UserAvatar user={collab.user} size="md" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{getDisplayName(collab.user)}</p>
|
||||
{collab.user?.email && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{collab.user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isOwner ? (
|
||||
<RoleBadge role="owner" />
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button>
|
||||
<RoleBadge role={collabRole} className="cursor-pointer hover:opacity-80 transition-opacity" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[140px]">
|
||||
{ROLE_OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon
|
||||
const isActive = opt.value === collabRole
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={opt.value}
|
||||
onClick={() => updateMutation.mutate({ userId: collab.user_id, role: opt.value })}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 opacity-60" />
|
||||
{opt.label}
|
||||
{isActive && <Check className="w-3.5 h-3.5 ml-auto" />}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-7 w-7 shrink-0',
|
||||
isOwner
|
||||
? 'invisible'
|
||||
: 'text-muted-foreground/50 hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity'
|
||||
)}
|
||||
onClick={() => !isOwner && removeMutation.mutate(collab.user_id)}
|
||||
disabled={isOwner}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{collaborators.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="px-5 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{collaborators.length} member{collaborators.length !== 1 ? 's' : ''} have access
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -9,16 +9,17 @@ import NewSketch from "@/components/sketches/new-sketch"
|
||||
|
||||
interface SketchesSectionProps {
|
||||
sketches: Sketch[]
|
||||
canCreate?: boolean
|
||||
}
|
||||
|
||||
|
||||
export function SketchesSection({ sketches }: SketchesSectionProps) {
|
||||
export function SketchesSection({ sketches, canCreate = true }: SketchesSectionProps) {
|
||||
const isEmpty = sketches.length === 0
|
||||
return (
|
||||
<section className="my-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-medium text-foreground">Sketches</h2>
|
||||
{!isEmpty && (
|
||||
{!isEmpty && canCreate && (
|
||||
<NewSketch>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground hover:text-foreground gap-1">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
@@ -29,9 +30,10 @@ export function SketchesSection({ sketches }: SketchesSectionProps) {
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<EmptySketches onAction={() => console.log("Create sketch")} />
|
||||
canCreate ? <EmptySketches onAction={() => console.log("Create sketch")} /> : <div className="text-sm text-muted-foreground py-4">No sketches yet.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div style={{ containerType: 'inline-size' }}>
|
||||
<div className="grid grid-cols-1 cq-sm:grid-cols-2 cq-md:grid-cols-3 cq-lg:grid-cols-4 gap-3">
|
||||
{sketches.map((sketch) => (
|
||||
<Link
|
||||
to="/dashboard/investigations/$investigationId/$type/$id"
|
||||
@@ -74,6 +76,7 @@ export function SketchesSection({ sketches }: SketchesSectionProps) {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section >
|
||||
|
||||
@@ -16,7 +16,8 @@ export function DashboardStats({ casesCount, activeCasesCount }: DashboardStatsP
|
||||
], [casesCount, activeCasesCount])
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4 mt-8">
|
||||
<div className="mt-8" style={{ containerType: 'inline-size' }}>
|
||||
<div className="grid grid-cols-1 cq-sm:grid-cols-2 cq-lg:grid-cols-4 gap-4">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="px-4 py-3 border border-border rounded-lg bg-card/30">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -31,5 +32,6 @@ export function DashboardStats({ casesCount, activeCasesCount }: DashboardStatsP
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,13 @@ export function FlowControls({
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="bg-card" onClick={onLayout} data-tour-id="layout-button">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-card"
|
||||
onClick={onLayout}
|
||||
data-tour-id="layout-button"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -95,7 +95,6 @@ const FlowEditor = memo(({ initialEdges, initialNodes, theme, flow }: FlowEditor
|
||||
const setLoading = useFlowStore((state) => state.setLoading)
|
||||
const colors = useNodesDisplaySettings((s) => s.colors)
|
||||
|
||||
// #### TanStack Query Mutations ####
|
||||
// Create flow mutation
|
||||
const createFlowMutation = useMutation({
|
||||
mutationFn: flowService.create,
|
||||
|
||||
@@ -169,80 +169,73 @@ function areEqual(prevProps: { enricher: Enricher }, nextProps: { enricher: Enri
|
||||
}
|
||||
|
||||
// Memoized enricher item component for the sidebar
|
||||
const EnricherItem = memo(
|
||||
({ enricher, onClick }: { enricher: Enricher; onClick: () => void }) => {
|
||||
const colors = useNodesDisplaySettings((s) => s.colors)
|
||||
const borderInputColor = colors[enricher.inputs.type.toLowerCase()]
|
||||
const borderOutputColor = colors[enricher.outputs.type.toLowerCase()]
|
||||
const Icon =
|
||||
enricher.type === 'type'
|
||||
? useIcon(enricher.outputs.type.toLowerCase() as string)
|
||||
: enricher.icon
|
||||
? useIcon(enricher.icon)
|
||||
: null
|
||||
const EnricherItem = memo(({ enricher, onClick }: { enricher: Enricher; onClick: () => void }) => {
|
||||
const colors = useNodesDisplaySettings((s) => s.colors)
|
||||
const borderInputColor = colors[enricher.inputs.type.toLowerCase()]
|
||||
const borderOutputColor = colors[enricher.outputs.type.toLowerCase()]
|
||||
const Icon =
|
||||
enricher.type === 'type'
|
||||
? useIcon(enricher.outputs.type.toLowerCase() as string)
|
||||
: enricher.icon
|
||||
? useIcon(enricher.icon)
|
||||
: null
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="p-3 rounded-md relative w-full overflow-hidden cursor-grab bg-card border ring-2 ring-transparent hover:ring-primary transition-all group"
|
||||
style={{
|
||||
borderLeftWidth: '5px',
|
||||
borderRightWidth: '5px',
|
||||
borderLeftColor: borderInputColor ?? borderOutputColor,
|
||||
borderRightColor: borderOutputColor,
|
||||
cursor: 'grab'
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between grow items-start">
|
||||
<div className="flex items-start gap-2 grow truncate text-ellipsis">
|
||||
<div className="space-y-1 truncate text-left">
|
||||
<div className="flex items-center gap-2 truncate text-ellipsis">
|
||||
{Icon && <Icon size={24} />}
|
||||
<h3 className="text-sm font-medium truncate text-ellipsis">
|
||||
{enricher.class_name}
|
||||
</h3>
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="p-3 rounded-md relative w-full overflow-hidden cursor-grab bg-card border ring-2 ring-transparent hover:ring-primary transition-all group"
|
||||
style={{
|
||||
borderLeftWidth: '5px',
|
||||
borderRightWidth: '5px',
|
||||
borderLeftColor: borderInputColor ?? borderOutputColor,
|
||||
borderRightColor: borderOutputColor,
|
||||
cursor: 'grab'
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between grow items-start">
|
||||
<div className="flex items-start gap-2 grow truncate text-ellipsis">
|
||||
<div className="space-y-1 truncate text-left">
|
||||
<div className="flex items-center gap-2 truncate text-ellipsis">
|
||||
{Icon && <Icon size={24} />}
|
||||
<h3 className="text-sm font-medium truncate text-ellipsis">
|
||||
{enricher.class_name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm font-normal opacity-60 truncate text-ellipsis">
|
||||
{enricher.description}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">Takes</span>
|
||||
<span className="font-bold truncate text-ellipsis">{enricher.inputs.type}</span>
|
||||
</div>
|
||||
<p className="text-sm font-normal opacity-60 truncate text-ellipsis">
|
||||
{enricher.description}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">Takes</span>
|
||||
<span className="font-bold truncate text-ellipsis">
|
||||
{enricher.inputs.type}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">Returns</span>
|
||||
<span className="font-bold truncate text-ellipsis">
|
||||
{enricher.outputs.type}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">Returns</span>
|
||||
<span className="font-bold truncate text-ellipsis">{enricher.outputs.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{enricher.required_params && (
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TriangleAlert className="h-4 w-4 text-yellow-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>API key required</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
areEqual
|
||||
)
|
||||
</div>
|
||||
{enricher.required_params && (
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TriangleAlert className="h-4 w-4 text-yellow-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>API key required</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}, areEqual)
|
||||
|
||||
EnricherItem.displayName = 'EnricherItem'
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Input } from '../ui/input'
|
||||
import KeySelector from '../keys/key-select'
|
||||
import { type Key } from '@/types/key'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { KeyService } from '@/api/key-service'
|
||||
import { keyService } from '@/api/key-service'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../ui/tabs'
|
||||
import { MemoizedMarkdown } from '../chat/memoized-markdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -39,7 +39,7 @@ const ParamsDialog = () => {
|
||||
// Fetch keys to convert between IDs and Key objects
|
||||
const { data: keys = [] } = useQuery<Key[]>({
|
||||
queryKey: ['keys'],
|
||||
queryFn: () => KeyService.get()
|
||||
queryFn: () => keyService.get()
|
||||
})
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
|
||||
@@ -19,6 +19,8 @@ export default function RawMaterial() {
|
||||
})
|
||||
const [searchTerm, setSearchTerm] = useState<string>('')
|
||||
|
||||
console.log(materials)
|
||||
|
||||
const filteredEnrichers = useMemo(() => {
|
||||
if (!materials?.items) return {}
|
||||
const result: Record<string, Enricher[]> = {}
|
||||
@@ -58,7 +60,7 @@ export default function RawMaterial() {
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search enrichers..."
|
||||
className="pl-8 !border border-border"
|
||||
className="pl-8 border! border-border"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user