feat(web): checkpoint web-only assisted investigation

This commit is contained in:
fy59 2026-09-27 16:41:29 +02:00
parent 3488f67b2a
commit 9495e1258a
135 changed files with 5108 additions and 48089 deletions

View file

@ -1,5 +1,17 @@
## [0.1.0-dev] — en préparation, non publiée
### Interface Web finale locale
- Web devient l’interface utilisateur `CURRENT` unique : coque Catppuccin
Mocha/Lavender Agent | Graphe | Activité, drawer transversal et cartes
SPECIMEN pour conversation, plan, hypothèse, autorisation et résultat.
- Retrait du client, des vues, widgets, tests, headers et dépendance de build
GTK4 ; les services C, SQLite V20, fondation financière V21, capabilities et
JobStore V4 sont conservés.
- Ajout de `tools/labfy` : instance loopback vérifiée, navigateur ouvert et
session HttpOnly/SameSite automatique sans code ni jeton dans l’URL ; les
mutations restent protégées par Host, Origin exact et CSRF.
### Connexion Web
- La session de `3600` secondes (une heure) commence à l'authentification, et non au démarrage
@ -146,6 +158,21 @@
- Finalisation JobStore V3 : budgets opposables au claim, temps monotone,
réserve conservée après crash, états de plan exportés et scénario Firefox J8.
# Changements locaux non publiés — recherche OSINT assistée V1
- JobStore V4 additif à V3 : persistance atomique et idempotente des plans,
actions, grants, campagnes, résultats et reçus de recherche, sans évolution
de V20 ni de la tranche financière V21.
- Policy explicite de sélection, exclusions, révocation, expiration, identité
d'enquête/plan/action et budgets ; aucune propagation implicite par seed,
CNAME, redirection ou nouvelle URL.
- Transport commun indisponible sans backend lié et libcurl couvert seulement
par fixtures/loopback ; aucun fournisseur public n'est configuré ou qualifié.
- Adapters normalisateurs DNS, RDAP, CDX, Brave, SearXNG et page, avec résultats
bornés, provenance C et absence de suivi implicite de liens, scripts ou pièces jointes.
- Parcours Web synthétique de préparation, approbation/refus, deux vagues et
révocation durable, avec isolation vérifiée entre espaces de travail A/B.
# Changements locaux non publiés — J9
## Espace local et import navigateur (non publié)

132
Makefile
View file

@ -1,7 +1,7 @@
CC = gcc
PKG_CONFIG = pkg-config
REQUIRED_PACKAGES = gtk4 sqlite3 libheif poppler-glib json-glib-1.0
REQUIRED_PACKAGES = glib-2.0 gio-2.0 sqlite3 libheif poppler-glib json-glib-1.0
ifeq ($(shell $(PKG_CONFIG) --exists $(REQUIRED_PACKAGES) && echo yes),)
$(error Dépendances de compilation manquantes : $(REQUIRED_PACKAGES). Voir docs/DEPENDENCE.md)
endif
@ -11,13 +11,9 @@ endif
SOURCE_SIZE_LIMIT := 2000
# Exceptions historiques : plafonds constatés avant la tranche V18.
SOURCE_SIZE_EXCEPTIONS := \
src/core/application.c:9325 \
src/widgets/investigation_graph_view.c:4901 \
src/widgets/workspace.c:4071 \
src/views/main_window.c:2239 \
src/views/create_relation_dialog.c:2043
src/core/local_job_store.c:2000
.PHONY: check-source-size web web-start web-status web-stop web-code web-workspace web-workspace-j7 web-workspace-j8 web-workspace-j9 web-workspace-local web-workspace-review-check web-workspace-web-check
.PHONY: check-source-size check-research-libcurl web web-start web-status web-stop web-workspace web-workspace-j7 web-workspace-j8 web-workspace-j9 web-workspace-local web-workspace-review-check web-workspace-web-check
.NOTPARALLEL: web-workspace-review-check
# CONTRACT: le poste Web possède sa bibliothèque explicite, séparée des
@ -27,10 +23,10 @@ PORT ?= 8081
LIBRARY ?= $(shell python3 -c 'import os; from pathlib import Path; print(Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "labfy-investigation" / "web-8081-lab")')
web: tools/local-jobs
tools/labfy-web serve --port "$(PORT)" --library "$(LIBRARY)"
LABFY_LIBRARY="$(LIBRARY)" tools/labfy
web-start: tools/local-jobs
tools/labfy-web start --port "$(PORT)" --library "$(LIBRARY)"
tools/labfy-web start --automatic-session --open-browser --port "$(PORT)" --library "$(LIBRARY)"
web-status:
tools/labfy-web status
@ -38,14 +34,12 @@ web-status:
web-stop:
tools/labfy-web stop
web-code:
tools/labfy-web code
check-source-size:
@limit=$(SOURCE_SIZE_LIMIT); failed=0; \
files="$$(git ls-files --cached --others --exclude-standard -- \
'src/*.c' 'src/**/*.c' | sort -u)"; \
for file in $$files; do \
[ -f "$$file" ] || continue; \
lines=$$(wc -l < "$$file"); allowed=$$limit; historical=0; \
for exception in $(SOURCE_SIZE_EXCEPTIONS); do \
case "$$exception" in "$$file":*) \
@ -61,6 +55,14 @@ check-source-size:
done; \
exit $$failed
# CONTRACT: le diagnostic libcurl est limité aux cibles qui compilent le
# transport HTTP concret ; les tests de policy/adapters restent indépendants.
check-research-libcurl:
@$(PKG_CONFIG) --exists libcurl || { \
echo "Dépendance de compilation manquante : libcurl (requis par le transport HTTP de recherche)." >&2; \
exit 2; \
}
web-workspace: tools/local-jobs
@test -n "$(WORKSPACE)" || (echo "Usage: make web-workspace WORKSPACE=/tmp/labfy-local-workspace"; exit 2)
python3 prototypes/web-graph/run_web_workspace.py --workspace "$(WORKSPACE)"
@ -111,9 +113,9 @@ CFLAGS = -std=c17 \
-Iinclude \
-MMD \
-MP \
$(shell $(PKG_CONFIG) --cflags gtk4 sqlite3 libheif poppler-glib json-glib-1.0)
$(shell $(PKG_CONFIG) --cflags glib-2.0 gio-2.0 sqlite3 libheif poppler-glib json-glib-1.0)
LDFLAGS = $(shell $(PKG_CONFIG) --libs gtk4 sqlite3 libheif poppler-glib json-glib-1.0) -ljpeg
LDFLAGS = $(shell $(PKG_CONFIG) --libs glib-2.0 gio-2.0 sqlite3 libheif poppler-glib json-glib-1.0) -ljpeg
TEST_CFLAGS = -std=c17 \
-Wall \
@ -130,16 +132,6 @@ EVIDENCE_TYPE_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
ENTITY_TYPE_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
EVIDENCE_TYPE_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
GRAPH_NODE_POSITION_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
EVIDENCE_IMPORT_DIALOG_TEST_CFLAGS := \
-std=c17 \
-Wall \
-Wextra \
-Werror \
-Iinclude \
$(shell $(PKG_CONFIG) --cflags gtk4)
EVIDENCE_IMPORT_DIALOG_TEST_LDFLAGS := $(shell $(PKG_CONFIG) --libs gtk4)
EVIDENCE_INTEGRITY_VERIFIER_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
ENTITY_RECORD_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
ENTITY_TYPE_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
@ -285,6 +277,31 @@ EML_GRAPH_DEMO := tools/eml_graph_demo
LOCAL_TOOLKIT_DEMO := tools/local_toolkit_demo
LOCAL_JOBS := tools/local-jobs
LOCAL_JOBS_TEST := tools/local-jobs-test
TEST_RESEARCH_STORE := tests/test_research_store
TEST_RESEARCH_POLICY := tests/test_research_policy
TEST_RESEARCH_TRANSPORT := tests/test_research_transport
TEST_RESEARCH_ADAPTERS := tests/test_research_adapters
# CONTRACT: seul le transport HTTP concret lie libcurl ; les contrats et
# adapters restent utilisables dans les tests et outils sans backend réseau.
RESEARCH_SOURCES := \
src/core/research_contracts.c \
src/core/research_policy.c \
src/core/research_transport.c \
src/core/research_http_transport_curl.c \
src/core/research_adapter_dns.c \
src/core/research_adapter_rdap.c \
src/core/research_adapter_cdx.c \
src/core/research_adapter_search.c \
src/core/research_adapter_page.c
RESEARCH_CURL_CFLAGS := $(shell $(PKG_CONFIG) --cflags libcurl)
RESEARCH_CURL_LDFLAGS := $(shell $(PKG_CONFIG) --libs libcurl)
RESEARCH_CURL_TARGETS := $(TARGET) $(TEST_EVIDENCE_METADATA_DIALOG_GTK) \
$(TEST_EVIDENCE_IDENTITY_IMPORT_GTK) $(TEST_WORKSPACE_IDENTITY_OCR_GTK)
$(RESEARCH_CURL_TARGETS): check-research-libcurl
$(RESEARCH_CURL_TARGETS): CFLAGS += $(RESEARCH_CURL_CFLAGS)
$(RESEARCH_CURL_TARGETS): LDFLAGS += $(RESEARCH_CURL_LDFLAGS)
LOCAL_REVIEW_PREVIEW_SOURCES := \
src/core/observation_review_service.c \
@ -362,6 +379,28 @@ $(TEST_LOCAL_CAPABILITY_REGISTRY): tests/test_local_capability_registry.c \
$(TEST_LOCAL_JOB_STORE): tests/test_local_job_store.c src/core/local_job_store.c
$(CC) $(CORE_GRAPH_CFLAGS) -DLOCAL_JOB_STORE_ENABLE_TEST_HOOKS $^ -o $@ $(CORE_GRAPH_LDFLAGS)
$(TEST_RESEARCH_STORE): tests/test_research_store.c \
src/core/local_job_store.c src/core/research_contracts.c
$(CC) $(CORE_GRAPH_CFLAGS) $^ -o $@ $(CORE_GRAPH_LDFLAGS)
$(TEST_RESEARCH_POLICY): tests/test_research_policy.c \
src/core/research_contracts.c src/core/research_policy.c
$(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS)
$(TEST_RESEARCH_TRANSPORT): check-research-libcurl \
tests/test_research_transport.c src/core/research_contracts.c \
src/core/research_policy.c src/core/research_transport.c \
src/core/research_http_transport_curl.c
$(CC) $(TEST_CFLAGS) -Wpedantic -DRESEARCH_TRANSPORT_ENABLE_TEST_HOOKS \
$(RESEARCH_CURL_CFLAGS) $(filter %.c,$^) -o $@ $(TEST_LDFLAGS) \
$(RESEARCH_CURL_LDFLAGS)
$(TEST_RESEARCH_ADAPTERS): tests/test_research_adapters.c \
src/core/research_contracts.c src/core/research_adapter_dns.c \
src/core/research_adapter_rdap.c src/core/research_adapter_cdx.c \
src/core/research_adapter_search.c src/core/research_adapter_page.c
$(CC) $(TEST_CFLAGS) -Wpedantic $^ -o $@ $(TEST_LDFLAGS)
$(TEST_LOCAL_CORRELATION): tests/test_local_correlation.c \
src/core/local_correlation_service.c \
src/dao/evidence_dao.c src/dao/evidence_entity_dao.c \
@ -387,13 +426,17 @@ $(TEST_OBSERVATION_REVIEW_SERVICE): tests/test_observation_review_service.c \
$(LOCAL_JOBS): tools/local_jobs.c src/core/local_job_store.c \
src/core/local_planner_service.c \
$(LOCAL_REVIEW_PREVIEW_SOURCES) $(LOCAL_TOOLKIT_SOURCES)
$(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS)
$(LOCAL_REVIEW_PREVIEW_SOURCES) $(LOCAL_TOOLKIT_SOURCES) $(RESEARCH_SOURCES) \
check-research-libcurl
$(CC) $(CFLAGS) $(RESEARCH_CURL_CFLAGS) $(filter %.c,$^) -o $@ $(LDFLAGS) \
$(RESEARCH_CURL_LDFLAGS)
$(LOCAL_JOBS_TEST): tools/local_jobs.c src/core/local_job_store.c \
src/core/local_planner_service.c \
$(LOCAL_REVIEW_PREVIEW_SOURCES) $(LOCAL_TOOLKIT_SOURCES)
$(CC) $(CFLAGS) -DLOCAL_JOBS_ENABLE_CRASH_HOOK $^ -o $@ $(LDFLAGS)
$(LOCAL_REVIEW_PREVIEW_SOURCES) $(LOCAL_TOOLKIT_SOURCES) $(RESEARCH_SOURCES) \
check-research-libcurl
$(CC) $(CFLAGS) -DLOCAL_JOBS_ENABLE_CRASH_HOOK $(RESEARCH_CURL_CFLAGS) \
$(filter %.c,$^) -o $@ $(LDFLAGS) $(RESEARCH_CURL_LDFLAGS)
$(TEST_DIALOG_GEOMETRY_GTK): tests/test_dialog_geometry_gtk.c \
src/views/dialog_geometry.c
@ -454,7 +497,10 @@ $(TEST_PERSON_OCR_PROJECTION_EDITOR_GTK): tests/test_person_ocr_projection_edito
src/models/identity_traceability.c
$(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS)
all: $(TARGET)
# CONTRACT: le produit utilisateur est le poste Web local. La cible principale
# construit le bridge C qui conserve les services, SQLite et le JobStore V4,
# sans lier un renderer de bureau historique.
all: $(LOCAL_JOBS)
.PHONY: core-graph-demo eml-graph-demo local-toolkit-demo
core-graph-demo: $(CORE_GRAPH_DEMO)
@ -1399,7 +1445,7 @@ $(TEST_INVESTIGATION_GRAPH_LOAD_TASK): \
$(CC) $(INVESTIGATION_GRAPH_LOAD_TASK_TEST_CFLAGS) $^ -o $@ \
$(TEST_LDFLAGS) -lsqlite3
test: \
test-gtk-legacy: \
$(TEST_NODE) \
$(TEST_TREE_MODEL) \
$(TEST_TREE_BUILDER) \
@ -1494,6 +1540,10 @@ test: \
$(TEST_LOCAL_TOOL_RUNNER) \
$(TEST_LOCAL_CAPABILITY_REGISTRY) \
$(TEST_LOCAL_JOB_STORE) \
$(TEST_RESEARCH_STORE) \
$(TEST_RESEARCH_POLICY) \
$(TEST_RESEARCH_TRANSPORT) \
$(TEST_RESEARCH_ADAPTERS) \
$(TEST_LOCAL_CORRELATION) \
$(TEST_LOCAL_PLANNER) \
$(TEST_LOCAL_REPORT) \
@ -1609,6 +1659,10 @@ test: \
@$(TEST_LOCAL_TOOL_RUNNER)
@$(TEST_LOCAL_CAPABILITY_REGISTRY)
@$(TEST_LOCAL_JOB_STORE)
@$(TEST_RESEARCH_STORE)
@$(TEST_RESEARCH_POLICY)
@$(TEST_RESEARCH_TRANSPORT)
@$(TEST_RESEARCH_ADAPTERS)
@$(TEST_LOCAL_CORRELATION)
@$(TEST_LOCAL_REPORT)
@$(TEST_OBSERVATION_REVIEW_SERVICE)
@ -1632,6 +1686,24 @@ test: \
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
# CONTRACT: la suite canonique valide les services restant dans le produit
# Web-only. Les anciennes assertions de widgets GTK sont retirées avec leur
# renderer, sans retirer les tests C de persistance, graphes, jobs et policy.
test: $(TEST_LOCAL_JOB_STORE) $(TEST_RESEARCH_STORE) $(TEST_RESEARCH_POLICY) \
$(TEST_RESEARCH_TRANSPORT) $(TEST_RESEARCH_ADAPTERS) \
$(TEST_SCHEMA_V21) $(TEST_FINANCIAL_DAO) $(TEST_FINANCIAL_FOUNDATION) \
$(TEST_CORE_GRAPH) $(LOCAL_JOBS_TEST)
@./$(TEST_LOCAL_JOB_STORE)
@./$(TEST_RESEARCH_STORE)
@./$(TEST_RESEARCH_POLICY)
@./$(TEST_RESEARCH_TRANSPORT)
@./$(TEST_RESEARCH_ADAPTERS)
@./$(TEST_SCHEMA_V21)
@./$(TEST_FINANCIAL_DAO)
@./$(TEST_FINANCIAL_FOUNDATION)
@./$(TEST_CORE_GRAPH)
@echo "Tous les tests Web-only C sont valides."
run: $(TARGET)
./$(TARGET)

View file

@ -3,6 +3,11 @@
Le parcours `CURRENT` de jobs locaux persistants et de reprise J5 est documenté
dans [docs/architecture/LOCAL_JOBS_RECOVERY.md](docs/architecture/LOCAL_JOBS_RECOVERY.md).
La [recherche OSINT assistée V1](docs/architecture/OSINT_ASSISTED_RESEARCH.md)
ajoute au JobStore V4 des plans, grants et reçus de policy distincts, validés
uniquement avec des fixtures et un fournisseur loopback `SPECIMEN`. Aucun
fournisseur public n'est configuré ou qualifié ; V20 et V21 ne sont pas modifiés.
Le premier poste Web local pilotable J6 est documenté dans
[docs/architecture/WEB_WORKSPACE_CONTROL.md](docs/architecture/WEB_WORKSPACE_CONTROL.md).
@ -67,14 +72,14 @@ données. Labfy vise à relier leurs résultats dans une enquête cohérente :
La cible de développement est **v0.1.0**. Le projet reste en développement actif
et n'est pas prêt pour un usage opérationnel en production.
**Interface GTK conservée ; poste Web local contrôlé disponible.** La fondation
v0.1.0 reste en développement : le poste Web réutilise le moteur de jobs
persistant existant et une bibliothèque locale explicitement choisie, sans
constituer une interface de production ni une autonomie générale.
**Web est l’interface utilisateur CURRENT ; GTK est supprimé.** La fondation
v0.1.0 reste en développement : le poste local réutilise le moteur de jobs
persistant, la bibliothèque explicitement choisie et les capabilities C, sans
constituer une autonomie générale ni un service distant.
### Disponible aujourd'hui
L'application actuelle est une application de bureau GTK4 en C17. Elle fournit
Le produit actuel est un poste Web local-first devant le cœur C17. Il fournit
notamment :
- création et ouverture d'enquêtes autonomes avec SQLite versionné ;
@ -207,10 +212,11 @@ effectuées par des adapters, sous contrôle des politiques applicables.
## Interface et expérience cible
GTK demeure temporairement l'interface fonctionnelle existante. La future
interface principale sera Web, avec le graphe comme espace de travail central.
Le protocole local, le mécanisme d'événements et le framework frontend ne sont
pas encore sélectionnés.
Le Web est l’interface fonctionnelle unique, avec le graphe comme espace de
travail central. Sa coque organise **Agent | Graphe | Activité**, complétée par
un drawer transversal de détails, provenance, timeline, recherches,
observations, finance, rapports et tâches. Qwen local, tool calling et le
governor AMD/RAM/swap restent `TARGET`.
La cible comprend :
@ -278,7 +284,7 @@ Les dépendances et procédures Arch Linux et Ubuntu sont décrites dans
```bash
make -j8
make test
make run
tools/labfy
```
La démonstration cœur/Web synthétique se lance séparément :
@ -293,21 +299,23 @@ python3 run_core_demo.py --port 8765
# ou : python3 run_local_toolkit_demo.py --port 8765
```
Le projet exige C17, GTK4, GLib/GIO, SQLite, libheif et Poppler GLib. Plusieurs
outils d'analyse sont optionnels et ne sont jamais installés automatiquement.
Le projet exige C17, GLib/GIO, SQLite, libheif et Poppler GLib. GTK4 n’est plus
une dépendance de compilation. Plusieurs outils d'analyse sont optionnels et
ne sont jamais installés automatiquement.
outils d'analyse sont optionnels et ne sont jamais installés automatiquement.
Le poste Web local se lance depuis une bibliothèque explicitement désignée ;
il écoute par défaut sur `127.0.0.1:8081` :
```bash
make web PORT=8081 LIBRARY=/chemin/vers/la-bibliotheque-locale
LABFY_LIBRARY=/chemin/vers/la-bibliotheque-locale tools/labfy
```
Les commandes `start`, `status`, `stop` et `code` de ce lanceur, ainsi que les
contrats de stockage local et de session, sont décrits dans
[le contrôle du poste Web](docs/architecture/WEB_WORKSPACE_CONTROL.md). Le
code éphémère affiché au démarrage reste privé : il ne doit ni être ajouté à une
commande, ni enregistré dans la documentation.
Le lanceur rejoint ou démarre une instance locale possédée, ouvre le navigateur
et établit une session HttpOnly sans code manuel ni jeton dans l’URL. Les
contrats de stockage local et de session sont décrits dans
[le contrôle du poste Web](docs/architecture/WEB_WORKSPACE_CONTROL.md).
## Documentation
@ -318,7 +326,9 @@ commande, ni enregistré dans la documentation.
- [Décisions architecturales](docs/architecture/decisions/0001-web-local-first.md)
- [Toolkit et statuts d'intégration](docs/osint/TOOLKIT.md)
- [Toolkit local et runner borné J4](docs/architecture/LOCAL_TOOLKIT_RUNNER.md)
- [Recherche OSINT assistée V1 en laboratoire](docs/architecture/OSINT_ASSISTED_RESEARCH.md)
- [Design system cible](docs/ui/DESIGN_SYSTEM.md)
- [Matrice de parité Web / GTK](docs/ui/WEB_GTK_PARITY.md)
- [Sécurité, scope et secrets](docs/security/SECURITY_MODEL.md)
- [Roadmap canonique](docs/ROADMAP.md)
- [Backlog préparé](docs/BACKLOG.md)

View file

@ -8,10 +8,19 @@ Le contrôle navigateur local sur espace exclusivement synthétique est décrit
dans [WEB_WORKSPACE_CONTROL.md](architecture/WEB_WORKSPACE_CONTROL.md). Cette
surface mutationnelle J6 est distincte des démonstrations read-only J2–J5.
La [recherche OSINT assistée V1](architecture/OSINT_ASSISTED_RESEARCH.md) est
`CURRENT` dans le worktree uniquement pour son parcours de laboratoire : le
JobStore V4 est additif à V3, les grants sont explicites et aucun fournisseur
public n'est configuré ou qualifié. Elle ne modifie ni V20 ni V21.
> **Statut :** direction canonique v0.1.0
> **Dernière mise à jour :** 2026-09-25
> **Dernière mise à jour :** 2026-09-27
> **Portée :** architecture actuelle et cible Labfy V2
> **Interface CURRENT :** le Web local est l'unique interface utilisateur ;
> GTK et ses tests/widgets de renderer sont retirés. Le cœur C17, SQLite, les
> services, capabilities et JobStore V4 n'en dépendent pas.
## 1. Objet et niveaux de maturité
Labfy Investigation est un environnement local-first d'investigation numérique
@ -123,11 +132,11 @@ une hypothèse. Il ne devient pas silencieusement une identité confirmée.
## 3. Architecture actuelle — CURRENT
L'application existante est principalement écrite en C17 avec GTK4, GLib/GIO
Le produit actuel est principalement écrit en C17 avec GLib/GIO
et SQLite.
```text
GTK views/widgets
Web frontend local
│ intentions et affichage
Application / services / tasks
│ orchestration
@ -154,7 +163,7 @@ Le socle comprend déjà :
- enquêtes autonomes et base versionnée ;
- preuves, extractions, entités et relations ;
- graphe interactif GTK et positions persistantes ;
- graphe Web interactif et positions persistantes ;
- tâches asynchrones en mémoire ;
- registre initial d'outils ;
- premiers parcours DNS et documentaires ;
@ -208,7 +217,7 @@ pas une réécriture générale du cœur fonctionnel.
SQLite
```
La future interface principale est Web. GTK reste l'interface fonctionnelle
L'interface principale actuelle est Web. GTK est supprimé ;
temporaire et ne sera pas supprimé avant qu'un remplacement validé couvre les
parcours nécessaires.
@ -599,7 +608,7 @@ Le parcours local est limité à une instance loopback et à des validations sur
fixtures `SPECIMEN`. Son lanceur peut ouvrir une bibliothèque locale
explicitement choisie, en créer les enquêtes par le bridge C et n'en active
qu'une seule dans une instance. Il ne rouvre pas implicitement l'enquête d'une
session précédente. Cette surface ne change pas son statut : GTK reste le
session précédente. Cette surface ne change pas son statut : le Web reste le
parcours fonctionnel actuel et le serveur, son packaging ainsi que le choix du
renderer de production restent `UNDECIDED`.
@ -647,3 +656,25 @@ signaux exploratoires ; ils ne prouvent ni identité ni relation métier.
Le durcissement P01–P06 couvre le cycle de vie des imports, les réservations,
le rejeu, les états terminaux et le framing HTTP du corps. Il ne transforme pas
le protocole local en service distant ni en garantie de production.
## 18. Recherche OSINT assistée V1 — CURRENT de laboratoire
Le JobStore V4 conserve le worker mono-propriétaire et ajoute des objets
distincts de `local_plan.v1` et de session : plan de recherche, action,
`ScopeGrant`, campagne, résultat et reçu de policy. Les admissions sont
idempotentes et atomiques ; la policy expose les refus d'exclusion, révocation,
expiration, identité d'enquête/plan/action ou budget. Une seed dérivée, une
redirection, un CNAME ou une URL nouvelle ne propage jamais le scope.
Le transport commun demeure indisponible sans backend lié. La variante libcurl
est validée seulement contre le fournisseur loopback `SPECIMEN` du harness et
rejette les adresses non publiques ; elle ne qualifie aucun fournisseur public
ni configuration de production. Les adapters DNS, RDAP, CDX, Brave, SearXNG et
page normalisent des fixtures bornées ; une `next_piste` demande toujours une
nouvelle préparation et décision humaine.
Le panneau Web local prépare un plan, permet l'approbation ou le refus explicite
des actions, puis admet une campagne en deux vagues séparées. Les sessions et
CSRF Web restent hors des contrats durables ; les tests synthétiques vérifient
l'isolation des espaces A/B. Le détail canonique, les procédures de harness et
les limites sont dans [OSINT_ASSISTED_RESEARCH.md](architecture/OSINT_ASSISTED_RESEARCH.md).

View file

@ -13,7 +13,7 @@ n’est persisté.
| Vidéo GTK | `gst-plugins-base`, `gst-plugins-good` | `gstreamer1.0-plugins-base`, `gstreamer1.0-plugins-good` |
Les codecs vidéo supplémentaires restent optionnels.
Le Makefile vérifie explicitement GTK4, SQLite, libheif, Poppler GLib et
Le Makefile vérifie explicitement GLib/GIO, SQLite, libheif, Poppler GLib et
JSON-GLib avant
compilation et renvoie vers ce document si une dépendance manque.
@ -54,6 +54,7 @@ export PATH="$HOME/.local/share/labfy-osint/bin:$PATH"
| Résolution réseau | `host` | `sudo pacman -S bind` | `sudo apt install bind9-host` |
| WHOIS | `whois` | `sudo pacman -S whois` | `sudo apt install whois` |
| Requêtes HTTPS | `curl` | `sudo pacman -S curl` | `sudo apt install curl` |
| Transport HTTP C de recherche (harness V1) | `libcurl` | `sudo pacman -S curl` | `sudo apt install libcurl4-openssl-dev` |
| Certificats/TLS | `openssl` | `sudo pacman -S openssl` | `sudo apt install openssl` |
| Récupération PDF | `qpdf` | `sudo pacman -S qpdf` | `sudo apt install qpdf` |
| Audit de mot de passe PDF | `john`, `pdf2john` | `sudo pacman -S john` | `sudo apt install john` |
@ -138,3 +139,14 @@ données distincte, ni installation automatique. Ses fichiers d'instance et de
configuration suivent XDG (`XDG_RUNTIME_DIR`, `XDG_STATE_HOME`) ; la
bibliothèque d'enquêtes est une destination locale explicitement fournie par
`LIBRARY`, et non une dépendance à installer.
Le harness de recherche OSINT assistée V1 utilise libcurl pour ses tests de
transport avec un fournisseur loopback `SPECIMEN`. Cette dépendance ne
configure, ne qualifie et ne contacte aucun fournisseur public ; les
fournisseurs externes restent indisponibles tant que leur configuration et
leur autorisation ne sont pas établies par une tranche dédiée.
GTK4 n’est plus une dépendance de Labfy : le build Web-only exige GLib/GIO,
SQLite, libheif, Poppler GLib, JSON-GLib, Python 3 et Firefox/`puppeteer-core`
pour les validations visuelles. Aucun environnement Wayland, GSK_RENDERER ou
display GTK n’est requis par la suite canonique.

View file

@ -1,7 +1,7 @@
# Documentation de Labfy Investigation
> **Version cible :** v0.1.0 Foundation, en préparation et non publiée
> **Interface actuelle :** GTK4
> **Interface actuelle :** Web local-first ; GTK est retiré du produit.
> **Interface cible :** Web local-first ; prototype expérimental J2/J3 présent
Cette page sépare l'usage du logiciel actuel, la direction acceptée et les
@ -18,9 +18,11 @@ déjà disponible.
- [test manuel du pivot EML](testing/EML_PIVOT_MANUAL_TEST.md).
- [poste Web local contrôlé](architecture/WEB_WORKSPACE_CONTROL.md) et
[guide du poste Web](ui/WEB_WORKBENCH.md).
- [recherche OSINT assistée V1 en laboratoire](architecture/OSINT_ASSISTED_RESEARCH.md) :
JobStore V4 additif, grants explicites et parcours Web synthétique.
Les guides GTK restent applicables tant que l'interface actuelle est maintenue.
Le poste Web local `CURRENT` ne retire donc aucun parcours GTK ni ne décide le
Les guides GTK archivés décrivent des parcours retirés ; ils ne sont plus des
instructions `CURRENT`. Le poste Web local `CURRENT` remplace le renderer GTK et décide le
serveur, le renderer ou le packaging de production.
## Direction v0.1.0 — TARGET

View file

@ -34,6 +34,10 @@ construction sans démarrer la refonte fonctionnelle.
**Dépendances.** État réel du dépôt et préservation des travaux locaux, dont la
tranche financière V21 indépendante.
> **Interface CURRENT local :** la tranche Web finale a remplacé GTK par une
> surface locale Agent | Graphe | Activité + drawer. Le raccord Qwen local,
> tool calling et resource governor AMD/RAM/swap restent `TARGET`.
**Livrables.** README, bannière et source éditable, index documentaire,
architecture canonique, contrat du graphe, ADR Web/graph-first/polyglotte,
Toolkit, sécurité, design system, roadmap et backlog préparé.

View file

@ -0,0 +1,163 @@
# Recherche OSINT assistée V1 — contrat de laboratoire
> **Statut :** `CURRENT` dans le worktree, validation exclusivement synthétique
> **Portée :** persistance V4, décisions de scope et parcours Web local de
> recherche assistée ; pas une qualification de fournisseur public
## 1. Limites de ce qui est livré
La recherche assistée V1 ajoute une persistance opérationnelle au `JobStore`
local. La migration est additive de V3 vers V4 : elle conserve les objets et
contrats de jobs locaux V3. Elle ne remplace pas la base métier V20 et ne
modifie pas la tranche financière V21.
Le `JobStore` reste servi par son worker mono-propriétaire existant. Les
opérations de recherche n'introduisent ni second ordonnanceur ni exécution
concurrente implicite. L'admission des objets de recherche ne démarre pas, à
elle seule, une campagne.
Cette tranche est qualifiée uniquement par des fixtures `SPECIMEN`, des
réponses injectées et un fournisseur de laboratoire loopback explicitement
configuré par le harness. Aucun fournisseur public n'est configuré ou qualifié
par ce contrat. Une absence de configuration ou d'autorisation de stockage
rend l'adapter concerné indisponible ; elle ne déclenche ni installation, ni
appel externe, ni repli vers un endpoint implicite.
## 2. Contrats persistants distincts
Les contrats versionnés de recherche assistée sont distincts des contrats
`labfy.local_plan.v1` et de session Web. Un cookie, un CSRF ou l'état de
l'interface ne font pas partie de leur contenu durable.
| Objet | Contrat | Rôle durable |
| --- | --- | --- |
| `ResearchPlan` | `labfy.research_plan.v1` | intention bornée, seeds, actions exactes et empreintes d'entrée |
| `ResearchAction` | composant du plan | capability, fournisseur, endpoint, sujet, divulgation, classe de contact et plafonds |
| `ScopeGrant` | `labfy.scope_grant.v1` | décision liée à une enquête et à un plan, actions sélectionnées, exclusions, expiration et budgets |
| `ResearchCampaign` | `labfy.research_campaign.v1` | identité idempotente d'une campagne admise ; elle ne vaut pas démarrage automatique |
| `ResearchResult` | `labfy.research_result.v1` | résultat brut référencé par chemin relatif et empreinte de contenu |
| `ResearchReceipt` | `labfy.research_receipt.v1` | décision de policy associée au résultat, à l'action, au grant et à la campagne |
L'admission est atomique : une même clé d'idempotence et le même contenu
retournent l'objet durable existant ; une même clé et un contenu différent sont
en conflit. Un plan ou un grant invalide ne laisse pas d'écriture partielle.
Un résultat ne peut pas être publié sans reçu visant une action explicitement
sélectionnée dans le grant de sa campagne.
## 3. Scope, policy et destinations
Avant tout contact, une décision est évaluée sur l'enquête, l'empreinte du
plan, l'action exacte, le grant et les budgets demandés. Les motifs observables
incluent notamment l'exclusion, la révocation, l'expiration, une enquête ou un
plan différent, une action non sélectionnée ou altérée, et un budget dépassé.
La révocation est durable et ne supprime pas l'historique du grant.
La validation HTTP compare exactement le sujet, l'endpoint, le port et la
sélection. Le profil réseau est une allowlist d'endpoints et de ports, jamais
une règle par suffixe. Les adresses non publiquement routables sont refusées
par le contrat du transport de production. Une redirection, un CNAME, une URL
nouvelle ou une seed dérivée ne propage jamais le scope : chacun constitue une
nouvelle destination à décider explicitement.
Le transport commun reste sans backend réseau lié et retourne donc
`UNAVAILABLE` sans contact. Son contrat interdit proxy, cookies, `netrc`,
authentification ambiante, redirection automatique et résolution fournie par
l'appelant. La variante libcurl est testée uniquement avec le routage de
fixture vers loopback ; son callback réseau refuse les adresses non publiques.
Les préfixes privés, locaux, documentaires et de benchmark ne sont pas des
adresses publiques. Les plafonds du corps compressé reçu sur le fil, du corps
décompressé et des en-têtes sont appliqués séparément, y compris sans
`Content-Length` et avec un transfert chunked.
Cela ne qualifie aucune configuration de production ni aucun fournisseur
externe.
## 4. Adapters normalisateurs — CURRENT en laboratoire
Les adapters suivants normalisent des données de fixture et préservent leurs
limites de périmètre :
- DNS : types bornés A, AAAA, CNAME, MX, NS, TXT, SOA et PTR ; ni `ANY`, ni
AXFR, ni trace n'appartiennent au type public. Un cycle CNAME est signalé et
ne crée pas de piste suivante implicite.
- RDAP : sélection du service depuis un bootstrap versionné et borné ; le
bootstrap routé ne vaut jamais scope pour le service retourné.
- CDX : correspondance d'URL ou d'hôte exact, captures normalisées et curseur
explicite ; il n'y a pas d'élargissement de la cible.
- Brave et SearXNG : disponibilité explicite (`UNAVAILABLE_CONFIG` ou
`UNAVAILABLE_STORAGE_AUTHORIZATION`) tant que l'endpoint, la référence de
secret et l'autorisation nécessaires ne sont pas réunis. Aucun secret n'est
persisté dans ces contrats.
- Page : production de texte inerte borné ; scripts, liens, sous-ressources et
pièces jointes ne sont ni exécutés ni suivis.
Ces résultats peuvent indiquer une `next_piste`, qui reste une information à
préparer et approuver ; elle ne confère aucune autorisation dérivée.
## 5. Publication et provenance C
Le cœur C sépare la décision, l'artefact brut, le résultat et le reçu. Le
résultat conserve un chemin relatif d'artefact et son SHA-256 ; la publication
atomique exige le reçu de policy correspondant. Cette provenance permet de
revenir au grant, à l'action et à la campagne, sans assimiler un contenu reçu
à une identité confirmée ou à une autorisation de poursuivre.
Les résultats de laboratoire du parcours Web sont stockés comme artefacts
privés de l'espace de travail de test. Le bridge expose un snapshot de
recherche, pas un accès arbitraire au répertoire d'artefacts ni une base de
provenance indépendante du cœur C.
La publication fichier/SQLite utilise un journal privé créé avant le renommage
final. Il lie l'identité déterministe du résultat à l'empreinte attendue. Si le
processus s'interrompt après le renommage, le rejeu de la même campagne vérifie
ce journal et l'artefact, puis enregistre atomiquement résultat et reçu sans
recontacter le fournisseur. Un état possédé incohérent est compensé avant de
retourner une erreur. Le journal est supprimé après publication durable ; cette
reprise n'ajoute ni table ni numéro de schéma.
## 6. Poste Web local — préparation et décision humaine
Le panneau de recherche du poste Web local prépare un plan depuis une
sélection et une révision de snapshot précises. L'interface présente les
actions et leurs divulgations, puis exige une décision explicite :
1. préparer un plan ;
2. sélectionner ou refuser les actions pour former le `ScopeGrant` ;
3. admettre une campagne pour les seules actions accordées ;
4. préparer et approuver séparément une seconde vague éventuelle.
L'action refusée reste refusée ; elle ne devient pas admissible au lancement
d'une autre action. La révocation du grant est une mutation explicite et
durable. Une révision de sélection périmée, un identifiant étranger ou une
forme de requête invalide sont refusés.
Les sessions, cookies et CSRF restent des contrôles de la surface Web et sont
distincts des contrats ci-dessus. Les validations couvrent l'isolation de deux
espaces de travail synthétiques A/B : un plan, grant, campagne ou résultat de
l'espace A n'est pas visible ni réutilisable dans l'espace B.
## 7. Procédure de validation autorisée
Les seuls parcours documentés ici utilisent les harnesses synthétiques du
dépôt et leurs répertoires temporaires. Les contrôles Web dédiés sont :
```sh
python3 prototypes/web-graph/tests/test_research_web.py
node prototypes/web-graph/tests/test_research_browser.mjs
```
Ils démarrent leur fournisseur `SPECIMEN` de loopback à durée de vie bornée et
ne nécessitent ni endpoint public, ni jeton, ni enquête réelle. Les tests C
`test_research_store`, `test_research_policy`, `test_research_transport` et
`test_research_adapters` couvrent respectivement la migration/persistance, les
décisions de policy, les bornes du transport/libcurl et les normalisations de
fixtures. Leur exécution relève du harness de validation de la tranche ; elle
ne constitue pas une autorisation de contacter des services externes.
## 8. Ce qui reste TARGET ou non qualifié
Restent hors de ce livrable : qualification et configuration d'un fournisseur
public, gestion opérationnelle de secrets, exécution de recherche hors
laboratoire, propagation automatique de scope, ordonnancement autonome de
recherche, ainsi que le serveur, le protocole d'événements, le renderer et le
packaging Web de production. Le Web local ne change pas le statut de GTK comme
parcours fonctionnel existant.

21
docs/ui/WEB_GTK_PARITY.md Normal file
View file

@ -0,0 +1,21 @@
# Matrice de parité Web avant retrait GTK
> État : `CURRENT` local — `LABFY_WEB_FINAL_INTERFACE_AND_REMOVE_GTK_V1`.
> Les contrôles utilisent exclusivement les workspaces `SPECIMEN`.
| Fonction GTK historique | Équivalent Web CURRENT | Test Web | État |
| --- | --- | --- | --- |
| Graphe, sélection, pan, zoom, focus et retour | SVG interactif central, recherche et actions contextuelles | `test_workbench_browser.mjs` | remplacé |
| Positions et zoom persistés | Projection C et positions épinglées conservées au rafraîchissement | `test_workbench_populated_browser.mjs` | remplacé |
| Arbre / détails d'entité, preuve et relation | Drawer Détails et inspecteur à onglets | `test_local_workspace_import_browser.mjs` | remplacé |
| Provenance et revue | Onglets Provenance et Observations, promotion explicite | `test_j9_browser.mjs` | remplacé |
| Activité, tâches et annulation | Drawer Tâches, JobStore V4 et contrôles de file | `test_j8_runtime_browser.mjs` | remplacé |
| Actions OSINT contextuelles | Capability → service → job, plan/grant/campagne explicites | `test_research_browser.mjs` | remplacé |
| Import, aperçu et analyse de preuve | Réception privée, preview dérivé, analyses C | `test_local_workspace_import_browser.mjs` | remplacé |
| Rapports | Prévisualisation et dossier hors ligne vérifiable | `test_j9_browser.mjs` | remplacé |
| Dialogues de création GTK et formulaires spécialisés | Parcours non exposés par le bridge Web actuel | aucun | retiré, pas de logique métier supprimée |
| Widgets OCR de rendu GTK | Données, correction et provenance conservées dans services/DAO ; renderer retiré | tests C de services conservés | retiré, renderer seulement |
Les parcours retirés ne constituaient pas une API métier : ils dépendaient du
renderer GTK. Les DAO, services, migrations V20, fondation financière V21 et
JobStore V4 restent hors de cette suppression.

View file

@ -1,20 +1,23 @@
# Poste Web local — guide d’utilisation
> **Statut CURRENT local :** poste utilisable au-dessus des services C et du
> JobStore V3 existants, depuis une bibliothèque d'enquêtes explicitement
> **Statut CURRENT local :** interface utilisateur Web unique, utilisable au-dessus des services C et du
> JobStore V4 existants, depuis une bibliothèque d'enquêtes explicitement
> choisie. Les validations emploient des fixtures `SPECIMEN`. Il ne constitue
> ni une autorisation d’ouvrir une enquête réelle, ni un service distant.
## Organisation
À l'ouverture, la bibliothèque affiche les enquêtes locales enregistrées et
À l'ouverture, le lanceur `tools/labfy` démarre ou rejoint l’instance loopback
possédée, ouvre le navigateur et établit une session HttpOnly locale sans code
manuel. La bibliothèque affiche les enquêtes locales enregistrées et
permet d'en créer une avec un titre explicite. Une enquête ouverte reste la
seule enquête active de cette instance ; le poste n'en restaure pas une autre
automatiquement. Le titre et la génération de la bibliothèque identifient le
contexte affiché, afin qu'une réponse tardive d'une enquête précédente ne le
repeuple pas.
Le graphe reste la surface principale. La barre haute porte l’identité de
Le graphe reste la surface principale. La coque est **Agent | Graphe |
Activité**, plus un tiroir transversal. La barre haute porte l’identité de
l’enquête, le statut local et la recherche. La rangée suivante change de
projection (`Réseau`, `Preuves`, `Chronologie`, `Infrastructure locale`) sans
créer une seconde vérité métier.
@ -93,10 +96,10 @@ Une date sans fuseau ou invalide reste non positionnable.
Lancer le poste local sur une bibliothèque explicitement choisie :
```bash
make web PORT=8081 LIBRARY=/chemin/vers/la-bibliotheque-locale
LABFY_LIBRARY=/chemin/vers/la-bibliotheque-locale tools/labfy
```
L’écoute reste loopback, la session est authentifiée et les mutations exigent
L’écoute reste loopback, la session est HttpOnly/SameSite et les mutations exigent
le jeton CSRF. Le poste n’expose pas le répertoire des preuves et n’installe
aucun outil. Les projections vides, erreurs, annulations et limites sont des
états explicites ; elles ne prouvent jamais une absence d’information.

View file

@ -1,69 +0,0 @@
/******************************************************************************
* @file application.h
* @brief Interface publique du cycle de vie de l'application.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_APPLICATION_H
#define LABFY_INVESTIGATION_APPLICATION_H
/**
* @brief Représentation opaque d'une session d'enquête.
*/
typedef struct InvestigationSession InvestigationSession;
/**
* @brief Représentation opaque de l'application.
*
* Les détails GTK sont volontairement masqués au reste du programme.
*/
typedef struct Application Application;
/**
* @brief Crée et initialise une application Labfy Investigation.
*
* @return Une nouvelle application, ou NULL en cas d'échec.
*/
Application *application_new(void);
/**
* @brief Lance la boucle principale de l'application.
*
* @param application Application à exécuter.
* @param argc Nombre d'arguments de la ligne de commande.
* @param argv Tableau des arguments de la ligne de commande.
*
* @return Code de sortie retourné par GTK.
*/
int application_run(
Application *application,
int argc,
char **argv
);
/**
* @brief Retourne la session d'enquête actuellement ouverte.
*
* Le pointeur retourné appartient à l'application.
* Le code appelant ne doit pas fermer cette session.
*
* @param application Application concernée.
*
* @return La session active, ou NULL si aucune enquête n'est ouverte
* ou si application vaut NULL.
*/
const InvestigationSession *application_get_session(
const Application *application
);
/**
* @brief Libère les ressources possédées par l'application.
*
* Cette fonction accepte NULL.
*
* @param application Application à libérer.
*/
void application_free(
Application *application
);
#endif

View file

@ -1,6 +1,6 @@
/******************************************************************************
* @file local_job_store.h
* @brief Stockage opérationnel V3 des jobs et plans locaux J5/J8.
* @brief Stockage opérationnel V4 des jobs, plans locaux et grants assistés.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_LOCAL_JOB_STORE_H
#define LABFY_INVESTIGATION_LOCAL_JOB_STORE_H
@ -9,7 +9,7 @@
G_BEGIN_DECLS
#define LOCAL_JOB_STORE_SCHEMA_VERSION 3
#define LOCAL_JOB_STORE_SCHEMA_VERSION 4
#define LOCAL_JOB_STORE_MAX_JOBS 128U
#define LOCAL_JOB_STORE_MAX_ATTEMPTS 3U
#define LOCAL_JOB_DIAGNOSTIC_MAX 2048U

View file

@ -0,0 +1,14 @@
#ifndef LABFY_INVESTIGATION_RESEARCH_ADAPTER_CDX_H
#define LABFY_INVESTIGATION_RESEARCH_ADAPTER_CDX_H
#include "core/research_contracts.h"
G_BEGIN_DECLS
typedef enum { RESEARCH_CDX_EXACT_URL, RESEARCH_CDX_EXACT_HOST } ResearchCdxMatch;
typedef struct { const char *original_url; const char *timestamp; const char *digest;
guint status_code; } ResearchCdxRow;
typedef struct { const ResearchCdxRow *rows; gsize row_count; const char *next_cursor; } ResearchCdxFixture;
typedef struct { GPtrArray *captures; char *next_cursor; gboolean next_piste; } ResearchCdxResult;
gboolean research_adapter_cdx_normalize(const ResearchAction *action,
ResearchCdxMatch match,const ResearchCdxFixture *fixture,ResearchCdxResult *out,GError **error);
void research_cdx_result_clear(ResearchCdxResult *result);
G_END_DECLS
#endif

View file

@ -0,0 +1,23 @@
#ifndef LABFY_INVESTIGATION_RESEARCH_ADAPTER_DNS_H
#define LABFY_INVESTIGATION_RESEARCH_ADAPTER_DNS_H
#include "core/research_contracts.h"
G_BEGIN_DECLS
typedef enum { RESEARCH_DNS_A, RESEARCH_DNS_AAAA, RESEARCH_DNS_CNAME,
RESEARCH_DNS_MX, RESEARCH_DNS_NS, RESEARCH_DNS_TXT, RESEARCH_DNS_SOA,
RESEARCH_DNS_PTR } ResearchDnsType;
typedef enum { RESEARCH_DNS_STATUS_OK, RESEARCH_DNS_STATUS_NXDOMAIN,
RESEARCH_DNS_STATUS_NODATA, RESEARCH_DNS_STATUS_SERVFAIL,
RESEARCH_DNS_STATUS_TIMEOUT } ResearchDnsStatus;
typedef struct { ResearchDnsType type; const char *owner; const char *value;
guint ttl; guint preference; } ResearchDnsRecord;
typedef struct { ResearchDnsStatus status; const ResearchDnsRecord *records;
gsize record_count; } ResearchDnsFixture;
typedef struct { ResearchDnsStatus status; GPtrArray *records;
gboolean cname_cycle; gboolean next_piste; } ResearchDnsResult;
/** CONTRACT: ANY, AXFR et trace n'existent pas dans le type public. */
gboolean research_adapter_dns_normalize(const ResearchAction *action,
ResearchDnsType requested_type, const ResearchDnsFixture *fixture,
ResearchDnsResult *out, GError **error);
void research_dns_result_clear(ResearchDnsResult *result);
G_END_DECLS
#endif

View file

@ -0,0 +1,13 @@
#ifndef LABFY_INVESTIGATION_RESEARCH_ADAPTER_PAGE_H
#define LABFY_INVESTIGATION_RESEARCH_ADAPTER_PAGE_H
#include "core/research_contracts.h"
G_BEGIN_DECLS
typedef struct { const char *content_type; const guint8 *body; gsize body_size; } ResearchPageFixture;
typedef struct { char *inert_text; gboolean links_ignored; gboolean attachments_ignored;
gboolean next_piste; } ResearchPageResult;
/** CONTRACT: produit du texte inerte; aucun script, lien, sous-ressource ou attachment n'est suivi. */
gboolean research_adapter_page_normalize(const ResearchAction *action,
const ResearchPageFixture *fixture,gsize max_text_bytes,ResearchPageResult *out,GError **error);
void research_page_result_clear(ResearchPageResult *result);
G_END_DECLS
#endif

View file

@ -0,0 +1,19 @@
#ifndef LABFY_INVESTIGATION_RESEARCH_ADAPTER_RDAP_H
#define LABFY_INVESTIGATION_RESEARCH_ADAPTER_RDAP_H
#include "core/research_contracts.h"
G_BEGIN_DECLS
typedef enum { RESEARCH_RDAP_DOMAIN_SERVICE, RESEARCH_RDAP_IPV4_SERVICE,
RESEARCH_RDAP_IPV6_SERVICE } ResearchRdapServiceKind;
typedef struct { ResearchRdapServiceKind kind; const char *prefix;
const char *base_url; } ResearchRdapBootstrapEntry;
typedef struct { const ResearchRdapBootstrapEntry *entries; gsize entry_count;
const char *fetched_at; const char *expires_at; const char *etag; } ResearchRdapBootstrap;
typedef struct { char *service_url; guint prefix_length; char *cache_fetched_at;
char *cache_expires_at; char *cache_etag; gboolean next_piste; } ResearchRdapResult;
/** WHY: le bootstrap routé ne vaut jamais autorisation sur l'objet retourné. */
gboolean research_adapter_rdap_select(const ResearchAction *action,
ResearchRdapServiceKind kind, const ResearchRdapBootstrap *bootstrap,
ResearchRdapResult *out, GError **error);
void research_rdap_result_clear(ResearchRdapResult *result);
G_END_DECLS
#endif

View file

@ -0,0 +1,19 @@
#ifndef LABFY_INVESTIGATION_RESEARCH_ADAPTER_SEARCH_H
#define LABFY_INVESTIGATION_RESEARCH_ADAPTER_SEARCH_H
#include "core/research_contracts.h"
G_BEGIN_DECLS
typedef enum { RESEARCH_SEARCH_BRAVE, RESEARCH_SEARCH_SEARXNG } ResearchSearchProvider;
typedef enum { RESEARCH_SEARCH_AVAILABLE, RESEARCH_SEARCH_UNAVAILABLE_CONFIG,
RESEARCH_SEARCH_UNAVAILABLE_STORAGE_AUTHORIZATION } ResearchSearchAvailability;
typedef struct { ResearchSearchProvider provider; const char *endpoint;
const char *secret_reference; gboolean storage_authorized; } ResearchSearchConfig;
typedef struct { const char *title; const char *url; const char *snippet; } ResearchSearchHit;
typedef struct { ResearchSearchAvailability availability; GPtrArray *hits;
gboolean next_piste; } ResearchSearchResult;
ResearchSearchAvailability research_adapter_search_availability(const ResearchSearchConfig *config);
gboolean research_adapter_search_normalize(const ResearchAction *action,
const ResearchSearchConfig *config,const ResearchSearchHit *hits,gsize hit_count,
ResearchSearchResult *out,GError **error);
void research_search_result_clear(ResearchSearchResult *result);
G_END_DECLS
#endif

View file

@ -0,0 +1,172 @@
/******************************************************************************
* @file research_contracts.h
* @brief Contrats versionnés de recherche assistée, sans exécution réseau.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_RESEARCH_CONTRACTS_H
#define LABFY_INVESTIGATION_RESEARCH_CONTRACTS_H
#include <gio/gio.h>
G_BEGIN_DECLS
#define RESEARCH_PLAN_CONTRACT "labfy.research_plan.v1"
#define RESEARCH_GRANT_CONTRACT "labfy.scope_grant.v1"
#define RESEARCH_CAMPAIGN_CONTRACT "labfy.research_campaign.v1"
#define RESEARCH_RESULT_CONTRACT "labfy.research_result.v1"
#define RESEARCH_RECEIPT_CONTRACT "labfy.research_receipt.v1"
typedef enum {
RESEARCH_SEED_DOMAIN,
RESEARCH_SEED_IP,
RESEARCH_SEED_HTTP_URL,
RESEARCH_SEED_EMAIL,
RESEARCH_SEED_SEARCH_TERM
} ResearchSeedKind;
typedef enum {
RESEARCH_CONTACT_NONE,
RESEARCH_CONTACT_THIRD_PARTY,
RESEARCH_CONTACT_TARGET
} ResearchContactClass;
typedef struct {
ResearchSeedKind kind;
const char *subject;
} ResearchSeed;
typedef struct {
const char *action_id;
const char *capability_id;
const char *provider_id;
const char *endpoint;
const char *subject;
ResearchContactClass contact;
const char *disclosure;
guint max_requests;
guint64 max_response_bytes;
guint max_active_ms;
} ResearchAction;
typedef struct {
const char *contract;
const char *plan_id;
const char *idempotency_key;
const char *content_fingerprint;
const char *input_fingerprint;
guint64 input_revision;
const char *created_at;
const ResearchSeed *seeds;
gsize seed_count;
const ResearchAction *actions;
gsize action_count;
} ResearchPlan;
typedef enum {
RESEARCH_ACTION_AUTHORIZE,
RESEARCH_ACTION_DEFER,
RESEARCH_ACTION_REFUSE
} ResearchActionDecisionCode;
typedef struct {
const char *action_id;
ResearchActionDecisionCode code;
} ResearchActionDecision;
typedef struct {
const char *contract;
const char *grant_id;
const char *investigation_id;
const char *idempotency_key;
const char *content_fingerprint;
const char *plan_fingerprint;
const char *created_at;
const char *expires_at;
const char *const *selected_action_ids;
gsize selected_action_count;
const char *const *excluded_subjects;
gsize excluded_subject_count;
guint max_requests;
guint64 max_response_bytes;
guint max_active_ms;
} ScopeGrant;
typedef struct {
const char *contract;
const char *campaign_id;
const char *investigation_id;
const char *grant_id;
const char *idempotency_key;
const char *content_fingerprint;
const char *created_at;
} ResearchCampaign;
typedef struct {
const char *contract;
const char *result_id;
const char *campaign_id;
const char *action_id;
const char *raw_artifact_relative_path;
const char *content_sha256;
const char *status;
} ResearchResult;
typedef struct {
const char *contract;
const char *receipt_id;
const char *result_id;
const char *campaign_id;
const char *grant_id;
const char *action_id;
const char *decided_at;
const char *decision_code;
} ResearchReceipt;
typedef enum {
RESEARCH_POLICY_ALLOW,
RESEARCH_POLICY_DENY_EXCLUDED,
RESEARCH_POLICY_DENY_REVOKED,
RESEARCH_POLICY_DENY_EXPIRED,
RESEARCH_POLICY_DENY_INVESTIGATION,
RESEARCH_POLICY_DENY_PLAN,
RESEARCH_POLICY_DENY_ACTION,
RESEARCH_POLICY_DENY_ACTION_CONTENT,
RESEARCH_POLICY_DENY_BUDGET
} ResearchPolicyDecision;
typedef struct {
const char *investigation_id;
const char *plan_fingerprint;
const ResearchAction *action;
const char *now;
guint requested_requests;
guint64 requested_response_bytes;
guint requested_active_ms;
} ResearchPolicyRequest;
/**
* Vérifie la forme complète d'un plan avant toute écriture.
* CONTRACT: les identités et empreintes sont stables et toutes les actions
* sont bornées. INVARIANT: une seed dérivée n'accorde jamais de scope.
*/
gboolean research_plan_validate(const ResearchPlan *plan, GError **error);
/**
* Vérifie le contenu durable d'un grant, à l'exclusion des cookies/CSRF qui
* n'appartiennent pas à ce contrat persistant.
*/
gboolean research_scope_grant_validate(const ScopeGrant *grant,
GError **error);
gboolean research_campaign_validate(const ResearchCampaign *campaign,
GError **error);
gboolean research_result_validate(const ResearchResult *result,
GError **error);
gboolean research_receipt_validate(const ResearchReceipt *receipt,
GError **error);
const char *research_seed_kind_code(ResearchSeedKind kind);
const char *research_contact_class_code(ResearchContactClass contact);
const char *research_policy_decision_code(ResearchPolicyDecision decision);
const char *research_action_decision_code(ResearchActionDecisionCode decision);
G_END_DECLS
#endif

View file

@ -0,0 +1,28 @@
#ifndef LABFY_INVESTIGATION_RESEARCH_HTTP_TRANSPORT_CURL_H
#define LABFY_INVESTIGATION_RESEARCH_HTTP_TRANSPORT_CURL_H
#include "core/research_transport.h"
G_BEGIN_DECLS
/**
* CONTRACT: exécute une requête préalablement validée, sans état ambiant.
* INVARIANT: le callback socket refuse toute adresse qui n'est pas publique.
*/
gboolean research_http_transport_curl_execute(
const ResearchTransportRequest *request, ResearchTransportReply *out_reply,
GError **error);
#ifdef RESEARCH_TRANSPORT_ENABLE_TEST_HOOKS
/**
* TEST-ONLY: route une autorité exacte vers une adresse loopback finie.
* Cette fonction n'existe pas dans l'API de production.
*/
gboolean research_http_transport_curl_execute_fixture(
const ResearchTransportRequest *request, const char *authority,
const char *loopback_address, ResearchTransportReply *out_reply,
GError **error);
#endif
G_END_DECLS
#endif

View file

@ -0,0 +1,55 @@
/******************************************************************************
* @file research_policy.h
* @brief Policy pure pour les destinations de recherche assistée V1.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_RESEARCH_POLICY_H
#define LABFY_INVESTIGATION_RESEARCH_POLICY_H
#include "core/research_contracts.h"
G_BEGIN_DECLS
typedef struct {
const guint16 *allowed_ports;
gsize allowed_port_count;
const char *const *allowed_endpoints;
gsize allowed_endpoint_count;
} ResearchNetworkProfile;
typedef struct {
char *scheme;
char *host;
guint16 port;
char *path_and_query;
} ResearchHttpTarget;
typedef enum {
RESEARCH_REDIRECT_REQUIRES_DECISION,
RESEARCH_REDIRECT_INVALID
} ResearchRedirectDecision;
/**
* CONTRACT: valide l'action et le grant exacts avant tout contact réseau.
* WHY: le profil décrit une allowlist, jamais une règle par suffixe.
* INVARIANT: sujet, endpoint, port et sélection sont comparés exactement.
*/
gboolean research_policy_validate_http_action(
const ResearchAction *action, const ScopeGrant *grant,
const ResearchNetworkProfile *profile, ResearchHttpTarget *out_target,
GError **error);
/** INVARIANT: seules les adresses globalement routables sont acceptées. */
gboolean research_policy_address_is_public(const char *address);
/**
* CONTRACT: une redirection valide reste une nouvelle destination à décider.
* Cette fonction ne propage jamais l'autorisation de l'action initiale.
*/
ResearchRedirectDecision research_policy_check_redirect(
const char *location, const ResearchNetworkProfile *profile,
ResearchHttpTarget *out_target, GError **error);
void research_http_target_clear(ResearchHttpTarget *target);
G_END_DECLS
#endif

View file

@ -0,0 +1,72 @@
/******************************************************************************
* @file research_store.h
* @brief Persistance V4 des autorisations de recherche assistée.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_RESEARCH_STORE_H
#define LABFY_INVESTIGATION_RESEARCH_STORE_H
#include "core/local_job_store.h"
#include "core/research_contracts.h"
G_BEGIN_DECLS
/**
* Admet atomiquement un plan et ses seeds/actions exactes.
* CONTRACT: même clé et même contenu retourne le même objet durable; une même
* clé avec un contenu différent est un conflit.
* INVARIANT: aucune action invalide ne laisse un plan partiellement écrit.
*/
gboolean research_store_admit_plan(LocalJobStore *store,
const ResearchPlan *plan,
gboolean *out_reused, GError **error);
/**
* Admet un grant lié au plan et matérialise les sujets/provider/endpoints
* autorisés au moment de la décision. Toutes les décisions humaines sont
* persistées dans la même transaction. REFUSE est terminal et rend l'action
* durablement inadmissible; la vague 2 exige une vague 1 autorisée, exécutée
* et documentée par son résultat et son reçu.
*/
gboolean research_store_admit_grant(LocalJobStore *store,
const ScopeGrant *grant,
const ResearchActionDecision *decisions,
gsize decision_count,
const char *decided_at,
gboolean *out_reused, GError **error);
/** Révoque durablement un grant sans supprimer son historique. */
gboolean research_store_revoke_grant(LocalJobStore *store,
const char *grant_id,
const char *revoked_at,
GError **error);
/** Admet l'identité durable d'une campagne; ne démarre aucun travail. */
gboolean research_store_admit_campaign(LocalJobStore *store,
const ResearchCampaign *campaign,
gboolean *out_reused, GError **error);
/**
* Enregistre atomiquement le résultat brut et le reçu de policy correspondant.
* INVARIANT: le reçu doit viser une action explicitement sélectionnée du grant
* de la campagne; aucun résultat orphelin n'est publié.
*/
gboolean research_store_record_result(LocalJobStore *store,
const ResearchResult *result,
const ResearchReceipt *receipt,
GError **error);
/**
* Évalue un usage exact du grant sans créer de job ni réserver de budget.
* WHY: l'unique worker existant reste le seul ordonnanceur; cette fonction
* rend la décision de policy observable avant toute admission ultérieure.
* INVARIANT: exclusion, révocation et expiration sont des refus explicites;
* redirection, CNAME ou nouvelle URL ne correspondent jamais par construction.
*/
gboolean research_store_policy_decide(LocalJobStore *store,
const char *grant_id,
const ResearchPolicyRequest *request,
ResearchPolicyDecision *out_decision,
GError **error);
G_END_DECLS
#endif

View file

@ -0,0 +1,94 @@
/******************************************************************************
* @file research_transport.h
* @brief Transport borné et sans autorité implicite pour la recherche V1.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_RESEARCH_TRANSPORT_H
#define LABFY_INVESTIGATION_RESEARCH_TRANSPORT_H
#include "core/research_policy.h"
G_BEGIN_DECLS
typedef enum {
RESEARCH_TRANSPORT_OK,
RESEARCH_TRANSPORT_UNAVAILABLE,
RESEARCH_TRANSPORT_CANCELLED,
RESEARCH_TRANSPORT_TIMEOUT,
RESEARCH_TRANSPORT_RATE_LIMITED,
RESEARCH_TRANSPORT_FORBIDDEN,
RESEARCH_TRANSPORT_REDIRECT,
RESEARCH_TRANSPORT_TOO_LARGE,
RESEARCH_TRANSPORT_INVALID_RESPONSE,
RESEARCH_TRANSPORT_FAILED
} ResearchTransportState;
typedef struct {
const char *name;
const char *value;
} ResearchHeader;
typedef struct {
const ResearchAction *action;
const ScopeGrant *grant;
const ResearchNetworkProfile *profile;
const char *method;
const ResearchHeader *headers;
gsize header_count;
guint64 max_compressed_bytes;
guint64 max_decompressed_bytes;
guint64 max_header_bytes;
GCancellable *cancellable;
} ResearchTransportRequest;
typedef struct {
ResearchTransportState state;
guint status_code;
guint retry_after_seconds;
gboolean retry_after_present;
gboolean body_is_compressed;
GBytes *body;
GHashTable *headers;
char *redirect_location;
char *diagnostic;
} ResearchTransportReply;
/**
* CONTRACT: la production n'utilise ni proxy, cookies, netrc, auth ambiante,
* redirection automatique, ni résolution fournie par l'appelant.
* CURRENT: aucun backend réseau n'est lié; retourne UNAVAILABLE sans contact.
*/
gboolean research_transport_execute(const ResearchTransportRequest *request,
ResearchTransportReply *out_reply,
GError **error);
gboolean research_transport_request_validate(
const ResearchTransportRequest *request, ResearchHttpTarget *out_target,
GError **error);
void research_transport_reply_clear(ResearchTransportReply *reply);
const char *research_transport_state_code(ResearchTransportState state);
#ifdef RESEARCH_TRANSPORT_ENABLE_TEST_HOOKS
typedef struct {
guint status_code;
const ResearchHeader *headers;
gsize header_count;
const guint8 *body;
gsize body_size;
gboolean compressed;
guint64 decompressed_size;
const char *peer_address;
const char *redirect_location;
gboolean timed_out;
gboolean unavailable;
} ResearchTransportFixture;
/** TEST-ONLY: réponse finie injectée, sans socket ni résolution réseau. */
gboolean research_transport_execute_fixture(
const ResearchTransportRequest *request,
const ResearchTransportFixture *fixture,
const char *const *loopback_authorities, gsize authority_count,
ResearchTransportReply *out_reply, GError **error);
#endif
G_END_DECLS
#endif

View file

@ -1,136 +0,0 @@
/******************************************************************************
* @file application_message_dialog.h
* @brief Fenêtre modale affichant un message à l'utilisateur.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_APPLICATION_MESSAGE_DIALOG_H
#define LABFY_INVESTIGATION_APPLICATION_MESSAGE_DIALOG_H
#include <gtk/gtk.h>
/**
* @brief Nature du message affiché.
*/
typedef enum
{
APPLICATION_MESSAGE_DIALOG_ERROR,
APPLICATION_MESSAGE_DIALOG_WARNING,
APPLICATION_MESSAGE_DIALOG_INFORMATION
} ApplicationMessageDialogType;
/**
* @brief Callback appelé à la fermeture d'un dialogue de confirmation.
*
* Le callback est appelé une seule fois après un clic sur l'un des boutons
* ou après une fermeture manuelle de la fenêtre.
*
* @param confirmed TRUE si l'utilisateur confirme, sinon FALSE.
* @param user_data Données empruntées fournies par l'appelant.
*/
typedef void (*ApplicationMessageDialogConfirmationCallback)(
gboolean confirmed,
gpointer user_data
);
/**
* @brief Callback appelé par l'action facultative d'un dialogue détaillé.
*
* @param user_data Données associées à l'action.
*/
typedef void (*ApplicationMessageDialogActionCallback)(
gpointer user_data
);
/**
* @brief Affiche une fenêtre modale contenant un message.
*
* Les chaînes sont copiées par les widgets GTK.
*
* @param parent_window Fenêtre parente, ou NULL.
* @param message_type Type de message.
* @param title Titre de la fenêtre, ou NULL.
* @param message Message principal, ou NULL.
*/
void application_message_dialog_present(
GtkWindow *parent_window,
ApplicationMessageDialogType message_type,
const char *title,
const char *message
);
/**
* @brief Affiche un message accompagné d'un contenu textuel défilable.
*
* Le contenu détaillé est affiché dans une zone monospace sélectionnable.
* Les chaînes sont copiées par les widgets GTK.
*
* @param parent_window Fenêtre parente, ou NULL.
* @param message_type Type de message.
* @param title Titre de la fenêtre, ou NULL.
* @param message Message principal, ou NULL.
* @param details Contenu détaillé, ou NULL.
*/
void application_message_dialog_present_details(
GtkWindow *parent_window,
ApplicationMessageDialogType message_type,
const char *title,
const char *message,
const char *details
);
/**
* @brief Affiche un message détaillé avec une action facultative.
*
* Le dialogue devient propriétaire de action_data lorsque l'affichage est
* créé. Les données sont libérées avec action_data_destroy à sa fermeture.
*
* @param parent_window Fenêtre parente, ou NULL.
* @param message_type Type de message.
* @param title Titre de la fenêtre, ou NULL.
* @param message Message principal, ou NULL.
* @param details Contenu détaillé, ou NULL.
* @param action_label Libellé de l'action, ou NULL.
* @param action_callback Callback de l'action, ou NULL.
* @param action_data Données transmises au callback.
* @param action_data_destroy Destructeur des données, ou NULL.
*/
void application_message_dialog_present_details_action(
GtkWindow *parent_window,
ApplicationMessageDialogType message_type,
const char *title,
const char *message,
const char *details,
const char *action_label,
ApplicationMessageDialogActionCallback action_callback,
gpointer action_data,
GDestroyNotify action_data_destroy
);
/**
* @brief Affiche un dialogue modal demandant une confirmation.
*
* Le bouton d'annulation transmet FALSE. Le bouton de confirmation transmet
* TRUE. Fermer la fenêtre manuellement équivaut à une annulation.
*
* callback et user_data sont empruntés jusqu'à la fermeture du dialogue.
* confirm_label peut être NULL ou vide pour utiliser « Confirmer ».
*
* @param parent_window Fenêtre parente, ou NULL.
* @param message_type Type de message.
* @param title Titre de la fenêtre, ou NULL.
* @param message Message principal, ou NULL.
* @param confirm_label Libellé du bouton de confirmation, ou NULL.
* @param callback Callback facultatif recevant la réponse.
* @param user_data Données empruntées transmises au callback.
*/
void application_message_dialog_present_confirmation(
GtkWindow *parent_window,
ApplicationMessageDialogType message_type,
const char *title,
const char *message,
const char *confirm_label,
ApplicationMessageDialogConfirmationCallback callback,
gpointer user_data
);
#endif

View file

@ -1,58 +0,0 @@
/******************************************************************************
* @file create_investigation_dialog.h
* @brief Interface publique du dialogue de création d'une enquête.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_CREATE_INVESTIGATION_DIALOG_H
#define LABFY_INVESTIGATION_CREATE_INVESTIGATION_DIALOG_H
#include <gtk/gtk.h>
/**
* @brief Callback appelé lorsque le dialogue est validé ou annulé.
*
* Lors d'une validation réussie :
*
* - parent_directory contient le dossier parent sélectionné ;
* - investigation_name contient le nom nettoyé de l'enquête.
*
* Lors d'une annulation :
*
* - parent_directory vaut NULL ;
* - investigation_name vaut NULL.
*
* Les chaînes transmises restent valides uniquement pendant l'appel du
* callback. Le code appelant doit les copier s'il souhaite les conserver.
*
* @param parent_directory Dossier parent sélectionné, ou NULL.
* @param investigation_name Nom de l'enquête, ou NULL.
* @param user_data Données utilisateur fournies lors de l'ouverture.
*/
typedef void (*CreateInvestigationDialogCallback)(
const char *parent_directory,
const char *investigation_name,
gpointer user_data
);
/**
* @brief Présente le dialogue de création d'une nouvelle enquête.
*
* Le dialogue permet :
*
* - de sélectionner un dossier parent existant ;
* - de saisir le nom de l'enquête ;
* - de valider ou d'annuler la création.
*
* Cette fonction ne crée aucun fichier et n'accède pas à SQLite.
*
* @param parent_window Fenêtre GTK parente, ou NULL.
* @param callback Fonction appelée lors de la validation ou de l'annulation.
* @param user_data Données transmises au callback.
*/
void create_investigation_dialog_present(
GtkWindow *parent_window,
CreateInvestigationDialogCallback callback,
gpointer user_data
);
#endif

View file

@ -1,54 +0,0 @@
/******************************************************************************
* @file create_person_dialog.h
* @brief Formulaire GTK de création d'une personne observée.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_CREATE_PERSON_DIALOG_H
#define LABFY_INVESTIGATION_CREATE_PERSON_DIALOG_H
#include "core/person_entity_service.h"
#include "core/evidence_staging.h"
#include "models/evidence_record.h"
#include "models/person_evidence_selection.h"
#include "core/task_manager.h"
#include "core/tool_registry.h"
#include <gtk/gtk.h>
G_BEGIN_DECLS
/** @brief Résultat opaque possédé par le callback. */
typedef struct CreatePersonDialogResult CreatePersonDialogResult;
/** @brief Callback appelé après validation ou annulation. */
typedef void (*CreatePersonDialogCallback)(CreatePersonDialogResult *result,
gpointer user_data);
typedef gboolean (*CreatePersonDialogSessionCheck)(gpointer user_data);
/**
* @brief Présente le formulaire de création d'une personne.
* @param parent Fenêtre parente.
* @param evidence_records Preuves disponibles empruntées.
* @param callback Callback de fin.
* @param user_data Données privées du callback.
* @return TRUE si le dialogue a été présenté.
*/
gboolean create_person_dialog_present(GtkWindow *parent,
Database *database, const GPtrArray *evidence_records,
const char *investigation_root_path,
TaskManager *task_manager, const ToolInfo *tesseract_tool,
CreatePersonDialogSessionCheck session_check,
CreatePersonDialogCallback callback, gpointer user_data,
GDestroyNotify user_data_destroy);
/** @brief Libère un résultat. */
void create_person_dialog_result_free(CreatePersonDialogResult *result);
/** @brief Retourne les données empruntées du résultat. */
const PersonEntityInput *create_person_dialog_result_get_input(
const CreatePersonDialogResult *result);
const PersonEvidenceSelection *create_person_dialog_result_get_evidence_selection(
const CreatePersonDialogResult *result);
EvidenceStaging *create_person_dialog_result_steal_staging(
CreatePersonDialogResult *result);
const GPtrArray *create_person_dialog_result_get_ocr_runs(
const CreatePersonDialogResult *result);
const GPtrArray *create_person_dialog_result_get_factual_relations(
const CreatePersonDialogResult *result);
const GPtrArray *create_person_dialog_result_get_ocr_projections(
const CreatePersonDialogResult *result);
gboolean create_person_dialog_test_overlay_has_region(GtkWindow *window);
guint64 create_person_dialog_test_ocr_generation(GtkWindow *window);
G_END_DECLS
#endif

View file

@ -1,211 +0,0 @@
/******************************************************************************
* @file create_relation_dialog.h
* @brief Dialogue GTK de préparation d'une relation entre deux entités.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_CREATE_RELATION_DIALOG_H
#define LABFY_INVESTIGATION_CREATE_RELATION_DIALOG_H
#include <gtk/gtk.h>
G_BEGIN_DECLS
/**
* @brief Résultat opaque produit par le dialogue.
*/
typedef struct CreateRelationDialogResult CreateRelationDialogResult;
typedef struct RelationRecord RelationRecord;
/**
* @brief Codes d'erreur produits par CreateRelationDialog.
*/
typedef enum
{
CREATE_RELATION_DIALOG_ERROR_INVALID_ARGUMENT,
CREATE_RELATION_DIALOG_ERROR_EMPTY_TARGETS,
CREATE_RELATION_DIALOG_ERROR_MEMORY,
CREATE_RELATION_DIALOG_ERROR_INVALID_DATA
} CreateRelationDialogError;
/**
* @brief Domaine d'erreur du dialogue.
*/
#define CREATE_RELATION_DIALOG_ERROR \
create_relation_dialog_error_quark()
/**
* @brief Callback appelé après validation ou annulation.
*
* En cas de validation, result appartient au callback et doit être libéré
* avec create_relation_dialog_result_free().
*
* En cas d'annulation, result vaut NULL.
*
* @param result Résultat possédé par le callback, ou NULL.
* @param user_data Données privées fournies lors de l'ouverture.
*/
typedef void (*CreateRelationDialogCallback)(
CreateRelationDialogResult *result,
gpointer user_data
);
/**
* @brief Retourne le domaine d'erreur du dialogue.
*/
GQuark create_relation_dialog_error_quark(void);
/**
* @brief Présente le dialogue de création d'une relation.
*
* entities doit contenir des pointeurs EntityRecord valides. Le dialogue
* copie tous les identifiants et libellés nécessaires et ne conserve aucun
* pointeur vers les modèles fournis.
*
* L'entité source est retirée de la liste des cibles.
*
* Cette fonction ne crée aucune relation et n'accède pas à SQLite.
*
* @param parent Fenêtre GTK parente.
* @param source_entity_identifier UUID de l'entité source.
* @param entities Tableau de EntityRecord.
* @param evidence_records Tableau de EvidenceRecord disponibles.
* @param investigation_root_path Racine canonique de l'enquête.
* @param callback Fonction appelée après validation ou annulation.
* @param user_data Données privées transmises au callback.
* @param error Emplacement facultatif recevant une erreur immédiate.
*
* @return TRUE si le dialogue a été créé et présenté.
*/
gboolean create_relation_dialog_present(
GtkWindow *parent,
const char *source_entity_identifier,
const GPtrArray *entities,
const GPtrArray *relation_types,
const GPtrArray *evidence_records,
const char *investigation_root_path,
CreateRelationDialogCallback callback,
gpointer user_data,
GError **error
);
/**
* @brief Présente le dialogue prérempli pour modifier une relation.
*
* La source et la cible sont conservées ; les champs métier sont modifiables.
* Le dialogue ne réalise aucune écriture SQLite.
*
* @param parent Fenêtre GTK parente.
* @param relation_record Relation existante à modifier.
* @param entities Tableau de EntityRecord.
* @param evidence_records Tableau de EvidenceRecord disponibles.
* @param selected_evidence_identifiers UUID des preuves déjà liées.
* @param investigation_root_path Racine canonique de l'enquête.
* @param callback Fonction appelée après validation ou annulation.
* @param user_data Données privées transmises au callback.
* @param error Emplacement facultatif recevant une erreur immédiate.
*
* @return TRUE si le dialogue a été créé et présenté.
*/
gboolean edit_relation_dialog_present(
GtkWindow *parent,
const RelationRecord *relation_record,
const GPtrArray *entities,
const GPtrArray *relation_types,
const GPtrArray *evidence_records,
const GPtrArray *selected_evidence_identifiers,
const char *investigation_root_path,
CreateRelationDialogCallback callback,
gpointer user_data,
GError **error
);
/**
* @brief Valide les valeurs obligatoires d'une relation.
*
* Cette fonction est indépendante de l'affichage et pourra être testée
* sans ouvrir de fenêtre GTK.
*
* @param source_entity_identifier UUID de l'entité source.
* @param target_entity_identifier UUID de l'entité cible.
* @param relation_type Type de relation non vide.
* @param confidence Confiance comprise entre 0 et 100.
* @param error Emplacement facultatif recevant une erreur.
*
* @return TRUE lorsque les valeurs sont valides.
*/
gboolean create_relation_dialog_validate_values(
const char *source_entity_identifier,
const char *target_entity_identifier,
const char *relation_type,
gint confidence,
GError **error
);
/**
* @brief Libère un résultat.
*
* Cette fonction accepte NULL.
*/
void create_relation_dialog_result_free(
CreateRelationDialogResult *result
);
/**
* @brief Retourne l'UUID de l'entité source.
*/
const char *create_relation_dialog_result_get_source_identifier(
const CreateRelationDialogResult *result
);
/**
* @brief Retourne l'UUID de l'entité cible.
*/
const char *create_relation_dialog_result_get_target_identifier(
const CreateRelationDialogResult *result
);
/**
* @brief Retourne le type de relation.
*/
const char *create_relation_dialog_result_get_relation_type(
const CreateRelationDialogResult *result
);
gint64 create_relation_dialog_result_get_relation_type_identifier(
const CreateRelationDialogResult *result);
const char *create_relation_dialog_result_get_relation_type_code(
const CreateRelationDialogResult *result);
/**
* @brief Retourne le libellé facultatif.
*/
const char *create_relation_dialog_result_get_label(
const CreateRelationDialogResult *result
);
/**
* @brief Retourne la justification facultative.
*/
const char *create_relation_dialog_result_get_justification(
const CreateRelationDialogResult *result
);
/**
* @brief Retourne le niveau de confiance.
*/
gint create_relation_dialog_result_get_confidence(
const CreateRelationDialogResult *result
);
/**
* @brief Retourne les UUID de preuves sélectionnés.
*
* @param result Résultat validé du dialogue.
*
* @return Tableau emprunté d'UUID, ou NULL.
*/
const GPtrArray *create_relation_dialog_result_get_evidence_identifiers(
const CreateRelationDialogResult *result);
G_END_DECLS
#endif

View file

@ -1,67 +0,0 @@
/******************************************************************************
* @file create_social_account_dialog.h
* @brief Dialogue GTK de création d'un compte social observé.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_CREATE_SOCIAL_ACCOUNT_DIALOG_H
#define LABFY_INVESTIGATION_CREATE_SOCIAL_ACCOUNT_DIALOG_H
#include "models/evidence_record.h"
#include <gtk/gtk.h>
G_BEGIN_DECLS
/** @brief Résultat opaque du dialogue de compte social. */
typedef struct CreateSocialAccountDialogResult CreateSocialAccountDialogResult;
/** @brief Callback appelé après validation ou annulation. */
typedef void (*CreateSocialAccountDialogCallback)(
CreateSocialAccountDialogResult *result,
gpointer user_data
);
/**
* @brief Présente le formulaire de création d'un compte social.
*
* Le tableau de preuves est uniquement emprunté pendant cet appel ; les
* informations nécessaires au formulaire sont copiées.
*
* @param parent Fenêtre parente.
* @param evidence_records Tableau facultatif de EvidenceRecord.
* @param callback Callback de fin.
* @param user_data Données privées du callback.
* @return TRUE si le dialogue a été présenté.
*/
gboolean create_social_account_dialog_present(
GtkWindow *parent,
const GPtrArray *evidence_records,
CreateSocialAccountDialogCallback callback,
gpointer user_data
);
/** @brief Libère un résultat, y compris toutes ses chaînes. */
void create_social_account_dialog_result_free(
CreateSocialAccountDialogResult *result
);
/** @brief Retourne le code de la plateforme sélectionnée. */
const char *create_social_account_dialog_result_get_platform(const CreateSocialAccountDialogResult *result);
/** @brief Retourne l'URL complète du profil. */
const char *create_social_account_dialog_result_get_profile_url(const CreateSocialAccountDialogResult *result);
/** @brief Retourne le pseudonyme affiché. */
const char *create_social_account_dialog_result_get_username(const CreateSocialAccountDialogResult *result);
/** @brief Retourne l'identifiant stable facultatif de la plateforme. */
const char *create_social_account_dialog_result_get_platform_identifier(const CreateSocialAccountDialogResult *result);
/** @brief Retourne la date UTC de première observation. */
const char *create_social_account_dialog_result_get_first_observed_at(const CreateSocialAccountDialogResult *result);
/** @brief Retourne l'état observé du compte. */
const char *create_social_account_dialog_result_get_account_state(const CreateSocialAccountDialogResult *result);
/** @brief Retourne les notes factuelles facultatives. */
const char *create_social_account_dialog_result_get_notes(const CreateSocialAccountDialogResult *result);
/** @brief Retourne l'UUID de la preuve associée, ou NULL. */
const char *create_social_account_dialog_result_get_evidence_identifier(const CreateSocialAccountDialogResult *result);
G_END_DECLS
#endif

View file

@ -1,31 +0,0 @@
#ifndef LABFY_DIALOG_GEOMETRY_H
#define LABFY_DIALOG_GEOMETRY_H
#include <gtk/gtk.h>
typedef struct {
int initial_width;
int initial_height;
int minimum_width;
int minimum_height;
} LabfyDialogGeometry;
LabfyDialogGeometry labfy_dialog_geometry_calculate(
int workarea_width, int workarea_height);
void labfy_dialog_apply_standard_geometry(
GtkWindow *window, GtkWindow *parent);
void labfy_dialog_prepare(
GtkWindow *window,
GtkWindow *parent,
gboolean modal,
gboolean destroy_with_parent);
void labfy_dialog_present(GtkWindow *window);
void labfy_paned_apply_initial_ratio(
GtkPaned *paned, double ratio, int minimum_start, int minimum_end);
gboolean labfy_paned_initial_ratio_applied(GtkPaned *paned);
#endif

View file

@ -1,12 +0,0 @@
#ifndef LABFY_DOCUMENT_AUTHENTICITY_EDITOR_H
#define LABFY_DOCUMENT_AUTHENTICITY_EDITOR_H
#include "database/database.h"
#include <gtk/gtk.h>
G_BEGIN_DECLS
typedef struct DocumentAuthenticityEditor DocumentAuthenticityEditor;
DocumentAuthenticityEditor *document_authenticity_editor_new(Database *database,const char *evidence_identifier);
GtkWidget *document_authenticity_editor_get_widget(DocumentAuthenticityEditor *editor);
gboolean document_authenticity_editor_refresh(DocumentAuthenticityEditor *editor,GError **error);
void document_authenticity_editor_free(DocumentAuthenticityEditor *editor);
G_END_DECLS
#endif

View file

@ -1,12 +0,0 @@
#ifndef LABFY_DOCUMENT_IDENTITY_MISUSE_EDITOR_H
#define LABFY_DOCUMENT_IDENTITY_MISUSE_EDITOR_H
#include "database/database.h"
#include <gtk/gtk.h>
G_BEGIN_DECLS
typedef struct DocumentIdentityMisuseEditor DocumentIdentityMisuseEditor;
DocumentIdentityMisuseEditor *document_identity_misuse_editor_new(Database *database,const char *evidence_identifier);
GtkWidget *document_identity_misuse_editor_get_widget(DocumentIdentityMisuseEditor *editor);
gboolean document_identity_misuse_editor_refresh(DocumentIdentityMisuseEditor *editor,GError **error);
void document_identity_misuse_editor_free(DocumentIdentityMisuseEditor *editor);
G_END_DECLS
#endif

View file

@ -1,33 +0,0 @@
/******************************************************************************
* @file eml_analysis_dialog.h
* @brief Présentation en lecture seule d'une analyse EML.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_EML_ANALYSIS_DIALOG_H
#define LABFY_INVESTIGATION_EML_ANALYSIS_DIALOG_H
#include "core/eml_processing.h"
#include "core/eml_integration.h"
#include "core/eml_pipeline_task.h"
#include <gtk/gtk.h>
G_BEGIN_DECLS
/** @brief Callback recevant les propositions sélectionnées, ou NULL. */
typedef void (*EmlAnalysisDialogCallback)(GPtrArray *proposals,
gpointer user_data);
/**
* @brief Affiche les métadonnées et indicateurs extraits d'une copie EML.
* @param parent Fenêtre parente.
* @param result Résultat emprunté pendant la construction de la fenêtre.
*/
void eml_analysis_dialog_present(GtkWindow *parent,
const EmlProcessingResult *result, EmlAnalysisDialogCallback callback,
gpointer user_data);
void eml_analysis_dialog_present_pipeline(
GtkWindow *parent,
const EmlPipelineResult *result,
const char *evidence_name,
const char *relative_path,
const char *source_sha256,
EmlAnalysisDialogCallback callback,
gpointer user_data
);
G_END_DECLS
#endif

View file

@ -1,60 +0,0 @@
#ifndef LABFY_EVIDENCE_IDENTITY_OCR_DIALOG_H
#define LABFY_EVIDENCE_IDENTITY_OCR_DIALOG_H
#include "core/tool_registry.h"
#include "core/background_task.h"
#include "models/entity_record.h"
#include "models/identity_ocr.h"
#include <gtk/gtk.h>
G_BEGIN_DECLS
typedef struct EvidenceIdentityOcrDialogResult
EvidenceIdentityOcrDialogResult;
typedef gboolean (*EvidenceIdentityOcrDialogSessionCheck)(gpointer user_data);
typedef void (*EvidenceIdentityOcrDialogCallback)(
EvidenceIdentityOcrDialogResult *result, gpointer user_data);
gboolean evidence_identity_ocr_dialog_file_is_compatible(
const char *file_path);
gboolean evidence_identity_ocr_dialog_present(
GtkWindow *parent, const char *file_path, const GPtrArray *persons,
const char *preselected_person_identifier,
const ToolInfo *tesseract_tool,
EvidenceIdentityOcrDialogSessionCheck session_check,
EvidenceIdentityOcrDialogCallback callback,
gpointer user_data, GDestroyNotify user_data_destroy,
GError **error);
gboolean evidence_identity_ocr_dialog_present_review(
GtkWindow *parent,const char *file_path,const GPtrArray *persons,
const char *preselected_person_identifier,
const IdentityOcrRun *persisted_run,
EvidenceIdentityOcrDialogSessionCheck session_check,
EvidenceIdentityOcrDialogCallback callback,
gpointer user_data,GDestroyNotify user_data_destroy,GError **error);
void evidence_identity_ocr_dialog_result_free(
EvidenceIdentityOcrDialogResult *result);
gboolean evidence_identity_ocr_dialog_result_has_ocr(
const EvidenceIdentityOcrDialogResult *result);
const char *evidence_identity_ocr_dialog_result_get_person_identifier(
const EvidenceIdentityOcrDialogResult *result);
const char *evidence_identity_ocr_dialog_result_get_temporary_identifier(
const EvidenceIdentityOcrDialogResult *result);
const char *evidence_identity_ocr_dialog_result_get_sha256(
const EvidenceIdentityOcrDialogResult *result);
guint64 evidence_identity_ocr_dialog_result_get_size(
const EvidenceIdentityOcrDialogResult *result);
const char *evidence_identity_ocr_dialog_result_get_mime_type(
const EvidenceIdentityOcrDialogResult *result);
IdentityOcrRun *evidence_identity_ocr_dialog_result_steal_run(
EvidenceIdentityOcrDialogResult *result);
GtkWindow *evidence_identity_ocr_dialog_result_get_dialog(
const EvidenceIdentityOcrDialogResult *result);
void evidence_identity_ocr_dialog_finish_import(
GtkWindow *dialog, const GError *error);
void evidence_identity_ocr_dialog_set_submission_task(
GtkWindow *dialog, BackgroundTask *task);
G_END_DECLS
#endif

View file

@ -1,176 +0,0 @@
/******************************************************************************
* @file evidence_import_dialog.h
* @brief Dialogue GTK de saisie des métadonnées d'une preuve.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_EVIDENCE_IMPORT_DIALOG_H
#define LABFY_INVESTIGATION_EVIDENCE_IMPORT_DIALOG_H
#include <gtk/gtk.h>
G_BEGIN_DECLS
/**
* @brief Résultat opaque produit par le dialogue d'import.
*/
typedef struct EvidenceImportDialogResult EvidenceImportDialogResult;
/**
* @brief Codes d'erreur produits par EvidenceImportDialog.
*/
typedef enum
{
/**
* Un argument public est invalide.
*/
EVIDENCE_IMPORT_DIALOG_ERROR_INVALID_ARGUMENT,
/**
* Aucun type de preuve n'est disponible.
*/
EVIDENCE_IMPORT_DIALOG_ERROR_EMPTY_TYPES,
/**
* Une allocation ou la création du dialogue a échoué.
*/
EVIDENCE_IMPORT_DIALOG_ERROR_MEMORY,
/**
* Les métadonnées saisies sont invalides.
*/
EVIDENCE_IMPORT_DIALOG_ERROR_INVALID_DATA
} EvidenceImportDialogError;
/**
* @brief Domaine d'erreur du dialogue d'import.
*/
#define EVIDENCE_IMPORT_DIALOG_ERROR \
evidence_import_dialog_error_quark()
/**
* @brief Callback appelé après la fermeture du dialogue.
*
* En cas de validation, result est non NULL et appartient au callback.
* Il doit être libéré avec evidence_import_dialog_result_free().
*
* En cas d'annulation, result vaut NULL.
*
* @param result Résultat possédé par le callback, ou NULL.
* @param user_data Données privées fournies lors de l'ouverture.
*/
typedef void (*EvidenceImportDialogCallback)(
EvidenceImportDialogResult *result,
gpointer user_data
);
/**
* @brief Retourne le domaine d'erreur du dialogue.
*
* @return Quark GLib associé à EvidenceImportDialog.
*/
GQuark evidence_import_dialog_error_quark(void);
/**
* @brief Présente le dialogue de saisie des métadonnées.
*
* Le chemin du fichier est affiché en lecture seule.
*
* evidence_types doit contenir des pointeurs EvidenceType valides.
* Le dialogue copie les codes et les libellés dont il a besoin :
* le tableau peut donc être libéré après le retour de cette fonction.
*
* Le type dont le code vaut "document" est sélectionné par défaut
* lorsqu'il est disponible. Sinon, le premier type est sélectionné.
*
* @param parent Fenêtre GTK parente.
* @param file_path Chemin du fichier sélectionné.
* @param evidence_types Tableau de EvidenceType.
* @param callback Fonction appelée après validation ou annulation.
* @param user_data Données privées transmises au callback.
* @param error Emplacement facultatif recevant une erreur immédiate.
*
* @return TRUE si le dialogue a été créé et présenté.
*/
gboolean evidence_import_dialog_present(
GtkWindow *parent,
const char *file_path,
const GPtrArray *evidence_types,
EvidenceImportDialogCallback callback,
gpointer user_data,
GError **error
);
/**
* @brief Valide les valeurs obligatoires d'un import.
*
* Cette fonction ne dépend pas de l'affichage du dialogue et peut être
* testée sans ouvrir de fenêtre GTK.
*
* Le chemin doit désigner un fichier existant.
* Le code du type doit être non vide.
* La date doit être une date ISO 8601 valide comprenant un fuseau horaire.
*
* @param file_path Chemin du fichier à importer.
* @param type_identifier Code métier du type de preuve.
* @param collected_at Date de collecte au format ISO 8601.
* @param error Emplacement facultatif recevant une erreur.
*
* @return TRUE lorsque les valeurs sont valides.
*/
gboolean evidence_import_dialog_validate_values(
const char *file_path,
const char *type_identifier,
const char *collected_at,
GError **error
);
/**
* @brief Libère le résultat produit par le dialogue.
*
* Cette fonction accepte NULL.
*
* @param result Résultat à libérer.
*/
void evidence_import_dialog_result_free(
EvidenceImportDialogResult *result
);
/**
* @brief Retourne le code du type sélectionné.
*
* @return Chaîne empruntée, ou NULL.
*/
const char *evidence_import_dialog_result_get_type_identifier(
const EvidenceImportDialogResult *result
);
/**
* @brief Retourne la date ISO 8601 de collecte.
*
* @return Chaîne empruntée, ou NULL.
*/
const char *evidence_import_dialog_result_get_collected_at(
const EvidenceImportDialogResult *result
);
/**
* @brief Retourne la source renseignée.
*
* @return Chaîne empruntée, ou NULL lorsque la source est vide.
*/
const char *evidence_import_dialog_result_get_source(
const EvidenceImportDialogResult *result
);
/**
* @brief Retourne la description renseignée.
*
* @return Chaîne empruntée, ou NULL lorsque la description est vide.
*/
const char *evidence_import_dialog_result_get_description(
const EvidenceImportDialogResult *result
);
G_END_DECLS
#endif

View file

@ -1,64 +0,0 @@
/******************************************************************************
* @file evidence_metadata_dialog.h
* @brief Dialogue de modification des métadonnées éditables d'une preuve.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_EVIDENCE_METADATA_DIALOG_H
#define LABFY_INVESTIGATION_EVIDENCE_METADATA_DIALOG_H
#include "models/evidence_record.h"
#include "database/database.h"
#include <gtk/gtk.h>
G_BEGIN_DECLS
/** @brief Résultat opaque possédé du dialogue. */
typedef struct EvidenceMetadataDialogResult EvidenceMetadataDialogResult;
/** @brief Callback recevant le résultat possédé, ou NULL après annulation. */
typedef void (*EvidenceMetadataDialogCallback)(
EvidenceMetadataDialogResult *result, gpointer user_data
);
typedef void (*EvidenceMetadataDialogAnalyzeCallback)(
GtkWindow *dialog, const char *evidence_identifier,
gboolean revise_existing, const char *ocr_run_identifier,
gpointer user_data);
/**
* @brief Présente le dialogue prérempli de modification des métadonnées.
* @param parent Fenêtre parente.
* @param record Preuve empruntée.
* @param evidence_types Tableau emprunté de EvidenceType.
* @param callback Callback appelé une fois à la fermeture.
* @param user_data Données empruntées du callback.
* @return TRUE si la fenêtre a été créée.
*/
gboolean evidence_metadata_dialog_present(
GtkWindow *parent,
const EvidenceRecord *record,
const GPtrArray *evidence_types,
EvidenceMetadataDialogCallback callback,
gpointer user_data
);
gboolean evidence_metadata_dialog_present_with_ocr(
GtkWindow *parent, const EvidenceRecord *record,
const GPtrArray *evidence_types, Database *database,
const char *investigation_root,
EvidenceMetadataDialogAnalyzeCallback analyze_callback,
EvidenceMetadataDialogCallback callback, gpointer user_data);
/** @brief Libère un résultat, ou accepte NULL. */
void evidence_metadata_dialog_result_free(EvidenceMetadataDialogResult *result);
/** @brief Retourne le code de type emprunté. */
const char *evidence_metadata_dialog_result_get_type_identifier(
const EvidenceMetadataDialogResult *result);
/** @brief Retourne la source empruntée, ou NULL. */
const char *evidence_metadata_dialog_result_get_source(
const EvidenceMetadataDialogResult *result);
/** @brief Retourne la description empruntée, ou NULL. */
const char *evidence_metadata_dialog_result_get_description(
const EvidenceMetadataDialogResult *result);
G_END_DECLS
#endif

View file

@ -1,74 +0,0 @@
/******************************************************************************
* @file file_dialog.h
* @brief Interface du dialogue de sélection d’un fichier de preuve.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_FILE_DIALOG_H
#define LABFY_INVESTIGATION_FILE_DIALOG_H
#include <gtk/gtk.h>
/**
* @brief Fonction appelée après la fermeture du sélecteur.
*
* Cas possibles :
*
* - succès : file_path non NULL et error NULL ;
* - annulation : file_path NULL et error NULL ;
* - erreur : file_path NULL et error non NULL.
*
* Le chemin et l’erreur restent valides uniquement pendant l’appel.
*
* @param file_path Chemin local sélectionné, ou NULL.
* @param error Erreur de sélection, ou NULL.
* @param user_data Données transmises lors de l’ouverture.
*/
typedef void (*FileDialogCallback)(
const char *file_path,
const GError *error,
gpointer user_data
);
/**
* @brief Fonction appelée après une sélection multiple.
*
* paths est un tableau emprunté de chemins locaux possédés par le dialogue.
* Il n'est valide que pendant l'appel. Une annulation transmet NULL sans
* erreur.
*
* @param paths Chemins sélectionnés, ou NULL.
* @param error Erreur de sélection, ou NULL.
* @param user_data Données fournies lors de l'ouverture.
*/
typedef void (*FileDialogMultipleCallback)(
const GPtrArray *paths,
const GError *error,
gpointer user_data
);
/**
* @brief Ouvre un dialogue permettant de sélectionner un fichier.
*
* @param parent Fenêtre parente, éventuellement NULL.
* @param callback Fonction appelée lorsque le dialogue est terminé.
* @param user_data Données transmises au callback.
*/
void file_dialog_select_file(
GtkWindow *parent,
FileDialogCallback callback,
gpointer user_data
);
/**
* @brief Ouvre un dialogue permettant de sélectionner plusieurs fichiers.
* @param parent Fenêtre parente, éventuellement NULL.
* @param callback Fonction appelée à la fermeture du dialogue.
* @param user_data Données transmises au callback.
*/
void file_dialog_select_files(
GtkWindow *parent,
FileDialogMultipleCallback callback,
gpointer user_data
);
#endif

View file

@ -1,46 +0,0 @@
/******************************************************************************
* @file folder_dialog.h
* @brief Interface du dialogue de sélection d'un dossier d'enquête.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_FOLDER_DIALOG_H
#define LABFY_INVESTIGATION_FOLDER_DIALOG_H
#include <gtk/gtk.h>
/**
* @brief Fonction appelée après la fermeture du dialogue de sélection.
*
* @param folder_path Chemin du dossier sélectionné, ou NULL si l'utilisateur
* a annulé la sélection.
* @param user_data Données transmises lors de l'ouverture du dialogue.
*
* Le chemin reçu reste valide uniquement pendant l'appel de cette fonction.
* Il doit être copié si le code appelant souhaite le conserver.
*
* @param callback
* Fonction appelée lorsque le dialogue est fermé.
*
* Si l'utilisateur annule la sélection,
* folder_path vaut NULL.
*/
typedef void (*FolderDialogCallback)(
const char *folder_path,
gpointer user_data
);
/**
* @brief Ouvre un dialogue permettant de sélectionner un dossier.
*
* @param parent Fenêtre parente du dialogue.
* Peut être NULL.
* @param callback Fonction appelée lorsque le dialogue est terminé.
* @param user_data Données transmises à la fonction de rappel.
*/
void folder_dialog_select_folder(
GtkWindow *parent,
FolderDialogCallback callback,
gpointer user_data
);
#endif

View file

@ -1,8 +0,0 @@
#ifndef IDENTITY_OCR_LANGUAGE_LOADER_H
#define IDENTITY_OCR_LANGUAGE_LOADER_H
#include <gtk/gtk.h>
void identity_ocr_language_loader_start(GtkWindow *window, const char *tesseract_path, GtkDropDown *language_dropdown);
#endif

View file

@ -1,758 +0,0 @@
/******************************************************************************
* @file main_window.h
* @brief Interface publique de la fenêtre principale.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_MAIN_WINDOW_H
#define LABFY_INVESTIGATION_MAIN_WINDOW_H
#include "core/investigation_tree_model.h"
#include "widgets/investigation_tree_view.h"
#include "core/investigation_node.h"
#include "core/task_manager.h"
#include "widgets/evidence_category_model.h"
#include "models/evidence_record.h"
#include "models/osint_action_catalog.h"
#include <gtk/gtk.h>
/**
* @brief Représentation opaque du graphe d'enquête.
*/
typedef struct InvestigationGraphModel InvestigationGraphModel;
/**
* @brief Représentation opaque de la disposition du graphe.
*/
typedef struct InvestigationGraphLayout InvestigationGraphLayout;
/**
* @brief Représentation opaque de la fenêtre principale.
*/
typedef struct MainWindow MainWindow;
/**
* @brief Callback appelé lorsque l'utilisateur demande une nouvelle enquête.
*
* @param user_data Données utilisateur associées au callback.
*/
typedef void (*MainWindowNewInvestigationCallback)(
gpointer user_data
);
/**
* @brief Callback appelé lorsque l'utilisateur demande
* l'import d'une preuve.
*
* @param user_data Données utilisateur associées au callback.
*/
typedef void (*MainWindowImportEvidenceCallback)(
gpointer user_data
);
/** @brief Callback appelé pour ajouter un compte de réseau social. */
typedef void (*MainWindowAddSocialAccountCallback)(
gpointer user_data
);
/** @brief Callback appelé pour ajouter une personne. */
typedef void (*MainWindowAddPersonCallback)(gpointer user_data);
/**
* @brief Callback appelé lorsque l'utilisateur revient au graphe.
*
* @param user_data Données utilisateur associées au callback.
*/
typedef void (*MainWindowShowGraphCallback)(
gpointer user_data
);
/**
* @brief Callback appelé lorsqu'une preuve est sélectionnée.
*
* L'identifiant est emprunté et uniquement valide pendant l'appel.
* Une sélection vide ou une catégorie transmet NULL.
*
* @param evidence_identifier UUID de la preuve, ou NULL.
* @param user_data Données privées fournies par l'appelant.
*/
typedef void (*MainWindowEvidenceSelectionCallback)(
const char *evidence_identifier,
gpointer user_data
);
/**
* @brief Callback appelé lorsque l'utilisateur demande une vérification.
*
* L'identifiant est emprunté et uniquement valide pendant l'appel.
*
* @param evidence_identifier UUID de la preuve.
* @param user_data Données privées du callback.
*/
typedef void (*MainWindowVerifyEvidenceCallback)(
const char *evidence_identifier,
gpointer user_data
);
/** @brief Callback appelé pour modifier les métadonnées d'une preuve. */
typedef void (*MainWindowEditEvidenceCallback)(
const char *evidence_identifier,
gpointer user_data
);
/** @brief Callback appelé pour analyser une preuve EML. */
typedef void (*MainWindowAnalyzeEmlCallback)(const char *evidence_identifier,
gpointer user_data);
/** @brief Callback appelé pour analyser un RIB par OCR. */
typedef void (*MainWindowAnalyzeRibCallback)(const char *evidence_identifier,
gpointer user_data);
typedef void (*MainWindowIdentityOcrCallback)(
const char *evidence_identifier, gboolean revise_existing,
const char *ocr_run_identifier, gpointer user_data);
/** @brief Callback appelé pour extraire les métadonnées d'une preuve. */
typedef void (*MainWindowExtractMetadataCallback)(const char *evidence_identifier,
gpointer user_data);
/** @brief Callback appelé pour récupérer le mot de passe d'un PDF. */
typedef void (*MainWindowRecoverPdfPasswordCallback)(
const char *evidence_identifier, gpointer user_data);
/**
* @brief Callback appelé après le déplacement effectif d'un nœud.
*
* @param entity_identifier UUID de l'entité déplacée.
* @param x Coordonnée horizontale logique.
* @param y Coordonnée verticale logique.
* @param user_data Données privées du callback.
*/
typedef void (*MainWindowGraphNodeMovedCallback)(
const char *entity_identifier,
double x,
double y,
gpointer user_data
);
typedef void (*MainWindowGraphTransformChangedCallback)(gpointer user_data);
typedef void (*MainWindowExtractionDropCallback)(
const char *file_path,
const char *target_entity_identifier,
gpointer user_data
);
/**
* @brief Callback appelé lors d'une demande de réinitialisation du graphe.
*
* @param user_data Données privées du callback.
*/
typedef void (*MainWindowResetGraphLayoutCallback)(
gpointer user_data
);
/**
* @brief Callback appelé lors d'une demande d'ajout d'une relation.
*
* @param source_entity_identifier UUID de l'entité source.
* @param user_data Données privées du callback.
*/
typedef void (*MainWindowAddRelationCallback)(
const char *source_entity_identifier,
gpointer user_data
);
/** @brief Callback appelé pour modifier une relation. */
typedef void (*MainWindowEditRelationCallback)(
const char *relation_identifier,
gpointer user_data
);
/** @brief Callback appelé pour supprimer une relation. */
typedef void (*MainWindowDeleteRelationCallback)(
const char *relation_identifier, gpointer user_data);
/** @brief Callback appelé quand une relation est sélectionnée. */
typedef void (*MainWindowRelationSelectedCallback)(
const char *relation_identifier,
gpointer user_data
);
/** @brief Callback appelé pour catégoriser une personne. */
typedef void (*MainWindowPersonRoleCallback)(const char *entity_identifier,
PersonRole role, gpointer user_data);
/** @brief Callback appelé pour modifier la confiance d'une personne. */
typedef void (*MainWindowPersonConfidenceCallback)(
const char *entity_identifier, gint confidence, gpointer user_data);
/**
* @brief Callback appelé lors du déclenchement d'une action OSINT.
*
* @param action_identifier Identifiant stable de l'action.
* @param target_identifier UUID de la sélection ciblée.
* @param target_value Valeur métier ciblée.
* @param user_data Données privées fournies par l'appelant.
*/
typedef void (*MainWindowOsintActionCallback)(
const char *action_identifier,
const char *target_identifier,
const char *target_value,
gpointer user_data
);
/** @brief Callback appelé pour modifier le nom affiché d'une personne. */
typedef void (*MainWindowPersonNameCallback)(const char *entity_identifier,
const char *display_name, gpointer user_data);
/** @brief Callback appelé pour gérer les preuves d'une personne. */
typedef void (*MainWindowPersonEvidenceCallback)(
const char *entity_identifier, gpointer user_data);
/** @brief Callback appelé quand une entité est sélectionnée. */
typedef void (*MainWindowEntitySelectedCallback)(
const char *entity_identifier, gpointer user_data);
/**
* @brief Définit le callback de vérification d'une preuve.
*
* MainWindow relaie la demande provenant du Workspace.
*
* @param main_window Fenêtre principale.
* @param callback Callback facultatif.
* @param user_data Données privées transmises au callback.
*/
void main_window_set_verify_evidence_callback(
MainWindow *main_window,
MainWindowVerifyEvidenceCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback de modification de la preuve sélectionnée.
* @param main_window Fenêtre principale.
* @param callback Callback facultatif.
* @param user_data Données privées transmises au callback.
*/
void main_window_set_edit_evidence_callback(
MainWindow *main_window,
MainWindowEditEvidenceCallback callback,
gpointer user_data
);
/** @brief Définit le callback d'analyse locale d'une preuve EML. */
void main_window_set_analyze_eml_callback(MainWindow *main_window,
MainWindowAnalyzeEmlCallback callback, gpointer user_data);
/** @brief Définit le callback d'analyse OCR d'un RIB. */
void main_window_set_analyze_rib_callback(MainWindow *main_window,
MainWindowAnalyzeRibCallback callback, gpointer user_data);
/** @brief Définit le callback d'extraction locale des métadonnées. */
void main_window_set_extract_metadata_callback(MainWindow *main_window,
MainWindowExtractMetadataCallback callback, gpointer user_data);
/** @brief Définit le callback de récupération d'un mot de passe PDF. */
void main_window_set_recover_pdf_password_callback(MainWindow *main_window,
MainWindowRecoverPdfPasswordCallback callback, gpointer user_data);
void main_window_set_identity_ocr_callback(
MainWindow *main_window, MainWindowIdentityOcrCallback callback,
gpointer user_data);
/**
* @brief Définit le callback de fin de déplacement d'un nœud.
*
* MainWindow relaie uniquement l'événement provenant du Workspace.
*
* @param main_window Fenêtre principale.
* @param callback Callback facultatif.
* @param user_data Données privées transmises au callback.
*/
void main_window_set_graph_node_moved_callback(
MainWindow *main_window,
MainWindowGraphNodeMovedCallback callback,
gpointer user_data
);
void main_window_set_graph_transform_changed_callback(
MainWindow *main_window,
MainWindowGraphTransformChangedCallback callback,
gpointer user_data);
void main_window_set_extraction_drop_callback(
MainWindow *main_window,
MainWindowExtractionDropCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback de demande de réinitialisation du graphe.
*
* MainWindow relaie uniquement la demande provenant du Workspace.
*
* @param main_window Fenêtre principale.
* @param callback Callback facultatif.
* @param user_data Données privées transmises au callback.
*/
void main_window_set_reset_graph_layout_callback(
MainWindow *main_window,
MainWindowResetGraphLayoutCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback de demande d'ajout d'une relation.
*
* MainWindow relaie uniquement la demande provenant du Workspace.
*
* @param main_window Fenêtre principale.
* @param callback Callback facultatif.
* @param user_data Données privées transmises au callback.
*/
void main_window_set_add_relation_callback(
MainWindow *main_window,
MainWindowAddRelationCallback callback,
gpointer user_data
);
/** @brief Définit le callback de modification d'une relation. */
void main_window_set_edit_relation_callback(
MainWindow *main_window,
MainWindowEditRelationCallback callback,
gpointer user_data
);
/** @brief Définit le callback de suppression d'une relation. */
void main_window_set_delete_relation_callback(MainWindow *main_window,
MainWindowDeleteRelationCallback callback, gpointer user_data);
/** @brief Définit le callback de sélection d'une relation. */
void main_window_set_relation_selected_callback(
MainWindow *main_window,
MainWindowRelationSelectedCallback callback,
gpointer user_data
);
/** @brief Affiche les preuves associées à la relation sélectionnée. */
void main_window_set_relation_evidences(
MainWindow *main_window,
const GPtrArray *evidence_records
);
/** @brief Transmet les preuves associées à l'entité sélectionnée. */
void main_window_set_person_evidences(MainWindow *main_window,
const GPtrArray *records);
void main_window_set_person_factual_relations(MainWindow *main_window,
const GPtrArray *relations, const GPtrArray *evidence_records,const GHashTable *profile_fields);
/** @brief Définit le callback de catégorisation d'une personne. */
void main_window_set_person_role_callback(MainWindow *main_window,
MainWindowPersonRoleCallback callback, gpointer user_data);
/** @brief Définit le callback de confiance d'une personne. */
void main_window_set_person_confidence_callback(MainWindow *main_window,
MainWindowPersonConfidenceCallback callback, gpointer user_data);
/**
* @brief Définit le callback de déclenchement des actions OSINT.
*
* MainWindow relaie uniquement la demande provenant du Workspace.
*
* @param main_window Fenêtre principale.
* @param callback Callback facultatif.
* @param user_data Données privées transmises au callback.
*/
void main_window_set_osint_action_callback(
MainWindow *main_window,
MainWindowOsintActionCallback callback,
gpointer user_data
);
/** @brief Définit le callback de modification du nom affiché. */
void main_window_set_person_name_callback(MainWindow *main_window,
MainWindowPersonNameCallback callback, gpointer user_data);
/** @brief Définit le callback de gestion des preuves d'une personne. */
void main_window_set_person_evidence_callback(MainWindow *main_window,
MainWindowPersonEvidenceCallback callback, gpointer user_data);
/** @brief Définit le callback de sélection d'une entité. */
void main_window_set_entity_selected_callback(MainWindow *main_window,
MainWindowEntitySelectedCallback callback, gpointer user_data);
/** @brief Affiche les preuves de la personne sélectionnée. */
void main_window_set_person_evidences(MainWindow *main_window,
const GPtrArray *evidence_records);
/**
* @brief Définit le callback du bouton « Nouvelle enquête ».
*
* La fenêtre ne crée pas directement l'enquête. Elle transmet uniquement
* la demande au contrôleur.
*
* @param main_window Fenêtre principale.
* @param callback Fonction appelée lors du clic.
* @param user_data Données transmises au callback.
*/
void main_window_set_new_investigation_callback(
MainWindow *main_window,
MainWindowNewInvestigationCallback callback,
gpointer user_data
);
/**
* @brief Crée une nouvelle fenêtre principale.
*
* MainWindow ne devient pas propriétaire de task_manager.
*
* @param application Application GTK.
* @param task_manager Gestionnaire de tâches de l'application.
*
* @return Nouvelle fenêtre, ou NULL en cas d'échec.
*/
MainWindow *main_window_new(
GtkApplication *application,
TaskManager *task_manager
);
/**
* @brief Affiche la fenêtre principale.
*
* @param main_window Fenêtre à afficher.
*/
void main_window_present(MainWindow *main_window);
/**
* @brief Retourne la fenêtre GTK associée.
*
* @param main_window Fenêtre principale.
*
* @return Fenêtre GTK.
*/
GtkWindow *main_window_get_window(
const MainWindow *main_window
);
/**
* @brief Transmet un modèle d'arborescence à la fenêtre principale.
*
* La fenêtre principale ne devient pas propriétaire du modèle.
* Elle le transmet uniquement à la Sidebar.
*
* @param main_window Fenêtre principale à mettre à jour.
* @param tree_model Modèle d'arborescence en lecture seule.
*/
void main_window_set_tree_model(
MainWindow *main_window,
const InvestigationTreeModel *tree_model
);
/**
* @brief Transmet le modèle des catégories de preuves à la Sidebar.
*
* MainWindow ne devient pas propriétaire du modèle.
* Le modèle doit rester valide pendant son affichage.
*
* Passer NULL vide l'onglet Preuves.
*
* @param main_window Fenêtre principale.
* @param evidence_category_model Modèle de catégories, ou NULL.
*/
void main_window_set_evidence_model(
MainWindow *main_window,
EvidenceCategoryModel *evidence_category_model
);
/**
* @brief Met à jour l'identité de l'enquête affichée.
*
* La fonction met à jour :
*
* - le titre de la fenêtre ;
* - le texte de la barre d'état.
*
* Les chaînes sont copiées par GTK et restent la propriété de l'appelant.
*
* Cette fonction accepte des chaînes NULL ou vides.
*
* @param main_window Fenêtre principale à mettre à jour.
* @param investigation_name Nom persistant de l'enquête.
* @param investigation_root_path Chemin racine de l'enquête.
*/
void main_window_set_investigation(
MainWindow *main_window,
const char *investigation_name,
const char *investigation_root_path
);
void main_window_set_tree_selection_callback(
MainWindow *main_window,
InvestigationTreeViewSelectionCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback de sélection d'une preuve.
*
* MainWindow transmet uniquement le callback à la Sidebar.
*
* @param main_window Fenêtre principale.
* @param callback Callback facultatif.
* @param user_data Données privées transmises au callback.
*/
void main_window_set_evidence_selection_callback(
MainWindow *main_window,
MainWindowEvidenceSelectionCallback callback,
gpointer user_data
);
/**
* @brief Affiche dans le Workspace les informations du nœud sélectionné.
*
* La fenêtre principale ne devient pas propriétaire du nœud.
*
* @param main_window Fenêtre principale à mettre à jour.
* @param node Nœud sélectionné en lecture seule, ou NULL.
*/
void main_window_set_selected_node(
MainWindow *main_window,
const InvestigationNode *node
);
/**
* @brief Affiche la fiche de la preuve sélectionnée.
*
* MainWindow ne conserve pas le EvidenceRecord.
*
* @param main_window Fenêtre principale.
* @param evidence_record Preuve sélectionnée, ou NULL.
*/
void main_window_set_selected_evidence(
MainWindow *main_window,
const EvidenceRecord *evidence_record
);
void main_window_set_identity_ocr_runs(
MainWindow *main_window, GPtrArray *owned_records);
void main_window_set_evidence_observations(MainWindow *main_window,
const GPtrArray *observations);
void main_window_set_observation_remove_callback(MainWindow *main_window,
void (*callback)(const char *, gpointer), gpointer user_data);
void main_window_set_eml_analysis_available(
MainWindow *main_window,
gboolean available
);
/** @brief Transmet au workspace un chemin absolu validé pour l'aperçu. */
void main_window_set_evidence_preview(MainWindow *main_window,
const char *file_path, const char *display_name);
void main_window_show_evidence_preview(MainWindow *main_window,
const char *investigation_root, const EvidenceRecord *record);
/**
* @brief Affiche l'état de chargement du graphe.
*
* MainWindow transmet uniquement la demande au Workspace.
*
* @param main_window Fenêtre principale.
*/
void main_window_set_graph_loading(
MainWindow *main_window
);
/**
* @brief Affiche le graphe chargé et sa disposition dans le Workspace.
*
* MainWindow et Workspace empruntent graph_model et graph_layout.
*
* Passer NULL pour graph_layout conserve le placement automatique.
*
* @param main_window Fenêtre principale.
* @param graph_model Graphe emprunté, ou NULL.
* @param graph_layout Disposition empruntée, ou NULL.
*/
void main_window_set_graph(
MainWindow *main_window,
const InvestigationGraphModel *graph_model,
const InvestigationGraphLayout *graph_layout
);
gboolean main_window_get_graph_view_transform(
MainWindow *main_window, double *zoom, double *offset_x, double *offset_y);
void main_window_set_graph_view_transform(
MainWindow *main_window, double zoom, double offset_x, double offset_y);
/** @brief Sélectionne une relation dans le graphe affiché. */
gboolean main_window_select_graph_relation(
MainWindow *main_window,
const char *relation_identifier
);
/** @brief Sélectionne une entité dans le graphe affiché. */
gboolean main_window_select_graph_entity(MainWindow *main_window,
const char *entity_identifier);
/**
* @brief Affiche une erreur de chargement du graphe.
*
* @param main_window Fenêtre principale.
* @param message Message d'erreur, ou NULL.
*/
void main_window_set_graph_error(
MainWindow *main_window,
const char *message
);
/**
* @brief Détache le graphe du Workspace.
*
* Aucun graphe n'est libéré par MainWindow.
*
* @param main_window Fenêtre principale.
*/
void main_window_clear_graph(
MainWindow *main_window
);
/**
* @brief Reconstruit visuellement la disposition automatique du graphe.
*
* Cette fonction ne modifie pas SQLite.
*
* @param main_window Fenêtre principale.
*/
void main_window_reset_graph_layout(
MainWindow *main_window
);
/**
* @brief Met à jour le texte de la barre d'état.
*
* Si status_text vaut NULL, le texte par défaut est affiché.
*
* @param main_window Fenêtre principale.
* @param status_text Nouveau texte de statut, ou NULL.
*/
void main_window_set_status(
MainWindow *main_window,
const char *status_text
);
/**
* @brief Transmet au workspace l'état d'un outil OSINT externe.
*
* @param main_window Fenêtre principale.
* @param tool_identifier Identifiant stable de l'outil.
* @param state État traduit par l'application.
* @param version Version détectée facultative.
*/
void main_window_set_osint_tool_state(
MainWindow *main_window,
const char *tool_identifier,
OsintActionToolState state,
const char *version
);
/**
* @brief Callback appelé lorsque l'utilisateur demande l'ouverture
* d'une enquête existante.
*
* @param user_data Données utilisateur associées au callback.
*/
typedef void (*MainWindowOpenInvestigationCallback)(
gpointer user_data
);
/**
* @brief Callback appelé lorsque l'utilisateur demande à quitter.
*
* @param user_data Données utilisateur associées au callback.
*/
typedef void (*MainWindowQuitCallback)(
gpointer user_data
);
/**
* @brief Définit le callback du bouton « Ouvrir une enquête ».
*
* La fenêtre transmet uniquement la demande au contrôleur.
*
* @param main_window Fenêtre principale.
* @param callback Fonction appelée lors du clic.
* @param user_data Données transmises au callback.
*/
void main_window_set_open_investigation_callback(
MainWindow *main_window,
MainWindowOpenInvestigationCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback du bouton « Quitter ».
*
* MainWindow transmet uniquement la demande au contrôleur.
*
* @param main_window Fenêtre principale.
* @param callback Fonction appelée lors du clic.
* @param user_data Données transmises au callback.
*/
void main_window_set_quit_callback(
MainWindow *main_window,
MainWindowQuitCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback de l'action « Importer une preuve ».
*
* MainWindow transmet uniquement la demande au contrôleur.
*
* @param main_window Fenêtre principale.
* @param callback Fonction appelée lors du clic.
* @param user_data Données transmises au callback.
*/
void main_window_set_import_evidence_callback(
MainWindow *main_window,
MainWindowImportEvidenceCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback de l'action d'ajout d'un compte social.
* @param main_window Fenêtre principale.
* @param callback Callback facultatif.
* @param user_data Données privées transmises au callback.
*/
void main_window_set_add_social_account_callback(
MainWindow *main_window,
MainWindowAddSocialAccountCallback callback,
gpointer user_data
);
/** @brief Définit le callback d'ajout d'une personne. */
void main_window_set_add_person_callback(MainWindow *main_window,
MainWindowAddPersonCallback callback, gpointer user_data);
/**
* @brief Définit le callback du bouton « Revenir au graphe ».
*
* MainWindow transmet uniquement la demande au contrôleur.
*
* @param main_window Fenêtre principale.
* @param callback Fonction appelée lors du clic, ou NULL.
* @param user_data Données transmises au callback.
*/
void main_window_set_show_graph_callback(
MainWindow *main_window,
MainWindowShowGraphCallback callback,
gpointer user_data
);
/**
* @brief Active ou désactive l'action d'import d'une preuve.
*
* @param main_window Fenêtre principale.
* @param enabled TRUE pour autoriser l'import.
*/
void main_window_set_import_evidence_enabled(
MainWindow *main_window,
gboolean enabled
);
/**
* @brief Active ou désactive l'ajout d'un compte social.
* @param main_window Fenêtre principale.
* @param enabled TRUE si une enquête est ouverte.
*/
void main_window_set_add_social_account_enabled(
MainWindow *main_window,
gboolean enabled
);
/** @brief Active ou désactive l'ajout d'une personne. */
void main_window_set_add_person_enabled(MainWindow *main_window, gboolean enabled);
/**
* @brief Libère les ressources de la fenêtre.
*
* @param main_window Fenêtre à détruire.
*/
void main_window_free(MainWindow *main_window);
#endif

View file

@ -1,33 +0,0 @@
/******************************************************************************
* @file manage_entity_evidence_dialog.h
* @brief Dialogue de sélection des preuves associées à une entité.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_MANAGE_ENTITY_EVIDENCE_DIALOG_H
#define LABFY_INVESTIGATION_MANAGE_ENTITY_EVIDENCE_DIALOG_H
#include "models/evidence_record.h"
#include "core/task_manager.h"
#include <gtk/gtk.h>
G_BEGIN_DECLS
typedef void (*ManageEntityEvidenceDialogCallback)(
GPtrArray *selected_identifiers, gpointer user_data);
typedef void (*ManageEntityEvidenceImportCallback)(gpointer user_data);
/**
* @brief Présente la sélection multiple de preuves avec aperçu.
* @param parent Fenêtre parente.
* @param evidence_records Preuves disponibles empruntées.
* @param selected_identifiers UUID déjà associés empruntés.
* @param investigation_root_path Racine de l'enquête.
* @param callback Callback recevant un tableau possédé, ou NULL à l'annulation.
* @param user_data Données empruntées du callback.
* @param error Destination facultative d'une erreur immédiate.
* @return TRUE si le dialogue a été présenté.
*/
gboolean manage_entity_evidence_dialog_present(GtkWindow *parent,
const GPtrArray *evidence_records, const GPtrArray *selected_identifiers,
const char *investigation_root_path,
TaskManager *task_manager,
ManageEntityEvidenceDialogCallback callback, gpointer user_data,
ManageEntityEvidenceImportCallback import_callback,
GError **error);
G_END_DECLS
#endif

View file

@ -1,25 +0,0 @@
/******************************************************************************
* @file metadata_analysis_dialog.h
* @brief Fenêtre de consultation des métadonnées extraites.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_METADATA_ANALYSIS_DIALOG_H
#define LABFY_INVESTIGATION_METADATA_ANALYSIS_DIALOG_H
#include <gtk/gtk.h>
G_BEGIN_DECLS
/**
* @brief Affiche une synthèse ExifTool dans une fenêtre défilable.
* @param parent Fenêtre parente facultative.
* @param summary Métadonnées lisibles empruntées.
* @param json_path Chemin emprunté de la sortie JSON conservée.
* @param version Version empruntée d'ExifTool.
*/
void metadata_analysis_dialog_present(GtkWindow *parent, const char *summary,
const char *json_path, const char *version);
G_END_DECLS
#endif

View file

@ -1,47 +0,0 @@
/******************************************************************************
* @file osint_dns_review_dialog.h
* @brief Dialogue de sélection des propositions issues d'une réponse DNS.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_OSINT_DNS_REVIEW_DIALOG_H
#define LABFY_INVESTIGATION_OSINT_DNS_REVIEW_DIALOG_H
#include <gtk/gtk.h>
G_BEGIN_DECLS
/**
* @brief Callback appelé après confirmation de la sélection DNS.
*
* Le tableau et les propositions sont empruntés et uniquement valides pendant
* l'appel. Le tableau contient seulement les propositions compatibles cochées.
*
* @param selected_proposals Propositions sélectionnées.
* @param user_data Données empruntées fournies par l'appelant.
*/
typedef void (*OsintDnsReviewDialogConfirmCallback)(
GPtrArray *selected_proposals,
gpointer user_data
);
/**
* @brief Affiche le dialogue de sélection avant intégration.
*
* Le dialogue conserve une référence sur proposals jusqu'à sa fermeture.
* Fermer ou annuler ne déclenche pas le callback.
*
* @param parent_window Fenêtre parente, ou NULL.
* @param proposals Tableau de OsintDnsProposal à réviser.
* @param callback Callback de confirmation facultatif.
* @param user_data Données empruntées transmises au callback.
*/
void osint_dns_review_dialog_present(
GtkWindow *parent_window,
GPtrArray *proposals,
OsintDnsReviewDialogConfirmCallback callback,
gpointer user_data
);
G_END_DECLS
#endif

View file

@ -1,32 +0,0 @@
/******************************************************************************
* @file osint_execution_history_dialog.h
* @brief Consultation en lecture seule de l'historique OSINT.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_OSINT_EXECUTION_HISTORY_DIALOG_H
#define LABFY_INVESTIGATION_OSINT_EXECUTION_HISTORY_DIALOG_H
#include <gtk/gtk.h>
G_BEGIN_DECLS
/**
* @brief Affiche les exécutions et leurs objets liés dans une fenêtre modale.
*
* La fonction conserve une référence sur les deux conteneurs jusqu'à la
* fermeture. records contient des OsintExecutionRecord. linked_objects associe
* chaque UUID d'exécution à un GPtrArray de chaînes.
*
* @param parent_window Fenêtre parente, ou NULL.
* @param records Historique non vide, de la plus récente à l'ancienne.
* @param linked_objects Table des objets liés par UUID d'exécution.
*/
void osint_execution_history_dialog_present(
GtkWindow *parent_window,
GPtrArray *records,
GHashTable *linked_objects
);
G_END_DECLS
#endif

View file

@ -1,29 +0,0 @@
/******************************************************************************
* @file pdf_password_dialog.h
* @brief Paramétrage d'une récupération de mot de passe PDF.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_PDF_PASSWORD_DIALOG_H
#define LABFY_INVESTIGATION_PDF_PASSWORD_DIALOG_H
#include "core/pdf_password_recovery.h"
#include <gtk/gtk.h>
G_BEGIN_DECLS
/** @brief Callback recevant la méthode et son paramètre validé. */
typedef void (*PdfPasswordDialogCallback)(PdfPasswordRecoveryMethod method,
const char *parameter, gpointer user_data);
/**
* @brief Présente le formulaire de récupération ciblée.
* @param parent Fenêtre parente facultative.
* @param callback Callback appelé après validation.
* @param user_data Données empruntées du callback.
*/
void pdf_password_dialog_present(GtkWindow *parent,
PdfPasswordDialogCallback callback, gpointer user_data);
G_END_DECLS
#endif

View file

@ -1,40 +0,0 @@
#ifndef LABFY_PERSON_FACTUAL_RELATION_EDITOR_H
#define LABFY_PERSON_FACTUAL_RELATION_EDITOR_H
#include <gtk/gtk.h>
#include "core/person_creation_coordinator.h"
#include "models/identity_ocr.h"
G_BEGIN_DECLS
typedef struct PersonFactualRelationEditor PersonFactualRelationEditor;
PersonFactualRelationEditor *person_factual_relation_editor_new(void);
GtkWidget *person_factual_relation_editor_get_widget(
PersonFactualRelationEditor *editor);
void person_factual_relation_editor_set_available_evidence(
PersonFactualRelationEditor *editor,
GtkStringList *evidence_labels,
GPtrArray *evidence_identifiers);
void person_factual_relation_editor_set_available_ocr_runs(
PersonFactualRelationEditor *editor,
GPtrArray *ocr_runs);
gboolean person_factual_relation_editor_collect_relations(
PersonFactualRelationEditor *editor, GPtrArray **relations,
GError **error);
guint person_factual_relation_editor_get_count(
const PersonFactualRelationEditor *editor);
gboolean person_factual_relation_editor_validate(
PersonFactualRelationEditor *editor, GError **error);
void person_factual_relation_editor_append_summary(
PersonFactualRelationEditor *editor, GString *summary);
void person_factual_relation_editor_free(PersonFactualRelationEditor *editor);
G_END_DECLS
#endif

View file

@ -1,16 +0,0 @@
#ifndef LABFY_PERSON_OCR_PROJECTION_EDITOR_H
#define LABFY_PERSON_OCR_PROJECTION_EDITOR_H
#include "models/identity_ocr.h"
#include "models/person_ocr_projection.h"
#include <gtk/gtk.h>
G_BEGIN_DECLS
typedef struct PersonOcrProjectionEditor PersonOcrProjectionEditor;
PersonOcrProjectionEditor *person_ocr_projection_editor_new(void);
GtkWidget *person_ocr_projection_editor_get_widget(PersonOcrProjectionEditor *editor);
void person_ocr_projection_editor_set_runs(PersonOcrProjectionEditor *editor,const GPtrArray *runs);
gboolean person_ocr_projection_editor_collect(PersonOcrProjectionEditor *editor,GPtrArray **projections,GError **error);
void person_ocr_projection_editor_append_summary(
PersonOcrProjectionEditor *editor, GString *summary);
void person_ocr_projection_editor_free(PersonOcrProjectionEditor *editor);
G_END_DECLS
#endif

View file

@ -1,37 +0,0 @@
#ifndef LABFY_PERSON_VOCABULARY_ADAPTER_H
#define LABFY_PERSON_VOCABULARY_ADAPTER_H
#include "database/database.h"
#include "models/identity_traceability.h"
#include <gtk/gtk.h>
G_BEGIN_DECLS
typedef struct PersonVocabularyAdapter PersonVocabularyAdapter;
PersonVocabularyAdapter *person_vocabulary_adapter_new(
Database *database, GError **error);
void person_vocabulary_adapter_free(PersonVocabularyAdapter *adapter);
const GPtrArray *person_vocabulary_adapter_get_roles(
const PersonVocabularyAdapter *adapter);
const GPtrArray *person_vocabulary_adapter_get_statuses(
const PersonVocabularyAdapter *adapter);
GtkStringList *person_vocabulary_adapter_create_role_labels(
const PersonVocabularyAdapter *adapter);
GtkStringList *person_vocabulary_adapter_create_status_labels(
const PersonVocabularyAdapter *adapter);
GtkStringList *person_vocabulary_adapter_create_relation_labels(void);
const char *person_vocabulary_adapter_relation_code(guint index);
const char *person_vocabulary_adapter_relation_label(const char *code);
const char *person_vocabulary_adapter_relation_description(const char *code);
const char *person_vocabulary_adapter_status_code(
const PersonVocabularyAdapter *adapter, guint index);
const char *person_vocabulary_adapter_status_label(
const PersonVocabularyAdapter *adapter, guint index);
GPtrArray *person_vocabulary_adapter_selected_role_labels(
const GPtrArray *buttons);
GPtrArray *person_vocabulary_adapter_create_role_buttons(
const PersonVocabularyAdapter *adapter, GtkBox *container);
GPtrArray *person_vocabulary_adapter_build_role_assignments(
const PersonVocabularyAdapter *adapter, const GPtrArray *buttons,
const char *evidence_identifier);
gboolean person_vocabulary_adapter_justification_valid(
const PersonRoleVocabularyEntry *entry, const char *justification);
G_END_DECLS
#endif

View file

@ -1,15 +0,0 @@
/******************************************************************************
* @file rib_ocr_review_dialog.h
* @brief Révision humaine des résultats OCR d'un RIB.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_RIB_OCR_REVIEW_DIALOG_H
#define LABFY_INVESTIGATION_RIB_OCR_REVIEW_DIALOG_H
#include <gtk/gtk.h>
G_BEGIN_DECLS
typedef void (*RibOcrReviewCallback)(const char *iban, gpointer user_data);
/** @brief Présente le texte OCR et un IBAN révisable. */
void rib_ocr_review_dialog_present(GtkWindow *parent, const char *ocr_text,
const char *suggested_iban, RibOcrReviewCallback callback,
gpointer user_data);
G_END_DECLS
#endif

View file

@ -1,40 +0,0 @@
/******************************************************************************
* @file controlled_vocab_dropdown.h
* @brief Composant GTK4 (GtkDropDown) basé sur le vocabulaire contrôlé.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_CONTROLLED_VOCAB_DROPDOWN_H
#define LABFY_INVESTIGATION_CONTROLLED_VOCAB_DROPDOWN_H
#include "core/controlled_vocab.h"
#include <gtk/gtk.h>
G_BEGIN_DECLS
/**
* @brief Crée un widget GtkDropDown initialisé avec une catégorie de vocabulaire contrôlé.
* @param category Catégorie de vocabulaire.
* @param default_code Code sélectionné par défaut (facultatif).
* @return Nouveau GtkWidget (GtkDropDown).
*/
GtkWidget *controlled_vocab_dropdown_new(ControlledVocabCategory category,
const char *default_code);
/**
* @brief Obtient le code technique sélectionné dans le dropdown.
* @param dropdown Widget GtkDropDown créé avec controlled_vocab_dropdown_new.
* @return Code technique emprunté, ou NULL si rien n'est sélectionné.
*/
const char *controlled_vocab_dropdown_get_selected_code(GtkWidget *dropdown);
/**
* @brief Sélectionne un code technique dans le dropdown.
* @param dropdown Widget GtkDropDown.
* @param code Code à sélectionner.
* @return TRUE si le code a été trouvé et sélectionné, FALSE sinon.
*/
gboolean controlled_vocab_dropdown_set_selected_code(GtkWidget *dropdown,
const char *code);
G_END_DECLS
#endif /* LABFY_INVESTIGATION_CONTROLLED_VOCAB_DROPDOWN_H */

View file

@ -1,226 +0,0 @@
/******************************************************************************
* @file entity_details_panel.h
* @brief Volet latéral affichant les détails d'une entité.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_ENTITY_DETAILS_PANEL_H
#define LABFY_INVESTIGATION_ENTITY_DETAILS_PANEL_H
#include <gtk/gtk.h>
#include "models/entity_record.h"
G_BEGIN_DECLS
/**
* @brief Modèle opaque représentant une entité.
*/
typedef struct EntityRecord EntityRecord;
/**
* @brief Représentation opaque du volet de détails.
*/
typedef struct EntityDetailsPanel EntityDetailsPanel;
/**
* @brief Callback appelé lorsque l'utilisateur ferme le volet.
*
* Le callback n'est pas déclenché lors d'une fermeture programmatique avec
* entity_details_panel_clear().
*
* @param user_data Données empruntées fournies par l'appelant.
*/
typedef void (*EntityDetailsPanelCloseCallback)(
gpointer user_data
);
/**
* @brief Callback appelé lorsque l'utilisateur demande une relation.
*
* L'identifiant est emprunté et uniquement valide pendant l'appel.
*
* @param source_entity_identifier UUID de l'entité source.
* @param user_data Données empruntées fournies par l'appelant.
*/
typedef void (*EntityDetailsPanelAddRelationCallback)(
const char *source_entity_identifier,
gpointer user_data
);
/** @brief Callback appelé lors du changement de catégorie d'une personne. */
typedef void (*EntityDetailsPanelPersonRoleCallback)(
const char *entity_identifier,
PersonRole role,
gpointer user_data
);
/** @brief Callback appelé pour enregistrer la confiance d'une personne. */
typedef void (*EntityDetailsPanelPersonConfidenceCallback)(
const char *entity_identifier,
gint confidence,
gpointer user_data
);
/** @brief Callback appelé pour enregistrer le nom affiché d'une personne. */
typedef void (*EntityDetailsPanelPersonNameCallback)(
const char *entity_identifier,
const char *display_name,
gpointer user_data
);
/** @brief Callback appelé pour gérer les preuves d'une personne. */
typedef void (*EntityDetailsPanelPersonEvidenceCallback)(
const char *entity_identifier, gpointer user_data);
/** @brief Callback appelé lorsqu'une preuve associée est activée. */
typedef void (*EntityDetailsPanelEvidenceActivatedCallback)(
const char *evidence_identifier, gpointer user_data);
/** @brief Callback appelé pour ouvrir les recherches OSINT compatibles. */
typedef void (*EntityDetailsPanelOsintCallback)(
const char *entity_identifier, gpointer user_data);
/**
* @brief Crée un volet de détails fermé.
*
* @return Nouveau volet, ou NULL en cas d'échec.
*/
EntityDetailsPanel *entity_details_panel_new(void);
/**
* @brief Retourne le widget GTK racine du volet.
*
* Le widget appartient au module EntityDetailsPanel.
*
* @param details_panel Volet à consulter.
*
* @return Widget GTK racine, ou NULL.
*/
GtkWidget *entity_details_panel_get_widget(
const EntityDetailsPanel *details_panel
);
/**
* @brief Affiche une entité et ouvre le volet.
*
* Les textes sont immédiatement copiés dans les widgets GTK.
* Le pointeur EntityRecord n'est jamais conservé.
*
* Passer NULL équivaut à entity_details_panel_clear().
*
* @param details_panel Volet à mettre à jour.
* @param entity_record Entité empruntée, ou NULL.
*/
void entity_details_panel_set_entity(
EntityDetailsPanel *details_panel,
const EntityRecord *entity_record
);
/**
* @brief Vide et referme le volet sans appeler le callback de fermeture.
*
* @param details_panel Volet à vider.
*/
void entity_details_panel_clear(
EntityDetailsPanel *details_panel
);
/**
* @brief Définit le callback du bouton de fermeture.
*
* Le callback et user_data sont empruntés.
*
* @param details_panel Volet à configurer.
* @param callback Callback à appeler, ou NULL.
* @param user_data Données empruntées du callback.
*/
void entity_details_panel_set_close_callback(
EntityDetailsPanel *details_panel,
EntityDetailsPanelCloseCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback du bouton d'ajout d'une relation.
*
* Le volet transmet uniquement l'UUID de l'entité actuellement affichée.
*
* @param details_panel Volet à configurer.
* @param callback Callback à appeler, ou NULL.
* @param user_data Données empruntées du callback.
*/
void entity_details_panel_set_add_relation_callback(
EntityDetailsPanel *details_panel,
EntityDetailsPanelAddRelationCallback callback,
gpointer user_data
);
/** @brief Définit le callback de changement de catégorie d'une personne. */
void entity_details_panel_set_person_role_callback(
EntityDetailsPanel *details_panel,
EntityDetailsPanelPersonRoleCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback de modification de la confiance d'une personne.
*
* @param details_panel Volet à configurer.
* @param callback Callback à appeler, ou NULL.
* @param user_data Données empruntées du callback.
*/
void entity_details_panel_set_person_confidence_callback(
EntityDetailsPanel *details_panel,
EntityDetailsPanelPersonConfidenceCallback callback,
gpointer user_data
);
/** @brief Définit le callback de modification du nom affiché. */
void entity_details_panel_set_person_name_callback(
EntityDetailsPanel *details_panel,
EntityDetailsPanelPersonNameCallback callback,
gpointer user_data
);
/** @brief Définit le callback de gestion des preuves d'une personne. */
void entity_details_panel_set_person_evidence_callback(
EntityDetailsPanel *details_panel,
EntityDetailsPanelPersonEvidenceCallback callback, gpointer user_data);
void entity_details_panel_set_evidence_activated_callback(
EntityDetailsPanel *details_panel,
EntityDetailsPanelEvidenceActivatedCallback callback,
gpointer user_data);
/** @brief Définit le callback du bouton de recherche OSINT. */
void entity_details_panel_set_osint_callback(EntityDetailsPanel *details_panel,
EntityDetailsPanelOsintCallback callback, gpointer user_data);
/**
* @brief Affiche les preuves associées à la personne courante.
* @param details_panel Volet à mettre à jour.
* @param evidence_records Tableau emprunté de EvidenceRecord, ou NULL.
*/
void entity_details_panel_set_person_evidences(
EntityDetailsPanel *details_panel, const GPtrArray *evidence_records);
void entity_details_panel_set_person_factual_relations(
EntityDetailsPanel *details_panel, const GPtrArray *relations, const GPtrArray *evidence_records);
void entity_details_panel_set_person_profile_fields(EntityDetailsPanel *details_panel,const GHashTable *fields);
/**
* @brief Indique si le volet est actuellement ouvert.
*
* @param details_panel Volet à consulter.
*
* @return TRUE si le volet est ouvert, sinon FALSE.
*/
gboolean entity_details_panel_is_open(
const EntityDetailsPanel *details_panel
);
/**
* @brief Libère la structure d'encapsulation du volet.
*
* Cette fonction accepte NULL.
*
* @param details_panel Volet à libérer.
*/
void entity_details_panel_free(
EntityDetailsPanel *details_panel
);
G_END_DECLS
#endif

View file

@ -1,43 +0,0 @@
#ifndef LABFY_INVESTIGATION_EVIDENCE_PREVIEW_WIDGET_H
#define LABFY_INVESTIGATION_EVIDENCE_PREVIEW_WIDGET_H
#include "core/evidence_preview.h"
#include "core/task_manager.h"
#include <gtk/gtk.h>
G_BEGIN_DECLS
typedef struct EvidencePreviewWidget EvidencePreviewWidget;
typedef gboolean (*EvidencePreviewWidgetSessionCheck)(gpointer user_data);
EvidencePreviewWidget *evidence_preview_widget_new(
TaskManager *task_manager,
EvidencePreviewWidgetSessionCheck session_check,
gpointer session_data);
GtkWidget *evidence_preview_widget_get_widget(
const EvidencePreviewWidget *widget);
void evidence_preview_widget_show(EvidencePreviewWidget *widget,
const EvidencePreviewRequest *request, const char *metadata);
void evidence_preview_widget_clear(EvidencePreviewWidget *widget);
void evidence_preview_widget_cancel(EvidencePreviewWidget *widget);
void evidence_preview_widget_free(EvidencePreviewWidget *widget);
const char *evidence_preview_widget_get_state(
const EvidencePreviewWidget *widget);
char *evidence_preview_widget_dup_text(
const EvidencePreviewWidget *widget);
void evidence_preview_widget_zoom_in(EvidencePreviewWidget *widget);
void evidence_preview_widget_zoom_out(EvidencePreviewWidget *widget);
void evidence_preview_widget_fit(EvidencePreviewWidget *widget);
double evidence_preview_widget_get_zoom(
const EvidencePreviewWidget *widget);
gboolean evidence_preview_widget_is_fit(
const EvidencePreviewWidget *widget);
gboolean evidence_preview_widget_set_pdf_page(
EvidencePreviewWidget *widget, guint page);
guint evidence_preview_widget_get_pdf_page(
const EvidencePreviewWidget *widget);
guint evidence_preview_widget_get_pdf_page_count(
const EvidencePreviewWidget *widget);
G_END_DECLS
#endif

View file

@ -1,99 +0,0 @@
/******************************************************************************
* @file evidence_tree_view.h
* @brief Affichage GTK hiérarchique des catégories et preuves importées.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_EVIDENCE_TREE_VIEW_H
#define LABFY_INVESTIGATION_EVIDENCE_TREE_VIEW_H
#include "widgets/evidence_category_model.h"
#include <gtk/gtk.h>
G_BEGIN_DECLS
/**
* @brief Vue opaque affichant les preuves regroupées par catégorie.
*/
typedef struct EvidenceTreeView EvidenceTreeView;
/**
* @brief Callback appelé lorsqu'une preuve est sélectionnée.
*
* L'identifiant est emprunté et uniquement valide pendant l'appel.
*
* Lorsque la sélection est vide ou correspond à une catégorie,
* evidence_identifier vaut NULL.
*
* @param evidence_identifier UUID de la preuve sélectionnée, ou NULL.
* @param user_data Données privées fournies par l'appelant.
*/
typedef void (*EvidenceTreeViewSelectionCallback)(
const char *evidence_identifier,
gpointer user_data
);
/**
* @brief Crée une vue hiérarchique vide.
*
* @return Nouvelle vue, ou NULL.
*/
EvidenceTreeView *evidence_tree_view_new(void);
/**
* @brief Retourne le widget GTK racine.
*
* Le widget retourné est emprunté.
*
* @param evidence_tree_view Vue à consulter.
*
* @return GtkScrolledWindow racine, ou NULL.
*/
GtkWidget *evidence_tree_view_get_widget(
const EvidenceTreeView *evidence_tree_view
);
/**
* @brief Installe un modèle de catégories.
*
* La vue ne devient pas propriétaire de EvidenceCategoryModel.
* Le modèle doit rester valide pendant son utilisation.
*
* Passer NULL vide la vue.
*
* @param evidence_tree_view Vue à actualiser.
* @param evidence_category_model Modèle de catégories, ou NULL.
*/
void evidence_tree_view_set_model(
EvidenceTreeView *evidence_tree_view,
EvidenceCategoryModel *evidence_category_model
);
/**
* @brief Définit le callback de sélection.
*
* @param evidence_tree_view Vue à configurer.
* @param callback Callback facultatif.
* @param user_data Données privées transmises au callback.
*/
void evidence_tree_view_set_selection_callback(
EvidenceTreeView *evidence_tree_view,
EvidenceTreeViewSelectionCallback callback,
gpointer user_data
);
/**
* @brief Libère la structure d'encapsulation.
*
* Les widgets GTK intégrés dans la fenêtre restent gérés par l'arbre GTK.
* Cette fonction accepte NULL.
*
* @param evidence_tree_view Vue à libérer.
*/
void evidence_tree_view_free(
EvidenceTreeView *evidence_tree_view
);
G_END_DECLS
#endif

View file

@ -1,304 +0,0 @@
/******************************************************************************
* @file investigation_graph_view.h
* @brief Vue GTK en lecture seule du graphe d'enquête.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_INVESTIGATION_GRAPH_VIEW_H
#define LABFY_INVESTIGATION_INVESTIGATION_GRAPH_VIEW_H
#include <gtk/gtk.h>
G_BEGIN_DECLS
/**
* @brief Représentation opaque d'une entité métier.
*/
typedef struct EntityRecord EntityRecord;
/**
* @brief Représentation opaque d'une relation métier.
*/
typedef struct RelationRecord RelationRecord;
/**
* @brief Représentation opaque du graphe métier d'une enquête.
*/
typedef struct InvestigationGraphModel InvestigationGraphModel;
/**
* @brief Représentation opaque de la disposition persistée du graphe.
*/
typedef struct InvestigationGraphLayout InvestigationGraphLayout;
/**
* @brief Représentation opaque de la vue graphique.
*/
typedef struct InvestigationGraphView InvestigationGraphView;
/**
* @brief Callback appelé lors d'un changement de sélection.
*
* Les enregistrements sont empruntés au graphe et restent valides uniquement
* pendant l'appel. Un seul des deux pointeurs est non NULL. Deux pointeurs
* NULL représentent une désélection.
*
* @param entity_record Entité sélectionnée empruntée, ou NULL.
* @param relation_record Relation sélectionnée empruntée, ou NULL.
* @param user_data Données empruntées fournies par l'appelant.
*/
typedef void (*InvestigationGraphViewSelectionCallback)(
const EntityRecord *entity_record,
const RelationRecord *relation_record,
gpointer user_data
);
/**
* @brief Callback appelé après le déplacement effectif d'un nœud.
*
* Les coordonnées sont exprimées dans l'espace logique du graphe.
* node_identifier est emprunté au modèle et reste valable uniquement
* pendant l'appel.
*
* @param node_identifier UUID du nœud déplacé (entité ou relation).
* @param x Coordonnée horizontale logique du coin supérieur gauche.
* @param y Coordonnée verticale logique du coin supérieur gauche.
* @param user_data Données empruntées fournies par l'appelant.
*/
typedef void (*InvestigationGraphViewNodeMovedCallback)(
const char *node_identifier,
double x,
double y,
gpointer user_data
);
typedef void (*InvestigationGraphViewTransformChangedCallback)(
gpointer user_data
);
/** @brief Callback appelé lorsqu'une extraction est déposée sur le graphe. */
typedef void (*InvestigationGraphViewExtractionDropCallback)(
const char *file_path,
const char *target_entity_identifier,
gpointer user_data
);
/**
* @brief Crée une nouvelle vue graphique vide.
*
* La vue ne possède jamais le graphe qui lui est transmis.
*
* @return Nouvelle vue graphique, ou NULL en cas d'échec.
*/
InvestigationGraphView *investigation_graph_view_new(void);
/**
* @brief Retourne le widget GTK racine de la vue.
*
* Le widget appartient à InvestigationGraphView.
*
* @param graph_view Vue graphique à consulter.
*
* @return Widget GTK racine, ou NULL.
*/
GtkWidget *investigation_graph_view_get_widget(
const InvestigationGraphView *graph_view
);
/**
* @brief Associe un graphe métier à la vue.
*
* Le graphe est emprunté et doit rester valide pendant son affichage.
* Passer NULL équivaut à investigation_graph_view_clear().
*
* @param graph_view Vue graphique à mettre à jour.
* @param graph_model Graphe emprunté, ou NULL.
*/
void investigation_graph_view_set_graph(
InvestigationGraphView *graph_view,
const InvestigationGraphModel *graph_model
);
/**
* @brief Associe une disposition persistée à la vue.
*
* La disposition est empruntée et doit rester valide pendant son utilisation.
*
* Lorsqu'un graphe est déjà affiché, la disposition privée de ses nœuds est
* reconstruite. Une position persistée remplace la position automatique
* uniquement lorsqu'elle existe pour l'entité concernée.
*
* Passer NULL rétablit le placement automatique.
*
* @param graph_view Vue graphique à mettre à jour.
* @param graph_layout Disposition persistée empruntée, ou NULL.
*/
void investigation_graph_view_set_layout(
InvestigationGraphView *graph_view,
const InvestigationGraphLayout *graph_layout
);
/**
* @brief Détache le graphe actuellement affiché.
*
* Le graphe précédemment emprunté n'est pas libéré.
*
* @param graph_view Vue graphique à vider.
*/
void investigation_graph_view_clear(
InvestigationGraphView *graph_view
);
/**
* @brief Définit le callback de changement de sélection.
*
* Le callback et user_data sont empruntés par la vue.
*
* @param graph_view Vue graphique.
* @param callback Callback à appeler, ou NULL.
* @param user_data Données empruntées du callback.
*/
void investigation_graph_view_set_selection_callback(
InvestigationGraphView *graph_view,
InvestigationGraphViewSelectionCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback de fin de déplacement d'un nœud.
*
* Le callback et user_data sont empruntés par la vue.
*
* Le callback n'est appelé ni pendant le déplacement du canvas, ni pour
* un simple clic, ni pendant une reconstruction de la disposition.
*
* @param graph_view Vue graphique.
* @param callback Callback à appeler, ou NULL.
* @param user_data Données empruntées du callback.
*/
void investigation_graph_view_set_node_moved_callback(
InvestigationGraphView *graph_view,
InvestigationGraphViewNodeMovedCallback callback,
gpointer user_data
);
void investigation_graph_view_set_transform_changed_callback(
InvestigationGraphView *graph_view,
InvestigationGraphViewTransformChangedCallback callback,
gpointer user_data
);
void investigation_graph_view_set_extraction_drop_callback(
InvestigationGraphView *graph_view,
InvestigationGraphViewExtractionDropCallback callback,
gpointer user_data
);
/**
* @brief Retourne l'entité actuellement sélectionnée.
*
* Le pointeur retourné est emprunté au graphe.
*
* @param graph_view Vue graphique à consulter.
*
* @return Entité sélectionnée empruntée, ou NULL.
*/
const EntityRecord *investigation_graph_view_get_selected_entity(
const InvestigationGraphView *graph_view
);
/**
* @brief Sélectionne une entité du graphe par son UUID.
*
* Le nœud trouvé est placé au centre de la zone visible. La vue reste
* inchangée si l'identifiant est absent du graphe.
*
* @param graph_view Vue graphique à modifier.
* @param entity_identifier UUID de l'entité.
*
* @return TRUE lorsque l'entité est trouvée et sélectionnée.
*/
gboolean investigation_graph_view_select_entity(
InvestigationGraphView *graph_view,
const char *entity_identifier
);
/**
* @brief Sélectionne une relation du graphe par son UUID.
*
* @param graph_view Vue graphique à modifier.
* @param relation_identifier UUID de la relation.
*
* @return TRUE lorsque la relation est trouvée et sélectionnée.
*/
gboolean investigation_graph_view_select_relation(
InvestigationGraphView *graph_view,
const char *relation_identifier
);
/**
* @brief Supprime la sélection courante.
*
* Le callback reçoit NULL seulement si une sélection existait.
*
* @param graph_view Vue graphique.
*/
void investigation_graph_view_clear_selection(
InvestigationGraphView *graph_view
);
/**
* @brief Réinitialise le zoom et la position du canvas.
*
* Le graphe emprunté reste associé à la vue.
*
* @param graph_view Vue graphique à recentrer.
*/
void investigation_graph_view_reset_view(
InvestigationGraphView *graph_view
);
/**
* @brief Lit le zoom et le déplacement courants du canvas.
*
* @return TRUE lorsqu'un graphe est actuellement présenté.
*/
gboolean investigation_graph_view_get_view_transform(
const InvestigationGraphView *graph_view,
double *zoom,
double *offset_x,
double *offset_y
);
/** @brief Restaure un zoom et un déplacement précédemment lus. */
void investigation_graph_view_set_view_transform(
InvestigationGraphView *graph_view,
double zoom,
double offset_x,
double offset_y
);
/**
* @brief Restaure la disposition déterministe initiale des nœuds.
*
* Le zoom et le déplacement global du canvas sont conservés.
*
* @param graph_view Vue graphique à réorganiser.
*/
void investigation_graph_view_reset_layout(
InvestigationGraphView *graph_view
);
/**
* @brief Libère la structure d'encapsulation de la vue.
*
* Cette fonction accepte NULL et ne libère jamais le graphe emprunté.
*
* @param graph_view Vue graphique à libérer.
*/
void investigation_graph_view_free(
InvestigationGraphView *graph_view
);
G_END_DECLS
#endif

View file

@ -1,103 +0,0 @@
/******************************************************************************
* @file investigation_tree_view.h
* @brief Interface publique de la vue arborescente d'une enquête.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_INVESTIGATION_TREE_VIEW_H
#define LABFY_INVESTIGATION_INVESTIGATION_TREE_VIEW_H
#include "core/investigation_tree_model.h"
#include <gtk/gtk.h>
/**
* @brief Représentation opaque de la vue arborescente d'une enquête.
*
* La structure réelle est définie dans investigation_tree_view.c.
* Les autres modules manipulent uniquement un pointeur vers
* InvestigationTreeView.
*/
typedef struct InvestigationTreeView InvestigationTreeView;
/**
* @brief Callback appelé lorsqu'un nœud est sélectionné.
*
* @param node Nœud sélectionné, ou NULL si aucune sélection n'est active.
* @param user_data Données privées fournies lors de l'enregistrement
* du callback.
*/
typedef void (*InvestigationTreeViewSelectionCallback)(
const InvestigationNode *node,
gpointer user_data
);
/**
* @brief Définit le callback de sélection.
*
* Le callback est appelé à chaque changement de sélection.
*
* Le composant ne devient pas propriétaire du callback ni de user_data.
*
* @param tree_view Vue arborescente.
* @param callback Fonction appelée lors d'une sélection.
* @param user_data Données privées transmises au callback.
*/
void investigation_tree_view_set_selection_callback(
InvestigationTreeView *tree_view,
InvestigationTreeViewSelectionCallback callback,
gpointer user_data
);
/**
* @brief Crée une nouvelle vue arborescente vide.
*
* Aucun modèle métier n'est associé au composant lors de sa création.
*
* @return Une nouvelle vue, ou NULL si sa création échoue.
*/
InvestigationTreeView *investigation_tree_view_new(void);
/**
* @brief Retourne le widget GTK racine de la vue arborescente.
*
* Le widget retourné appartient au module InvestigationTreeView.
* Le code appelant ne doit ni le détruire ni en libérer la référence.
*
* @param tree_view Vue arborescente à consulter.
*
* @return Le widget racine, ou NULL si tree_view vaut NULL.
*/
GtkWidget *investigation_tree_view_get_widget(
const InvestigationTreeView *tree_view
);
/**
* @brief Associe un modèle métier à la vue arborescente.
*
* La vue ne devient pas propriétaire du modèle. Le modèle doit rester valide
* pendant toute la durée de son affichage.
*
* Passer NULL retire le modèle actuel et vide la vue.
*
* @param tree_view Vue arborescente à mettre à jour.
* @param tree_model Modèle métier en lecture seule, ou NULL.
*/
void investigation_tree_view_set_model(
InvestigationTreeView *tree_view,
const InvestigationTreeModel *tree_model
);
/**
* @brief Libère la structure d'encapsulation de la vue.
*
* Cette fonction accepte NULL.
*
* Les objets GTK intégrés à l'arbre de widgets restent gérés par GTK.
*
* @param tree_view Vue arborescente à libérer.
*/
void investigation_tree_view_free(
InvestigationTreeView *tree_view
);
#endif

View file

@ -1,28 +0,0 @@
#ifndef LABFY_OCR_PROVENANCE_OVERLAY_H
#define LABFY_OCR_PROVENANCE_OVERLAY_H
#include "models/identity_ocr.h"
#include <gtk/gtk.h>
typedef struct OcrProvenanceOverlay OcrProvenanceOverlay;
OcrProvenanceOverlay *ocr_provenance_overlay_new(void);
void ocr_provenance_overlay_free(OcrProvenanceOverlay *overlay);
GtkWidget *ocr_provenance_overlay_get_widget(OcrProvenanceOverlay *overlay);
void ocr_provenance_overlay_set_image(OcrProvenanceOverlay *overlay,
GBytes *png_bytes, guint64 generation);
void ocr_provenance_overlay_set_field(OcrProvenanceOverlay *overlay,
const IdentityFieldObservation *field, guint64 generation);
void ocr_provenance_overlay_clear(OcrProvenanceOverlay *overlay,
guint64 generation);
void ocr_provenance_overlay_set_page(
OcrProvenanceOverlay *overlay, guint page, guint64 generation);
void ocr_provenance_overlay_zoom_in(OcrProvenanceOverlay *overlay);
void ocr_provenance_overlay_zoom_out(OcrProvenanceOverlay *overlay);
void ocr_provenance_overlay_fit(OcrProvenanceOverlay *overlay);
double ocr_provenance_overlay_get_zoom(
const OcrProvenanceOverlay *overlay);
gboolean ocr_provenance_overlay_has_region(
const OcrProvenanceOverlay *overlay);
#endif

View file

@ -1,190 +0,0 @@
/******************************************************************************
* @file sidebar.h
* @brief Interface publique du panneau de navigation latéral.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_SIDEBAR_H
#define LABFY_INVESTIGATION_SIDEBAR_H
#include "core/investigation_tree_model.h"
#include "widgets/investigation_tree_view.h"
#include "widgets/evidence_category_model.h"
#include "models/investigation_graph_model.h"
#include <gtk/gtk.h>
/**
* @brief Représentation opaque du panneau latéral.
*
* La structure réelle est définie dans sidebar.c.
* Les autres modules manipulent uniquement un pointeur vers Sidebar.
*/
typedef struct Sidebar Sidebar;
/**
* @brief Callback appelé lorsqu'une preuve est sélectionnée.
*
* L'identifiant est emprunté et uniquement valide pendant l'appel.
* Une sélection vide ou une catégorie transmet NULL.
*
* @param evidence_identifier UUID de la preuve, ou NULL.
* @param user_data Données privées fournies par l'appelant.
*/
typedef void (*SidebarEvidenceSelectionCallback)(
const char *evidence_identifier,
gpointer user_data
);
/**
* @brief Callback appelé lorsqu'une entité est sélectionnée.
*
* L'identifiant est emprunté et valide uniquement pendant l'appel.
* Une sélection vide transmet NULL.
*
* @param entity_identifier UUID de l'entité, ou NULL.
* @param user_data Données privées fournies par l'appelant.
*/
typedef void (*SidebarEntitySelectionCallback)(
const char *entity_identifier,
gpointer user_data
);
/**
* @brief Callback appelé lorsqu'une relation est sélectionnée.
*
* @param relation_identifier UUID emprunté de la relation, ou NULL.
* @param user_data Données privées fournies par l'appelant.
*/
typedef void (*SidebarRelationSelectionCallback)(
const char *relation_identifier,
gpointer user_data
);
/**
* @brief Crée un nouveau panneau de navigation latéral.
*
* @return Un nouveau panneau latéral, ou NULL en cas d'échec.
*/
Sidebar *sidebar_new(void);
/**
* @brief Retourne le widget GTK racine du panneau latéral.
*
* Le widget retourné appartient au module Sidebar et ne doit pas être libéré
* directement par le code appelant.
*
* @param sidebar Panneau latéral à consulter.
*
* @return Le widget GTK racine, ou NULL si sidebar est NULL.
*/
GtkWidget *sidebar_get_widget(
const Sidebar *sidebar
);
/**
* @brief Associe un modèle d'arborescence à la barre latérale.
*
* La barre latérale ne devient pas propriétaire du modèle.
* Le modèle doit rester valide pendant toute la durée de son utilisation.
*
* Pour ce ticket, seul le nom du nœud racine est affiché dans le titre.
*
* @param sidebar Barre latérale à mettre à jour.
* @param tree_model Modèle d'arborescence en lecture seule.
*/
void sidebar_set_tree_model(
Sidebar *sidebar,
const InvestigationTreeModel *tree_model
);
/**
* @brief Définit le callback appelé lors de la sélection d'un nœud.
*
* La Sidebar transmet simplement ce callback à InvestigationTreeView.
*
* @param sidebar Barre latérale à configurer.
* @param callback Fonction appelée lors d'un changement de sélection.
* @param user_data Données privées transmises au callback.
*/
void sidebar_set_selection_callback(
Sidebar *sidebar,
InvestigationTreeViewSelectionCallback callback,
gpointer user_data
);
/**
* @brief Installe le modèle des catégories de preuves.
*
* La Sidebar ne devient pas propriétaire du modèle.
* Passer NULL rétablit l'état vide.
*
* @param sidebar Barre latérale à actualiser.
* @param evidence_category_model Modèle de catégories, ou NULL.
*/
void sidebar_set_evidence_model(
Sidebar *sidebar,
EvidenceCategoryModel *evidence_category_model
);
/**
* @brief Définit le callback de sélection d'une preuve.
*
* @param sidebar Barre latérale à configurer.
* @param callback Callback facultatif.
* @param user_data Données privées transmises au callback.
*/
void sidebar_set_evidence_selection_callback(
Sidebar *sidebar,
SidebarEvidenceSelectionCallback callback,
gpointer user_data
);
/**
* @brief Installe les entités et relations du graphe dans la barre latérale.
*
* La Sidebar emprunte graph_model. Passer NULL vide la liste.
*
* @param sidebar Barre latérale à actualiser.
* @param graph_model Graphe emprunté, ou NULL.
*/
void sidebar_set_graph_model(
Sidebar *sidebar,
const InvestigationGraphModel *graph_model
);
/**
* @brief Définit le callback de sélection d'une entité.
*
* @param sidebar Barre latérale à configurer.
* @param callback Callback facultatif.
* @param user_data Données privées transmises au callback.
*/
void sidebar_set_entity_selection_callback(
Sidebar *sidebar,
SidebarEntitySelectionCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback de sélection d'une relation.
*
* @param sidebar Barre latérale à configurer.
* @param callback Callback facultatif.
* @param user_data Données privées transmises au callback.
*/
void sidebar_set_relation_selection_callback(
Sidebar *sidebar,
SidebarRelationSelectionCallback callback,
gpointer user_data
);
/**
* @brief Libère la structure d'encapsulation du panneau latéral.
*
* Cette fonction accepte NULL.
*
* @param sidebar Panneau latéral à libérer.
*/
void sidebar_free(Sidebar *sidebar);
#endif

View file

@ -1,66 +0,0 @@
/******************************************************************************
* @file task_panel.h
* @brief Panneau GTK affichant les tâches suivies par TaskManager.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_TASK_PANEL_H
#define LABFY_INVESTIGATION_TASK_PANEL_H
#include "core/task_manager.h"
#include <gtk/gtk.h>
/**
* @brief Représentation opaque du panneau d’activité.
*/
typedef struct TaskPanel TaskPanel;
/**
* @brief Crée un panneau affichant les tâches d’un gestionnaire.
*
* Le panneau ne devient pas propriétaire de task_manager.
* Le gestionnaire doit rester valide pendant toute la durée de vie
* du panneau.
*
* @param task_manager Gestionnaire de tâches observé.
*
* @return Nouveau panneau, ou NULL si le gestionnaire est invalide.
*/
TaskPanel *task_panel_new(
TaskManager *task_manager
);
/**
* @brief Retourne le widget racine du panneau.
*
* Le pointeur retourné est emprunté.
*
* @param task_panel Panneau concerné.
*
* @return Widget racine, ou NULL.
*/
GtkWidget *task_panel_get_widget(
const TaskPanel *task_panel
);
/**
* @brief Reconstruit l’affichage à partir du gestionnaire.
*
* @param task_panel Panneau à actualiser.
*/
void task_panel_refresh(
TaskPanel *task_panel
);
/**
* @brief Libère le panneau et arrête son rafraîchissement périodique.
*
* Le gestionnaire associé n’est pas libéré.
*
* @param task_panel Panneau à libérer.
*/
void task_panel_free(
TaskPanel *task_panel
);
#endif

View file

@ -1,552 +0,0 @@
/******************************************************************************
* @file workspace.h
* @brief Interface publique de la zone principale de travail.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_WORKSPACE_H
#define LABFY_INVESTIGATION_WORKSPACE_H
#include "core/investigation_node.h"
#include "models/evidence_record.h"
#include "models/evidence_observation.h"
#include "models/entity_record.h"
#include "models/osint_action_catalog.h"
#include "models/identity_ocr.h"
#include "core/task_manager.h"
#include <gtk/gtk.h>
/**
* @brief Représentation opaque du graphe d'enquête.
*/
typedef struct InvestigationGraphModel InvestigationGraphModel;
/**
* @brief Représentation opaque de la disposition du graphe.
*/
typedef struct InvestigationGraphLayout InvestigationGraphLayout;
/**
* @brief Représentation opaque de la zone de travail.
*/
typedef struct Workspace Workspace;
/**
* @brief Callback appelé lors d'une demande de vérification d'une preuve.
*
* @param evidence_identifier UUID de la preuve affichée.
* @param user_data Données empruntées fournies par l'appelant.
*/
typedef void (*WorkspaceVerifyEvidenceCallback)(
const char *evidence_identifier,
gpointer user_data
);
/**
* @brief Callback appelé après le déplacement effectif d'un nœud.
*
* Les coordonnées sont exprimées dans l'espace logique du graphe.
*
* @param node_identifier UUID du nœud déplacé.
* @param x Coordonnée horizontale logique.
* @param y Coordonnée verticale logique.
* @param user_data Données empruntées fournies par l'appelant.
*/
typedef void (*WorkspaceGraphNodeMovedCallback)(
const char *node_identifier,
double x,
double y,
gpointer user_data
);
typedef void (*WorkspaceGraphTransformChangedCallback)(gpointer user_data);
typedef void (*WorkspaceExtractionDropCallback)(
const char *file_path,
const char *target_entity_identifier,
gpointer user_data
);
/**
* @brief Callback appelé lors d'une demande de réinitialisation du graphe.
*
* Le Workspace ne modifie ni SQLite ni le modèle de disposition. Il relaie
* uniquement la demande vers la couche applicative.
*
* @param user_data Données empruntées fournies par l'appelant.
*/
typedef void (*WorkspaceResetGraphLayoutCallback)(
gpointer user_data
);
/**
* @brief Callback appelé lors d'une demande d'ajout d'une relation.
*
* @param source_entity_identifier UUID de l'entité source.
* @param user_data Données empruntées fournies par l'appelant.
*/
typedef void (*WorkspaceAddRelationCallback)(
const char *source_entity_identifier,
gpointer user_data
);
/** @brief Callback appelé pour modifier la relation sélectionnée. */
typedef void (*WorkspaceEditRelationCallback)(
const char *relation_identifier,
gpointer user_data
);
/** @brief Callback appelé pour supprimer la relation sélectionnée. */
typedef void (*WorkspaceDeleteRelationCallback)(
const char *relation_identifier, gpointer user_data);
/** @brief Callback appelé quand une relation devient sélectionnée. */
typedef void (*WorkspaceRelationSelectedCallback)(
const char *relation_identifier,
gpointer user_data
);
/** @brief Callback appelé pour catégoriser une personne. */
typedef void (*WorkspacePersonRoleCallback)(const char *entity_identifier,
PersonRole role, gpointer user_data);
/** @brief Callback appelé pour modifier la confiance d'une personne. */
typedef void (*WorkspacePersonConfidenceCallback)(
const char *entity_identifier, gint confidence, gpointer user_data);
/**
* @brief Callback appelé lors du déclenchement d'une action OSINT.
*
* Les chaînes sont empruntées et uniquement valides pendant l'appel.
*
* @param action_identifier Identifiant stable de l'action.
* @param target_identifier UUID de la sélection ciblée.
* @param target_value Valeur métier ciblée par l'action.
* @param user_data Données empruntées fournies par l'appelant.
*/
typedef void (*WorkspaceOsintActionCallback)(
const char *action_identifier,
const char *target_identifier,
const char *target_value,
gpointer user_data
);
/** @brief Callback appelé pour modifier le nom affiché d'une personne. */
typedef void (*WorkspacePersonNameCallback)(const char *entity_identifier,
const char *display_name, gpointer user_data);
/** @brief Callback appelé pour gérer les preuves d'une personne. */
typedef void (*WorkspacePersonEvidenceCallback)(
const char *entity_identifier, gpointer user_data);
typedef void (*WorkspaceEvidenceActivatedCallback)(
const char *evidence_identifier, gpointer user_data);
/** @brief Callback appelé quand une entité devient sélectionnée. */
typedef void (*WorkspaceEntitySelectedCallback)(
const char *entity_identifier, gpointer user_data);
/** @brief Callback appelé pour modifier les métadonnées d'une preuve. */
typedef void (*WorkspaceEditEvidenceCallback)(
const char *evidence_identifier,
gpointer user_data
);
/** @brief Callback appelé pour analyser une preuve EML. */
typedef void (*WorkspaceAnalyzeEmlCallback)(const char *evidence_identifier,
gpointer user_data);
/** @brief Callback appelé pour analyser un RIB par OCR. */
typedef void (*WorkspaceAnalyzeRibCallback)(const char *evidence_identifier,
gpointer user_data);
/** @brief Callback appelé pour extraire les métadonnées d'une preuve. */
typedef void (*WorkspaceExtractMetadataCallback)(const char *evidence_identifier,
gpointer user_data);
/** @brief Callback appelé pour récupérer le mot de passe d'un PDF. */
typedef void (*WorkspaceRecoverPdfPasswordCallback)(
const char *evidence_identifier, gpointer user_data);
typedef void (*WorkspaceObservationRemoveCallback)(
const char *observation_identifier, gpointer user_data);
typedef void (*WorkspaceIdentityOcrCallback)(
const char *evidence_identifier, gboolean revise_existing,
const char *ocr_run_identifier, gpointer user_data);
typedef struct {
IdentityOcrRun *run;
char *person_identifier;
char *executed_at;
char *text_relative_path;
char *text_sha256;
char *tsv_relative_path;
char *tsv_sha256;
} WorkspaceIdentityOcrRecord;
WorkspaceIdentityOcrRecord *workspace_identity_ocr_record_new(
IdentityOcrRun *owned_run, const char *person_identifier,
const char *executed_at, const char *text_relative_path,
const char *text_sha256, const char *tsv_relative_path,
const char *tsv_sha256);
void workspace_identity_ocr_record_free(WorkspaceIdentityOcrRecord *record);
/**
* @brief Crée une nouvelle zone de travail.
*
* @return Une nouvelle zone de travail, ou NULL en cas d'échec.
*/
Workspace *workspace_new(TaskManager *task_manager);
/**
* @brief Retourne le widget GTK racine de la zone de travail.
*
* Le widget retourné appartient au module Workspace.
*
* @param workspace Zone de travail à consulter.
*
* @return Widget racine, ou NULL.
*/
GtkWidget *workspace_get_widget(
const Workspace *workspace
);
/**
* @brief Affiche les informations d'un nœud sélectionné.
*
* Le Workspace emprunte le nœud.
* Passer NULL restaure l'état principal courant.
*
* @param workspace Zone de travail.
* @param node Nœud emprunté, ou NULL.
*/
void workspace_set_selected_node(
Workspace *workspace,
const InvestigationNode *node
);
/**
* @brief Affiche la fiche détaillée d'une preuve.
*
* Les textes sont copiés dans les widgets. Le Workspace ne conserve pas le
* pointeur EvidenceRecord.
*
* Passer NULL restaure l'état principal courant.
*
* @param workspace Zone de travail.
* @param evidence_record Preuve empruntée, ou NULL.
*/
void workspace_set_selected_evidence(
Workspace *workspace,
const EvidenceRecord *evidence_record
);
void workspace_set_identity_ocr_runs(
Workspace *workspace, GPtrArray *owned_records);
void workspace_set_identity_ocr_callback(
Workspace *workspace, WorkspaceIdentityOcrCallback callback,
gpointer user_data);
void workspace_set_evidence_observations(Workspace *workspace,
const GPtrArray *observations);
void workspace_set_observation_remove_callback(Workspace *workspace,
WorkspaceObservationRemoveCallback callback, gpointer user_data);
void workspace_set_eml_analysis_available(
Workspace *workspace,
gboolean available
);
/**
* @brief Affiche l'aperçu local d'un fichier image ou vidéo.
*
* file_path doit être un chemin absolu déjà validé par l'application.
* Passer NULL libère l'aperçu courant.
*/
void workspace_set_evidence_preview(Workspace *workspace,
const char *file_path, const char *display_name);
void workspace_show_evidence_preview(Workspace *workspace,
const char *investigation_root, const EvidenceRecord *record);
/**
* @brief Sélectionne une entité dans le graphe affiché.
*
* La sélection actualise également le volet de détails et le contexte OSINT.
*
* @param workspace Zone de travail.
* @param entity_identifier UUID de l'entité à sélectionner.
*
* @return TRUE lorsque l'entité est trouvée et sélectionnée.
*/
gboolean workspace_select_graph_entity(
Workspace *workspace,
const char *entity_identifier
);
/**
* @brief Sélectionne une relation dans le graphe affiché.
*
* @param workspace Zone de travail.
* @param relation_identifier UUID de la relation à sélectionner.
*
* @return TRUE lorsque la relation est trouvée et sélectionnée.
*/
gboolean workspace_select_graph_relation(
Workspace *workspace,
const char *relation_identifier
);
/**
* @brief Actualise l'état d'un outil requis par les actions OSINT.
*
* @param workspace Zone de travail.
* @param tool_identifier Identifiant stable de l'outil.
* @param state État traduit par la couche applicative.
* @param version Version détectée facultative.
*/
void workspace_set_osint_tool_state(
Workspace *workspace,
const char *tool_identifier,
OsintActionToolState state,
const char *version
);
/**
* @brief Définit le callback de déclenchement des actions OSINT.
*
* Le Workspace ne lance aucun outil externe. Il relaie uniquement l'action
* et la valeur du contexte courant vers la couche applicative.
*
* @param workspace Zone de travail.
* @param callback Callback facultatif.
* @param user_data Données empruntées transmises au callback.
*/
void workspace_set_osint_action_callback(
Workspace *workspace,
WorkspaceOsintActionCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback de modification de la preuve sélectionnée.
* @param workspace Zone de travail.
* @param callback Callback facultatif.
* @param user_data Données empruntées transmises au callback.
*/
void workspace_set_edit_evidence_callback(
Workspace *workspace,
WorkspaceEditEvidenceCallback callback,
gpointer user_data
);
/** @brief Définit le callback d'analyse locale d'une preuve EML. */
void workspace_set_analyze_eml_callback(Workspace *workspace,
WorkspaceAnalyzeEmlCallback callback, gpointer user_data);
/** @brief Définit le callback d'analyse OCR d'un RIB. */
void workspace_set_analyze_rib_callback(Workspace *workspace,
WorkspaceAnalyzeRibCallback callback, gpointer user_data);
/** @brief Définit le callback d'extraction locale des métadonnées. */
void workspace_set_extract_metadata_callback(Workspace *workspace,
WorkspaceExtractMetadataCallback callback, gpointer user_data);
/** @brief Définit le callback de récupération d'un mot de passe PDF. */
void workspace_set_recover_pdf_password_callback(Workspace *workspace,
WorkspaceRecoverPdfPasswordCallback callback, gpointer user_data);
/**
* @brief Définit le callback de vérification de la preuve affichée.
*
* Le Workspace emprunte callback et user_data.
*
* @param workspace Zone de travail.
* @param callback Callback à utiliser, ou NULL.
* @param user_data Données empruntées du callback.
*/
void workspace_set_verify_evidence_callback(
Workspace *workspace,
WorkspaceVerifyEvidenceCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback de fin de déplacement d'un nœud.
*
* Le Workspace relaie uniquement l'événement provenant de la vue graphique.
*
* @param workspace Zone de travail.
* @param callback Callback facultatif.
* @param user_data Données empruntées transmises au callback.
*/
void workspace_set_graph_node_moved_callback(
Workspace *workspace,
WorkspaceGraphNodeMovedCallback callback,
gpointer user_data
);
void workspace_set_graph_transform_changed_callback(
Workspace *workspace,
WorkspaceGraphTransformChangedCallback callback,
gpointer user_data);
void workspace_set_extraction_drop_callback(
Workspace *workspace,
WorkspaceExtractionDropCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback de demande de réinitialisation du graphe.
*
* Le Workspace emprunte callback et user_data.
*
* @param workspace Zone de travail.
* @param callback Callback facultatif.
* @param user_data Données empruntées transmises au callback.
*/
void workspace_set_reset_graph_layout_callback(
Workspace *workspace,
WorkspaceResetGraphLayoutCallback callback,
gpointer user_data
);
/**
* @brief Définit le callback de demande d'ajout d'une relation.
*
* Le Workspace relaie uniquement l'événement du volet de détails.
*
* @param workspace Zone de travail.
* @param callback Callback facultatif.
* @param user_data Données empruntées transmises au callback.
*/
void workspace_set_add_relation_callback(
Workspace *workspace,
WorkspaceAddRelationCallback callback,
gpointer user_data
);
/** @brief Définit le callback de modification d'une relation. */
void workspace_set_edit_relation_callback(
Workspace *workspace,
WorkspaceEditRelationCallback callback,
gpointer user_data
);
/** @brief Définit le callback de suppression d'une relation. */
void workspace_set_delete_relation_callback(Workspace *workspace,
WorkspaceDeleteRelationCallback callback, gpointer user_data);
/**
* @brief Définit le callback de sélection d'une relation.
*
* @param workspace Zone de travail.
* @param callback Callback facultatif.
* @param user_data Données empruntées transmises au callback.
*/
void workspace_set_relation_selected_callback(
Workspace *workspace,
WorkspaceRelationSelectedCallback callback,
gpointer user_data
);
/**
* @brief Affiche les preuves associées à la relation sélectionnée.
*
* @param workspace Zone de travail.
* @param evidence_records Tableau emprunté de EvidenceRecord, ou NULL.
*/
void workspace_set_relation_evidences(
Workspace *workspace,
const GPtrArray *evidence_records
);
/** @brief Définit le callback de catégorisation d'une personne. */
void workspace_set_person_role_callback(Workspace *workspace,
WorkspacePersonRoleCallback callback, gpointer user_data);
/** @brief Définit le callback de confiance d'une personne. */
void workspace_set_person_confidence_callback(Workspace *workspace,
WorkspacePersonConfidenceCallback callback, gpointer user_data);
/**
* @brief Affiche l'état de chargement du graphe.
*
* L'ancien graphe emprunté est détaché.
*
* @param workspace Zone de travail.
*/
void workspace_set_graph_loading(
Workspace *workspace
);
/** @brief Définit le callback de modification du nom affiché. */
void workspace_set_person_name_callback(Workspace *workspace,
WorkspacePersonNameCallback callback, gpointer user_data);
/** @brief Définit le callback de gestion des preuves d'une personne. */
void workspace_set_person_evidence_callback(Workspace *workspace,
WorkspacePersonEvidenceCallback callback, gpointer user_data);
void workspace_set_evidence_activated_callback(
Workspace *workspace,
WorkspaceEvidenceActivatedCallback callback,
gpointer user_data);
/** @brief Définit le callback de sélection d'une entité. */
void workspace_set_entity_selected_callback(Workspace *workspace,
WorkspaceEntitySelectedCallback callback, gpointer user_data);
/** @brief Affiche les preuves de la personne sélectionnée. */
void workspace_set_person_evidences(Workspace *workspace,
const GPtrArray *records);
void workspace_set_person_factual_relations(Workspace *workspace,
const GPtrArray *relations, const GPtrArray *evidence_records,const GHashTable *profile_fields);
/**
* @brief Affiche le graphe chargé avec sa disposition persistée.
*
* Le Workspace emprunte graph_model et graph_layout et ne les libère jamais.
*
* Passer NULL pour graph_model équivaut à workspace_clear_graph().
* Passer NULL pour graph_layout conserve le placement automatique.
*
* @param workspace Zone de travail.
* @param graph_model Graphe emprunté.
* @param graph_layout Disposition empruntée, ou NULL.
*/
void workspace_set_graph(
Workspace *workspace,
const InvestigationGraphModel *graph_model,
const InvestigationGraphLayout *graph_layout
);
gboolean workspace_get_graph_view_transform(
Workspace *workspace, double *zoom, double *offset_x, double *offset_y);
void workspace_set_graph_view_transform(
Workspace *workspace, double zoom, double offset_x, double offset_y);
/**
* @brief Affiche une erreur de chargement du graphe.
*
* Le texte du message est immédiatement copié par GTK.
*
* @param workspace Zone de travail.
* @param message Détail de l'erreur, ou NULL.
*/
void workspace_set_graph_error(
Workspace *workspace,
const char *message
);
/**
* @brief Détache le graphe et restaure l'état sans enquête.
*
* Le graphe précédemment emprunté n'est jamais libéré par le Workspace.
*
* @param workspace Zone de travail.
*/
void workspace_clear_graph(
Workspace *workspace
);
/**
* @brief Reconstruit visuellement la disposition automatique du graphe.
*
* Cette fonction ne touche ni SQLite ni InvestigationGraphLayout. Elle doit
* être appelée par la couche applicative uniquement après la suppression
* réussie des positions persistées.
*
* Le zoom et le déplacement global du canvas sont conservés.
*
* @param workspace Zone de travail.
*/
void workspace_reset_graph_layout(
Workspace *workspace
);
/**
* @brief Libère la structure Workspace.
*
* Cette fonction accepte NULL. Elle ne libère jamais le graphe emprunté.
*
* @param workspace Zone de travail à libérer.
*/
void workspace_free(
Workspace *workspace
);
#endif

View file

@ -111,6 +111,7 @@ let navigationHistory = [];
let collapsedGroups = new Set();
let filters = {};
let scale = 1;
let graph_scale_initialized = false;
let offset = { x: 0, y: 0 };
let keyboardIndex = -1;
let eventSource = null;
@ -126,6 +127,7 @@ let correlationsRefreshing = false;
let correlationRevision = null;
let plannerRevision = null;
let plannerValue = null;
let researchValue = null;
let specializedView = "graph";
const pinnedPositions = new Map();
const reportSelection = new Set();
@ -156,6 +158,7 @@ function invalidateWorkspaceContext() {
jobsRefreshing = false;
graphRefreshing = false;
correlationsRefreshing = false;
researchValue = null;
}
function beginWorkspaceContext(workspaceId, generation) {
@ -734,6 +737,148 @@ async function refreshPlanner() {
}
}
function researchDecision(actionId) {
return document.querySelector(
`input[name="research-${CSS.escape(actionId)}"]:checked`,
)?.value ?? "defer";
}
function renderResearch() {
// INVARIANT: toutes les cartes proviennent du snapshot C ; aucune action ou
// adresse de fournisseur n'est construite ni conservée comme vérité JS.
const panel = byId("research-plan");
panel.replaceChildren();
if (!researchValue || researchValue.state === "EMPTY") {
byId("research-launch").disabled = true;
byId("research-revoke").disabled = true;
return;
}
const heading = document.createElement("h4");
heading.textContent = `Question : ${researchValue.question}`;
panel.append(heading);
const waveOneDone = researchValue.actions.some(
(action) => action.wave === 1 && action.contacted,
);
for (const action of researchValue.actions) {
const card = document.createElement("article");
card.className = "research-card";
const title = document.createElement("h5");
title.textContent = `Vague ${action.wave} · ${action.capability_id}`;
const explanation = document.createElement("p");
explanation.textContent = action.wave === 1
? "Gain attendu : obtenir un fait sourcé. Incertitude : le fournisseur peut ne rien connaître."
: action.wave === 2
? "Nouvelle action non contactée : vérifier ou contredire le premier résultat."
: "Action optionnelle distincte ; aucun contact sans autorisation explicite.";
const provenance = document.createElement("p");
provenance.textContent = `Source/provenance : sélection du graphe · sujet exact : ${action.subject}`;
const disclosure = document.createElement("p");
disclosure.textContent = `Fournisseur : ${action.provider_id} · contact ${action.contact} · ${action.disclosure}`;
const budget = document.createElement("p");
budget.textContent = `Budget maximal : ${action.max_requests} requête · ${action.max_response_bytes} octets · ${action.max_active_ms} ms.`;
const status = document.createElement("p");
status.className = action.contacted ? "research-contacted" : "research-uncontacted";
status.textContent = action.contacted
? `Résultat : ${action.result_status || "MISSING"}`
: "Non contactée — aucune donnée fournisseur reçue.";
const choices = document.createElement("fieldset");
choices.disabled = action.contacted || (action.wave === 2 && !waveOneDone);
const legend = document.createElement("legend");
legend.textContent = "Décision humaine";
choices.append(legend);
for (const [value, label] of [["authorize", "Autoriser"],
["defer", "Différer"], ["refuse", "Refuser"]]) {
const choice = document.createElement("label");
const input = document.createElement("input");
input.type = "radio";
input.name = `research-${action.action_id}`;
input.value = value;
input.checked = value === (action.decision?.toLowerCase() ?? "defer");
input.addEventListener("change", () => {
byId("research-launch").disabled = !researchValue.actions.some(
(item) => !item.contacted && researchDecision(item.action_id) === "authorize",
);
});
choice.append(input, ` ${label}`);
choices.append(choice);
}
card.append(title, explanation, provenance, disclosure, budget, status, choices);
panel.append(card);
}
byId("research-launch").disabled = true;
byId("research-revoke").disabled = !researchValue.grants.some(
(grant) => !grant.revoked_at,
);
}
async function refreshResearch() {
if (!operationalMode) return;
const response = await workspaceFetch("/api/v1/research", { cache: "no-store" });
const value = await workspaceJson(response);
if (!response.ok || value.contract !== "labfy.research.snapshot.v1")
throw new Error(value.message ?? "Recherche indisponible");
researchValue = value;
renderResearch();
}
async function prepareResearch() {
const node = selectedNode();
if (!node) throw new Error("Sélectionnez d’abord une preuve ou une entité.");
const exclusions = byId("research-exclusions").value.split("\n")
.map((item) => item.trim()).filter(Boolean);
researchValue = await postCommand("/api/v1/research/prepare", {
selection_ids: [node.id],
question: byId("research-question").value.trim(),
exclusions,
idempotency_key: crypto.randomUUID(),
});
byId("research-note").textContent =
"Plan préparé par le cœur C. Choisissez chaque action avant le lancement final.";
renderResearch();
}
async function launchResearch() {
const actionIds = researchValue.actions.filter((action) =>
!action.contacted && researchDecision(action.action_id) === "authorize")
.map((action) => action.action_id);
if (!actionIds.length) return;
const exclusions = byId("research-exclusions").value.split("\n")
.map((item) => item.trim()).filter(Boolean);
const decisions = researchValue.actions.map((action) => ({
action_id: action.action_id,
decision: researchDecision(action.action_id).toUpperCase(),
}));
const grant = await postCommand("/api/v1/research/grants", {
plan_id: researchValue.plan_id,
input_revision: researchValue.input_revision,
selected_action_ids: actionIds,
decisions,
exclusions,
idempotency_key: crypto.randomUUID(),
});
const grantId = grant.grants[0]?.grant_id;
if (!grantId) throw new Error("Autorisation durable absente.");
researchValue = await postCommand("/api/v1/research/campaigns", {
grant_id: grantId,
input_revision: grant.input_revision,
action_ids: actionIds,
idempotency_key: crypto.randomUUID(),
});
byId("research-note").textContent =
"Campagne terminée ; résultats sourcés et actions non contactées affichés.";
renderResearch();
}
async function revokeResearch() {
const grant = [...researchValue.grants].reverse().find((item) => !item.revoked_at);
if (!grant) return;
researchValue = await postCommand("/api/v1/research/revoke", {
grant_id: grant.grant_id,
});
byId("research-note").textContent = "Autorisation révoquée durablement.";
renderResearch();
}
const renderer = new GraphRenderer({
svg,
viewport: byId("viewport"),
@ -1233,7 +1378,9 @@ function resetGlobalView() {
focusNodeId = null;
filters = {};
collapsedGroups.clear();
scale = 1;
// CONTRACT: « Réinitialiser » revient à la vue globale visible. Une échelle
// fixe à 1 ferait sortir le snapshot cœur sous les panneaux persistants.
scale = snapshot.nodes.length > 0 ? fitGraphScale() : 1;
offset = { x: 0, y: 0 };
keyboardIndex = -1;
specializedView = "graph";
@ -1338,6 +1485,24 @@ function updateProjectionControls() {
}
function configureControls() {
// CONTRACT: la disposition est une préférence locale par origine ; elle ne
// transporte ni l'enquête, ni un secret, ni un état métier vers le navigateur.
for (const button of document.querySelectorAll(".pane-toggle")) {
const pane = byId(button.dataset.pane);
const key = `labfy-pane:${button.dataset.pane}`;
const apply = (collapsed) => {
pane.classList.toggle("pane-collapsed", collapsed);
button.setAttribute("aria-expanded", String(!collapsed));
button.textContent = collapsed ? "Déployer" : "Réduire";
requestAnimationFrame(() => renderer.updateViewport(scale, offset));
};
apply(localStorage.getItem(key) === "collapsed");
button.addEventListener("click", () => {
const collapsed = !pane.classList.contains("pane-collapsed");
localStorage.setItem(key, collapsed ? "collapsed" : "expanded");
apply(collapsed);
});
}
for (const tab of document.querySelectorAll("[data-panel]")) {
tab.addEventListener("click", () =>
activateInspectorPanel(tab.dataset.panel),
@ -1359,6 +1524,7 @@ function configureControls() {
.classList.toggle("drawer-collapsed", collapsed);
event.currentTarget.setAttribute("aria-expanded", String(!collapsed));
event.currentTarget.textContent = collapsed ? "Déployer" : "Réduire";
localStorage.setItem("labfy-pane:drawer", collapsed ? "collapsed" : "expanded");
requestAnimationFrame(() => renderer.updateViewport(scale, offset));
});
byId("view-evidence").addEventListener("click", () => {
@ -1458,6 +1624,23 @@ function openNodeContextMenu(nodeId, clientX, clientY) {
}
function configureGraphInteractions() {
// WHY: les panneaux persistants modifient réellement la largeur SVG. Le
// renderer doit recaler son viewport avant le prochain drag, sans muter les
// coordonnées épinglées qui restent un état de présentation local.
new ResizeObserver(() => requestAnimationFrame(() => {
const panel = byId("graph-panel");
if (!graph_scale_initialized && snapshot.nodes.length > 0 &&
panel.clientWidth > 100 && panel.clientHeight > 100) {
// WHY: le snapshot peut arriver pendant que la coque tri-panneaux passe
// de hidden à sa largeur finale. L'auto-fit unique évite que des nœuds
// SVG débordent sous Activité, tout en préservant ensuite zoom/pins.
scale = fitGraphScale();
graph_scale_initialized = true;
render();
return;
}
renderer.updateViewport(scale, offset);
})).observe(byId("graph-panel"));
let pan = null;
svg.addEventListener("pointerdown", (event) => {
if (event.target !== svg && event.target.id !== "graph-background") return;
@ -1624,6 +1807,7 @@ function resetWorkspacePresentation() {
plannerValue = null;
preparedUploads.clear();
pinnedPositions.clear();
graph_scale_initialized = false;
resetGlobalView();
}
@ -1646,6 +1830,9 @@ async function activateWorkspace(opened) {
snapshot = prepareSnapshot(value);
acceptedSnapshotFingerprint = snapshotFingerprint(value);
coreMode = snapshot.origin === "core";
// CONTRACT: la mesure suit la publication de la coque visible ; elle ne
// repose pas sur la largeur héritée lorsque le shell était hidden.
await new Promise((resolve) => requestAnimationFrame(resolve));
scale = fitGraphScale();
syncTypeFilterOptions();
restoreReportDraft();
@ -1653,7 +1840,8 @@ async function activateWorkspace(opened) {
renderTimeline();
renderReportSelection();
byId("connection").textContent = `Ouverte · génération ${opened.generation}`;
await Promise.all([refreshJobs(), refreshCorrelations(), refreshPlanner()]);
await Promise.all([refreshJobs(), refreshCorrelations(), refreshPlanner(),
refreshResearch()]);
for (const [callback, delay] of [[refreshJobs, 500],
[refreshOperationalGraph, 700], [refreshCorrelations, 900], [refreshPlanner, 1100]])
refreshTimers.add(setInterval(callback, delay));
@ -1684,6 +1872,9 @@ async function activateLegacyWorkspace(session) {
snapshot = prepareSnapshot(value);
acceptedSnapshotFingerprint = snapshotFingerprint(value);
coreMode = snapshot.origin === "core";
// CONTRACT: le snapshot cœur doit être ajusté après le layout Agent |
// Graphe | Activité, sinon ses coordonnées SVG débordent sous Activité.
await new Promise((resolve) => requestAnimationFrame(resolve));
byId("mode-badge").textContent = operationalMode
? "Poste local · contrôles locaux"
: coreMode ? "Snapshot du cœur C · lecture seule" : "Démonstration locale · lecture seule";
@ -1700,7 +1891,8 @@ async function activateLegacyWorkspace(session) {
byId("graph-state").textContent = `Snapshot cœur indisponible : ${snapshot._loadError}`;
if (!coreMode) connectEvents();
if (operationalMode) {
await Promise.all([refreshJobs(), refreshCorrelations(), refreshPlanner()]);
await Promise.all([refreshJobs(), refreshCorrelations(), refreshPlanner(),
refreshResearch()]);
for (const [callback, delay] of [[refreshJobs, 500],
[refreshOperationalGraph, 700], [refreshCorrelations, 900], [refreshPlanner, 1100]])
refreshTimers.add(setInterval(callback, delay));
@ -1751,6 +1943,20 @@ function configureApplication() {
configureControls();
configureGraphInteractions();
configureImport();
const drawerCollapsed = localStorage.getItem("labfy-pane:drawer") === "collapsed";
if (drawerCollapsed && !byId("work-panel").classList.contains("collapsed"))
byId("work-panel-toggle").click();
byId("agent-send").addEventListener("click", () => {
const prompt = byId("agent-prompt").value.trim();
if (!prompt) { byId("agent-prompt").focus(); return; }
const card = document.createElement("li");
card.className = "agent-card user";
const label = document.createElement("b"); label.textContent = "USER";
const text = document.createElement("p"); text.textContent = prompt;
card.append(label, text);
document.querySelector(".agent-conversation").append(card);
byId("agent-prompt").value = "";
});
byId("home-button").addEventListener("click", () => void showLibrary({ focus: true }));
byId("workspace-create-form").addEventListener("submit", async (event) => {
event.preventDefault();
@ -1788,6 +1994,27 @@ function configureApplication() {
} finally { button.disabled = false; }
});
byId("jobs-note").textContent = "Contrôle local authentifié.";
byId("research-form").addEventListener("submit", (event) => {
event.preventDefault();
byId("research-note").textContent = "Préparation…";
void prepareResearch().catch((error) => {
if (error.name !== "AbortError")
byId("research-note").textContent = error.message;
});
});
byId("research-launch").addEventListener("click", () => {
byId("research-note").textContent = "Admission et campagne…";
void launchResearch().catch((error) => {
if (error.name !== "AbortError")
byId("research-note").textContent = error.message;
});
});
byId("research-revoke").addEventListener("click", () =>
void revokeResearch().catch((error) => {
if (error.name !== "AbortError")
byId("research-note").textContent = error.message;
}),
);
for (const [id, command] of [
["queue-pause", "pause"],
["queue-resume", "resume"],

View file

@ -91,6 +91,20 @@
</div>
</nav>
<main class="workbench">
<aside id="agent-panel" class="agent-panel" aria-label="Agent IA local">
<div class="panel-heading"><div><span class="eyebrow">Agent IA · préparation</span><h2>Mission</h2></div><button class="pane-toggle" data-pane="agent-panel" aria-expanded="true">Réduire</button></div>
<div class="agent-scroll">
<p class="agent-mission">Cartographier les relations autour de <strong>example.test</strong> en respectant les autorisations explicites.</p>
<ol class="agent-conversation" aria-label="Conversation SPECIMEN">
<li class="agent-card user"><b>USER</b><p>Montre les pivots passifs disponibles.</p></li>
<li class="agent-card plan"><b>PLAN</b><p>1. Lire les observations · 2. Proposer les capabilities · 3. Attendre l’accord.</p></li>
<li class="agent-card hypothesis"><b>HYPOTHESIS</b><p>Le domaine et l’adresse contact peuvent partager une provenance, sans confirmer une identité.</p></li>
<li class="agent-card authorization"><b>AUTHORIZATION</b><p><strong>rdap.lookup_domain</strong><br/>Cible : example.test · Provider : SPECIMEN loopback<br/>Sortie : nom de domaine · Exposition : passive · Budget : 1 requête<br/><button type="button">Examiner l’autorisation</button></p></li>
<li class="agent-card result"><b>RESULT</b><p>7 observations reçues ; aucune relation n’est promue automatiquement.</p></li>
</ol>
<label class="agent-prompt"><span>Prompt à l’agent</span><textarea id="agent-prompt" maxlength="2000" placeholder="Décrivez l’intention d’enquête…"></textarea><button type="button" id="agent-send">Préparer une demande</button></label>
</div>
</aside>
<section id="graph-panel" aria-label="Graphe interactif">
<div id="graph-state" role="alert"></div>
<div class="graph-legend" aria-label="Légende">
@ -147,10 +161,17 @@
<aside id="inspector" aria-label="Inspecteur contextuel">
<div class="panel-heading">
<div>
<span class="eyebrow">Objet sélectionné</span>
<h2>Inspecteur</h2>
<span class="eyebrow">Activité · outils · jobs</span>
<h2>Activité</h2>
</div>
<button class="pane-toggle" data-pane="inspector" aria-expanded="true">Réduire</button>
</div>
<section class="activity-stream" aria-label="Timeline technique SPECIMEN">
<div class="activity-entry"><time>15:42:12</time><b>TOOL</b><span>rdap.lookup_domain<br/><code>target = example.test</code></span></div>
<div class="activity-entry policy"><time>15:42:12</time><b>POLICY</b><span>Autorisé · budget restant 18/20</span></div>
<div class="activity-entry result"><time>15:42:13</time><b>RESULT</b><span>1 artefact · 7 observations</span></div>
</section>
<details class="system-card"><summary>Système · ressources réelles</summary><dl><dt>CPU</dt><dd id="system-cpu">Disponible via backend</dd><dt>RAM</dt><dd id="system-ram">Disponible via backend</dd><dt>Jobs actifs</dt><dd id="system-jobs">Lecture JobStore</dd><dt>Instance Web</dt><dd id="system-instance">Loopback</dd><dt>Providers</dt><dd id="system-providers">Selon capabilities</dd></dl><p class="future-slots">Prévu : modèle · quantification · GPU AMD · KV cache · réserve RAM · swap · scratch IA.</p></details>
<div class="tablist" role="tablist">
<button role="tab" data-panel="details-panel" aria-selected="true">
Détails</button
@ -202,6 +223,7 @@
><button role="tab" data-work-panel="next-panel">
Prochaines actions</button
><button role="tab" data-work-panel="plans-panel">Plans</button
><button role="tab" data-work-panel="research-panel">Recherches</button
><button role="tab" data-work-panel="reports-panel">
Rapports
</button>
@ -237,6 +259,25 @@
<h3>Plans approuvés</h3>
<ol id="plans-list"></ol>
</section>
<section id="research-panel" class="work-tab-panel" hidden>
<h3>Recherches assistées</h3>
<p id="research-note" role="status">
Sélectionnez une preuve ou une entité dans le graphe.
</p>
<form id="research-form" class="research-form">
<label>Question
<textarea id="research-question" maxlength="512" required></textarea>
</label>
<label>Exclusions explicites
<input id="research-exclusions" maxlength="512"
placeholder="Un sujet exact par ligne" />
</label>
<button id="research-prepare" type="submit">Préparer le plan</button>
</form>
<div id="research-plan" aria-live="polite"></div>
<button id="research-launch" disabled>Autoriser et lancer la sélection</button>
<button id="research-revoke" disabled>Révoquer l’autorisation</button>
</section>
<section id="reports-panel" class="work-tab-panel" hidden>
<div id="report-panel">
<h3 id="report-title">Préparer le rapport</h3>

View file

@ -699,7 +699,48 @@ body.login .workbench {
.observation-actions input {
min-width: 12rem;
}
.research-form {
display: grid;
gap: 0.7rem;
margin-bottom: 1rem;
}
.research-form label,
.research-card {
display: grid;
gap: 0.35rem;
}
.research-card {
padding: 0.8rem;
margin: 0.7rem 0;
border: 1px solid var(--surface1);
border-radius: 0.65rem;
background: var(--mantle);
}
.research-card h5,
.research-card p {
margin: 0;
}
.research-card fieldset {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
border-color: var(--surface1);
}
.research-contacted {
color: var(--green);
}
.research-uncontacted {
color: var(--yellow);
}
/* CONTRACT: la coque finale garde le graphe dominant ; les trois surfaces
* sont des vues de la même enquête, jamais des stores concurrents. */
.workbench { grid-template-columns: minmax(15rem,20rem) minmax(0,1fr) minmax(18rem,23rem); grid-template-areas: "agent graph inspector" "work work work"; }
.agent-panel { grid-area: agent; min-width:0; min-height:0; display:grid; grid-template-rows:auto minmax(0,1fr); background:linear-gradient(180deg,#181825,#11111b); border-right:1px solid var(--border); }
.agent-scroll { min-height:0; overflow:auto; padding:.75rem; }.agent-mission { margin:0 0 .8rem; color:var(--muted); line-height:1.45; }.agent-conversation { display:grid; gap:.55rem; margin:0; padding:0; list-style:none; }.agent-card { border:1px solid var(--border); border-left:3px solid var(--blue); border-radius:.65rem; padding:.6rem .7rem; background:#1e1e2ecc; }.agent-card b { font-size:.68rem; letter-spacing:.08em; color:var(--muted); }.agent-card p { margin:.3rem 0 0; line-height:1.4; }.agent-card.plan { border-left-color:var(--accent); }.agent-card.hypothesis { border-left-color:var(--yellow); }.agent-card.authorization { border-left-color:#cba6f7; }.agent-card.result { border-left-color:var(--green); }.agent-prompt { display:grid; gap:.4rem; margin-top:.8rem; }.agent-prompt textarea { min-height:5.5rem; resize:vertical; }.pane-toggle { padding:.28rem .48rem; font-size:.78rem; }
.activity-stream { display:grid; gap:.35rem; margin-bottom:.75rem; padding-bottom:.75rem; border-bottom:1px solid var(--border); }.activity-entry { display:grid; grid-template-columns:4.4rem 4.2rem minmax(0,1fr); gap:.3rem; padding:.42rem; border-radius:.42rem; background:#11111b; font-size:.78rem; }.activity-entry time { color:var(--muted); }.activity-entry b { color:var(--blue); font-size:.7rem; }.activity-entry.policy b { color:var(--yellow); }.activity-entry.result b { color:var(--green); }.activity-entry code { color:var(--muted); overflow-wrap:anywhere; }.system-card { margin-bottom:.75rem; padding:.55rem; border:1px solid var(--border); border-radius:.55rem; background:#11111b; }.system-card summary { color:var(--accent); cursor:pointer; }.system-card dl { display:grid; grid-template-columns:1fr auto; gap:.3rem .6rem; margin:.7rem 0; font-size:.8rem; }.system-card dd { margin:0; color:var(--muted); text-align:right; }.future-slots { margin:0; color:var(--muted); font-size:.75rem; line-height:1.35; }
.agent-panel.pane-collapsed { grid-template-rows: auto; }.agent-panel.pane-collapsed .agent-scroll { display:none; }.agent-panel.pane-collapsed { min-width: 5.5rem; }.agent-panel.pane-collapsed h2 { display:none; }#inspector.pane-collapsed > :not(.panel-heading) { display:none; }#inspector.pane-collapsed { min-width:5.5rem; }#inspector.pane-collapsed h2 { display:none; }
@media (max-width: 900px) {
.workbench { grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(18rem,1fr) auto var(--drawer); grid-template-areas:"agent" "graph" "inspector" "work"; }.agent-panel { max-height:19rem; border-right:0; border-bottom:1px solid var(--border); }
.workbench.drawer-collapsed {
grid-template-rows: minmax(18rem, 1fr) auto auto;
}

View file

@ -27,6 +27,9 @@ export const scenarios = Object.freeze([
// de harness isolée ; il démontre l'arrêt du polling et la reconnexion sans
// rejouer une mutation dont l'admission n'est pas connue.
"test_session_lifetime_browser.mjs",
// CONTRACT: deux vagues de recherche SPECIMEN approuvées sont visibles ; une
// branche refusée demeure hors transport et ne peut donc produire de résultat.
"test_research_browser.mjs",
]);
const tests_directory = fileURLToPath(new URL(".", import.meta.url));

View file

@ -0,0 +1,59 @@
"""Régressions de session sans code visible du lanceur Web-only."""
import json
import sys
import tempfile
import threading
import unittest
from http.client import HTTPConnection
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from workspace_server import Handler, WorkspaceServer
class AutomaticSessionTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="labfy-auto-SPECIMEN-")
root = Path(self.temporary.name)
self.server = WorkspaceServer(("127.0.0.1", 0), Handler,
workspace=root, bridge=root / "missing-bridge", bootstrap="unused",
automatic_session=True)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.authority = f"127.0.0.1:{self.server.server_port}"
def tearDown(self):
self.server.shutdown(); self.server.server_close(); self.thread.join()
self.temporary.cleanup()
def request(self, method, path, headers=None, body=None):
connection = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3)
connection.request(method, path, body=body, headers=headers or {"Host": self.authority})
response = connection.getresponse(); value = response.read()
answer = response.status, dict(response.getheaders()), value
connection.close(); return answer
def test_root_establishes_http_only_session_without_code_or_url_token(self):
status, headers, _ = self.request("GET", "/")
self.assertEqual(status, 303)
self.assertEqual(headers["Location"], "/")
self.assertIn("HttpOnly", headers["Set-Cookie"])
self.assertIn("SameSite=Strict", headers["Set-Cookie"])
self.assertNotIn("unused", headers["Set-Cookie"])
cookie = headers["Set-Cookie"].split(";", 1)[0]
status, _, body = self.request("GET", "/api/v1/session", {"Host": self.authority, "Cookie": cookie})
self.assertEqual(status, 200)
self.assertIn("csrf", json.loads(body))
def test_third_party_origin_cannot_mutate_auto_session(self):
_, headers, _ = self.request("GET", "/")
cookie = headers["Set-Cookie"].split(";", 1)[0]
status, _, _ = self.request("POST", "/api/v1/queue/pause", {
"Host": self.authority, "Cookie": cookie, "Origin": "https://evil.test",
"Content-Type": "application/json", "Content-Length": "2"}, "{}")
self.assertEqual(status, 403)
if __name__ == "__main__":
unittest.main()

View file

@ -239,7 +239,7 @@ async function testProjectionKeyboardAndResets(page) {
await page.setViewport({ width: 700, height: 900 });
const narrowLayout = await page.evaluate(() => ({
columns: getComputedStyle(document.querySelector("main")).gridTemplateColumns,
asideBorderTop: getComputedStyle(document.querySelector("aside")).borderTopStyle,
asideBorderTop: getComputedStyle(document.querySelector("#inspector")).borderTopStyle,
}));
assert.equal(narrowLayout.columns.split(" ").length, 1);
assert.equal(narrowLayout.asideBorderTop, "solid");

View file

@ -39,6 +39,18 @@ async function clickNode(page, id) {
});
}
async function dragNode(page, id, deltaX, deltaY) {
const node = await page.$(`[data-id="${id}"]`);
const box = await node.boundingBox();
assert.ok(box, `boîte du nœud ${id}`);
const x = box.x + box.width / 2, y = box.y + box.height / 2;
await page.mouse.move(x, y); await page.mouse.down();
// Firefox livre les PointerEvents intermédiaires de manière fiable ; un seul
// saut ne prouve pas le contrat de position épinglée du renderer SVG.
await page.mouse.move(x + deltaX / 2, y + deltaY / 2, { steps: 3 });
await page.mouse.move(x + deltaX, y + deltaY, { steps: 3 }); await page.mouse.up();
}
async function inspectCoreMode(page) {
await page.waitForSelector(`[data-id="${PERSON}"]`);
const initial = await page.evaluate(() => window.__LABFY_TEST__.getState());
@ -91,13 +103,7 @@ async function inspectCoreMode(page) {
});
assert.equal((await page.evaluate(() => window.__LABFY_TEST__.getState())).visibleNodeIds.length, 0);
await page.$eval("#reset", (item) => item.click());
const person = await page.$(`[data-id="${PERSON}"]`);
const box = await person.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 70, box.y + box.height / 2 + 45, { steps: 4 });
await page.mouse.up();
await dragNode(page, PERSON, 70, 45);
assert.ok((await page.evaluate(() => window.__LABFY_TEST__.getState())).pinned[PERSON]);
await page.setViewport({ width: 700, height: 900 });

View file

@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import http from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn, spawnSync } from "node:child_process";
import puppeteer from "puppeteer-core";
const REPOSITORY = new URL("../../..", import.meta.url);
const PROTOTYPE = new URL("..", import.meta.url);
const FIREFOX = process.env.FIREFOX_PATH ?? "/usr/bin/firefox";
function ready(server) {
return new Promise((resolve, reject) => {
let text = "";
const timer = setTimeout(() => reject(new Error("démarrage recherche expiré")), 8000);
server.stdout.on("data", (chunk) => {
text += chunk;
const url = text.match(/http:\/\/127\.0\.0\.1:(\d+)\//);
const code = text.match(/Code de session éphémère : ([^\s]+)/);
if (url && code) {
clearTimeout(timer);
resolve({ url: url[0], code: code[1] });
}
});
server.once("exit", (code) => reject(new Error(`serveur arrêté (${code})`)));
});
}
function start(workspace, authority) {
return spawn("python3", ["workspace_server.py", "--workspace", workspace,
"--bridge", "../../tools/local-jobs", "--port", "0"], {
cwd: PROTOTYPE,
env: { ...process.env, PYTHONUNBUFFERED: "1",
LABFY_RESEARCH_FIXTURE_AUTHORITY: authority },
stdio: ["ignore", "pipe", "pipe"],
});
}
async function stop(server) {
server.kill("SIGINT");
await new Promise((resolve) => server.once("exit", resolve));
}
async function login(page, session) {
await page.goto(session.url, { waitUntil: "domcontentloaded" });
await page.type("#bootstrap-code", session.code);
await Promise.all([page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.click("#login-form button")]);
}
function init(workspace) {
const result = spawnSync("./tools/local-jobs",
["init-j7-specimen", "--workspace", workspace],
{ cwd: REPOSITORY, encoding: "utf8", timeout: 30000 });
assert.equal(result.status, 0, result.stderr);
assert.equal(spawnSync("./tools/local-jobs",
["export", "--workspace", workspace],
{ cwd: REPOSITORY, timeout: 15000 }).status, 0);
}
const contacts = [];
const provider = http.createServer((request, response) => {
contacts.push(request.url);
const body = JSON.stringify({ contract: "labfy.fixture.provider.v1",
path: request.url });
response.writeHead(200, { "Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body) });
response.end(body);
});
await new Promise((resolve) => provider.listen(0, "127.0.0.1", resolve));
const authority = `127.0.0.1:${provider.address().port}`;
const workspaceA = await mkdtemp(join(tmpdir(), "labfy-research-A-"));
const workspaceB = await mkdtemp(join(tmpdir(), "labfy-research-B-"));
const profile = await mkdtemp(join(tmpdir(), "labfy-research-firefox-"));
let browser, server;
try {
init(workspaceA); init(workspaceB);
server = start(workspaceA, authority); const sessionA = await ready(server);
browser = await puppeteer.launch({ browser: "firefox", executablePath: FIREFOX,
headless: true, userDataDir: profile, args: ["--no-remote"] });
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 900 });
await login(page, sessionA);
await page.waitForSelector("#object-list button");
await page.click("#object-list button");
await page.click('[data-work-panel="research-panel"]');
await page.type("#research-question", "Que confirme cette source SPECIMEN ?");
await page.click("#research-prepare");
await page.waitForSelector(".research-card input[value=authorize]");
const cards = await page.$$(".research-card");
assert.equal(cards.length, 3);
await (await cards[0].$("input[value=authorize]")).click();
await (await cards[2].$("input[value=refuse]")).click();
await page.click("#research-launch");
await page.waitForFunction(() => document.querySelector("#research-plan")
?.textContent.includes("Résultat : NEW"));
assert.deepEqual(contacts, ["/specimen/wave-1"]);
assert.match(await page.$eval("#research-plan", (node) => node.textContent),
/Nouvelle action non contactée/);
const secondWave = (await page.$$(".research-card"))[1];
await (await secondWave.$("input[value=authorize]")).click();
await page.click("#research-launch");
await page.waitForFunction(() => document.querySelector("#research-plan")
?.textContent.includes("Résultat : CONTRADICTION"));
assert.deepEqual(contacts, ["/specimen/wave-1", "/specimen/wave-2"]);
assert.doesNotMatch(contacts.join(" "), /refused/);
await stop(server); server = start(workspaceB, authority);
const sessionB = await ready(server); const pageB = await browser.newPage();
await login(pageB, sessionB);
await pageB.click('[data-work-panel="research-panel"]');
assert.equal(await pageB.$$eval(".research-card", (nodes) => nodes.length), 0);
assert.match(await pageB.$eval("#research-note", (node) => node.textContent),
/Sélectionnez/);
console.log("PASS recherche Firefox : deux vagues A, refus sans contact, B isolé");
} finally {
if (browser) await browser.close();
if (server?.exitCode === null) await stop(server);
await new Promise((resolve) => provider.close(resolve));
await rm(workspaceA, { recursive: true, force: true });
await rm(workspaceB, { recursive: true, force: true });
await rm(profile, { recursive: true, force: true });
}

View file

@ -0,0 +1,345 @@
import json
import os
import sqlite3
import subprocess
import tempfile
import threading
import time
import unittest
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from workspace_server import Handler, WorkspaceServer
class Provider(BaseHTTPRequestHandler):
requests = []
bodies = {}
protocol_version = "HTTP/1.1"
def do_GET(self):
type(self).requests.append((self.path, self.headers.get("X-Labfy-Action")))
body = type(self).bodies.get(self.path)
if body is None:
body = json.dumps({"contract": "labfy.fixture.provider.v1",
"path": self.path}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_args):
pass
class ResearchWebTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.repository = Path(__file__).resolve().parents[3]
cls.bridge = cls.repository / "tools/local-jobs"
cls.temporary = tempfile.TemporaryDirectory(prefix="labfy-research-web-")
cls.provider = ThreadingHTTPServer(("127.0.0.1", 0), Provider)
cls.provider_thread = threading.Thread(
target=cls.provider.serve_forever, daemon=True)
cls.provider_thread.start()
cls.instances = []
@classmethod
def tearDownClass(cls):
for server, thread in cls.instances:
server.shutdown(); server.server_close(); thread.join()
cls.provider.shutdown(); cls.provider.server_close()
cls.provider_thread.join(); cls.temporary.cleanup()
@classmethod
def start_workspace(cls, name):
workspace = Path(cls.temporary.name) / name
subprocess.run([str(cls.bridge), "init-j7-specimen", "--workspace",
str(workspace)], cwd=cls.repository, check=True,
capture_output=True, timeout=30)
subprocess.run([str(cls.bridge), "export", "--workspace", str(workspace)],
cwd=cls.repository, check=True, timeout=15)
server = WorkspaceServer(("127.0.0.1", 0), Handler,
workspace=workspace, bridge=cls.bridge, bootstrap="SPECIMEN-RESEARCH",
research_fixture_authority=f"127.0.0.1:{cls.provider.server_port}")
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start(); cls.instances.append((server, thread))
return workspace, server
@staticmethod
def request(server, method, path, value=None, *, cookie=None, csrf=None,
origin=None, host=None):
connection = HTTPConnection("127.0.0.1", server.server_port, timeout=10)
headers = {"Host": host or server.authority}
if cookie: headers["Cookie"] = cookie
if csrf: headers["X-Labfy-CSRF"] = csrf
if origin: headers["Origin"] = origin
body = None
if value is not None:
headers["Content-Type"] = "application/json"
body = json.dumps(value)
connection.request(method, path, body=body, headers=headers)
response = connection.getresponse(); data = response.read()
result = response.status, dict(response.getheaders()), data
connection.close(); return result
def login(self, server):
status, headers, _ = self.request(server, "POST", "/api/v1/session",
{"bootstrap_code": "SPECIMEN-RESEARCH"}, origin=server.origin)
self.assertEqual(status, 200)
cookie = headers["Set-Cookie"].split(";", 1)[0]
status, _, body = self.request(server, "GET", "/api/v1/session",
cookie=cookie)
self.assertEqual(status, 200)
return cookie, json.loads(body)["csrf"]
def mutate(self, server, cookie, csrf, path, value):
time.sleep(0.04)
return self.request(server, "POST", path, value, cookie=cookie,
csrf=csrf, origin=server.origin)
def test_two_waves_refusal_never_contacts_and_workspace_b_isolated(self):
Provider.requests.clear()
workspace_a, server_a = self.start_workspace("A")
cookie_a, csrf_a = self.login(server_a)
graph = json.loads((workspace_a / "core-snapshot.json").read_text())
seed = graph["nodes"][0]["id"]
prepared = {"selection_ids": [seed],
"question": "Que confirme cette source SPECIMEN ?",
"exclusions": [],
"idempotency_key": "95000000-0000-4000-8000-000000000001"}
status, _, body = self.mutate(server_a, cookie_a, csrf_a,
"/api/v1/research/prepare", prepared)
self.assertEqual(status, 200, body); snapshot = json.loads(body)
first, second, refused = snapshot["actions"]
def campaign(action, suffix):
decisions = [{"action_id": item["action_id"],
"decision": ("REFUSE" if item == refused else
"AUTHORIZE" if item == first or item == action
else "DEFER")}
for item in snapshot["actions"]]
status, _, grant_body = self.mutate(server_a, cookie_a, csrf_a,
"/api/v1/research/grants", {"plan_id": snapshot["plan_id"],
"input_revision": snapshot["input_revision"],
"selected_action_ids": [action["action_id"]], "exclusions": [],
"decisions": decisions,
"idempotency_key": f"95000000-0000-4000-8000-0000000000{suffix}"})
self.assertEqual(status, 200, grant_body); grant = json.loads(grant_body)
grant_id = grant["grants"][0]["grant_id"]
status, _, result_body = self.mutate(server_a, cookie_a, csrf_a,
"/api/v1/research/campaigns", {"grant_id": grant_id,
"input_revision": snapshot["input_revision"],
"action_ids": [action["action_id"]],
"idempotency_key": f"96000000-0000-4000-8000-0000000000{suffix}"})
self.assertEqual(status, 200, result_body)
return json.loads(result_body)
premature = [{"action_id": item["action_id"],
"decision": "AUTHORIZE" if item == second else "DEFER"}
for item in snapshot["actions"]]
status, _, body = self.mutate(server_a, cookie_a, csrf_a,
"/api/v1/research/grants", {"plan_id": snapshot["plan_id"],
"input_revision": snapshot["input_revision"],
"selected_action_ids": [second["action_id"]], "exclusions": [],
"decisions": premature,
"idempotency_key": "95000000-0000-4000-8000-000000000009"})
self.assertEqual(status, 409, body)
previous_proxy = os.environ.get("http_proxy")
os.environ["http_proxy"] = "http://127.0.0.1:1"
try:
after_first = campaign(first, "11")
finally:
if previous_proxy is None: os.environ.pop("http_proxy", None)
else: os.environ["http_proxy"] = previous_proxy
self.assertEqual([item[0] for item in Provider.requests],
["/specimen/wave-1"])
second_view = next(item for item in after_first["actions"]
if item["action_id"] == second["action_id"])
self.assertFalse(second_view["contacted"])
self.assertEqual(next(item for item in after_first["actions"]
if item["action_id"] == first["action_id"])["result_status"], "NEW")
retry_refused = [{"action_id": refused["action_id"],
"decision": "AUTHORIZE"}]
status, _, body = self.mutate(server_a, cookie_a, csrf_a,
"/api/v1/research/grants", {"plan_id": snapshot["plan_id"],
"input_revision": snapshot["input_revision"],
"selected_action_ids": [refused["action_id"]], "exclusions": [],
"decisions": retry_refused,
"idempotency_key": "95000000-0000-4000-8000-000000000019"})
self.assertEqual(status, 409, body)
after_second = campaign(second, "12")
self.assertEqual([item[0] for item in Provider.requests],
["/specimen/wave-1", "/specimen/wave-2"])
self.assertEqual(next(item for item in after_second["actions"]
if item["action_id"] == second["action_id"])["result_status"],
"CONTRADICTION")
self.assertFalse(next(item for item in after_second["actions"]
if item["action_id"] == refused["action_id"])["contacted"])
with sqlite3.connect(workspace_a / ".labfy/runtime/jobs.sqlite") as connection:
self.assertEqual(connection.execute(
"SELECT count(*) FROM research_results").fetchone()[0], 2)
self.assertEqual(connection.execute(
"SELECT count(*) FROM research_receipts").fetchone()[0], 2)
self.assertEqual(len(list((workspace_a / ".labfy/research").glob("*.txt"))), 2)
_, server_b = self.start_workspace("B")
cookie_b, _ = self.login(server_b)
status, _, body = self.request(server_b, "GET", "/api/v1/research",
cookie=cookie_b)
self.assertEqual(status, 200); self.assertEqual(json.loads(body)["state"], "EMPTY")
self.assertNotEqual(server_a.context()["investigation_id"],
server_b.context()["investigation_id"])
self.assertNotIn("/specimen/refused", [item[0] for item in Provider.requests])
def test_result_failure_compensates_final_artifact(self):
Provider.requests.clear()
workspace, server = self.start_workspace("artifact-failure")
cookie, csrf = self.login(server)
graph = json.loads((workspace / "core-snapshot.json").read_text())
prepared = {"selection_ids": [graph["nodes"][0]["id"]],
"question": "Faute SQLite SPECIMEN ?", "exclusions": [],
"idempotency_key": "98000000-0000-4000-8000-000000000001"}
status, _, body = self.mutate(server, cookie, csrf,
"/api/v1/research/prepare", prepared)
self.assertEqual(status, 200, body); snapshot = json.loads(body)
first = snapshot["actions"][0]
decisions = [{"action_id": item["action_id"],
"decision": "AUTHORIZE" if item == first else "DEFER"}
for item in snapshot["actions"]]
status, _, body = self.mutate(server, cookie, csrf,
"/api/v1/research/grants", {"plan_id": snapshot["plan_id"],
"input_revision": snapshot["input_revision"],
"selected_action_ids": [first["action_id"]], "decisions": decisions,
"exclusions": [],
"idempotency_key": "98000000-0000-4000-8000-000000000002"})
self.assertEqual(status, 200, body); grant = json.loads(body)
database = workspace / ".labfy/runtime/jobs.sqlite"
with sqlite3.connect(database) as connection:
connection.execute("CREATE TRIGGER specimen_fail_result BEFORE INSERT ON research_results BEGIN SELECT RAISE(FAIL, 'SPECIMEN'); END")
status, _, body = self.mutate(server, cookie, csrf,
"/api/v1/research/campaigns", {"grant_id": grant["grants"][0]["grant_id"],
"input_revision": snapshot["input_revision"],
"action_ids": [first["action_id"]],
"idempotency_key": "98000000-0000-4000-8000-000000000003"})
self.assertEqual(status, 409, body)
artifact_dir = workspace / ".labfy/research"
self.assertEqual(list(artifact_dir.glob("*.txt")), [])
with sqlite3.connect(database) as connection:
self.assertEqual(connection.execute(
"SELECT count(*) FROM research_results").fetchone()[0], 0)
self.assertEqual(connection.execute(
"SELECT count(*) FROM research_receipts").fetchone()[0], 0)
def test_response_over_action_limit_publishes_nothing(self):
Provider.requests.clear()
Provider.bodies["/specimen/wave-1"] = b"x" * 4097
try:
workspace, server = self.start_workspace("response-limit")
cookie, csrf = self.login(server)
graph = json.loads((workspace / "core-snapshot.json").read_text())
prepared = {"selection_ids": [graph["nodes"][0]["id"]],
"question": "Borne corps SPECIMEN ?", "exclusions": [],
"idempotency_key": "98100000-0000-4000-8000-000000000001"}
status, _, body = self.mutate(server, cookie, csrf,
"/api/v1/research/prepare", prepared)
self.assertEqual(status, 200, body); snapshot = json.loads(body)
first = snapshot["actions"][0]
decisions = [{"action_id": item["action_id"],
"decision": "AUTHORIZE" if item == first else "DEFER"}
for item in snapshot["actions"]]
status, _, body = self.mutate(server, cookie, csrf,
"/api/v1/research/grants", {"plan_id": snapshot["plan_id"],
"input_revision": snapshot["input_revision"],
"selected_action_ids": [first["action_id"]],
"decisions": decisions, "exclusions": [],
"idempotency_key": "98100000-0000-4000-8000-000000000002"})
self.assertEqual(status, 200, body); grant = json.loads(body)
status, _, body = self.mutate(server, cookie, csrf,
"/api/v1/research/campaigns", {
"grant_id": grant["grants"][0]["grant_id"],
"input_revision": snapshot["input_revision"],
"action_ids": [first["action_id"]],
"idempotency_key": "98100000-0000-4000-8000-000000000003"})
self.assertEqual(status, 409, body)
self.assertEqual(list((workspace / ".labfy/research").glob("*.txt")), [])
with sqlite3.connect(workspace / ".labfy/runtime/jobs.sqlite") as connection:
self.assertEqual(connection.execute(
"SELECT count(*) FROM research_results").fetchone()[0], 0)
self.assertEqual(connection.execute(
"SELECT count(*) FROM research_receipts").fetchone()[0], 0)
finally:
Provider.bodies.pop("/specimen/wave-1", None)
def test_crash_after_rename_reconciles_without_recontact(self):
Provider.requests.clear()
workspace, server = self.start_workspace("crash-reconcile")
cookie, csrf = self.login(server)
graph = json.loads((workspace / "core-snapshot.json").read_text())
prepared = {"selection_ids": [graph["nodes"][0]["id"]],
"question": "Reprise publication SPECIMEN ?", "exclusions": [],
"idempotency_key": "98200000-0000-4000-8000-000000000001"}
status, _, body = self.mutate(server, cookie, csrf,
"/api/v1/research/prepare", prepared)
self.assertEqual(status, 200, body); snapshot = json.loads(body)
first = snapshot["actions"][0]
decisions = [{"action_id": item["action_id"],
"decision": "AUTHORIZE" if item == first else "DEFER"}
for item in snapshot["actions"]]
status, _, body = self.mutate(server, cookie, csrf,
"/api/v1/research/grants", {"plan_id": snapshot["plan_id"],
"input_revision": snapshot["input_revision"],
"selected_action_ids": [first["action_id"]],
"decisions": decisions, "exclusions": [],
"idempotency_key": "98200000-0000-4000-8000-000000000002"})
self.assertEqual(status, 200, body); grant = json.loads(body)
campaign = {"grant_id": grant["grants"][0]["grant_id"],
"input_revision": snapshot["input_revision"],
"action_ids": [first["action_id"]],
"idempotency_key": "98200000-0000-4000-8000-000000000003"}
os.environ["LABFY_TEST_RESEARCH_CRASH_AFTER_RENAME"] = "1"
try:
status, _, _ = self.mutate(server, cookie, csrf,
"/api/v1/research/campaigns", campaign)
finally:
os.environ.pop("LABFY_TEST_RESEARCH_CRASH_AFTER_RENAME", None)
self.assertEqual(status, 409)
self.assertEqual(len(Provider.requests), 1)
artifact_dir = workspace / ".labfy/research"
self.assertEqual(len(list(artifact_dir.glob("*.txt"))), 1)
self.assertEqual(len(list(artifact_dir.glob(".publishing-*"))), 1)
status, _, body = self.mutate(server, cookie, csrf,
"/api/v1/research/campaigns", campaign)
self.assertEqual(status, 200, body)
self.assertEqual(len(Provider.requests), 1)
self.assertEqual(list(artifact_dir.glob(".publishing-*")), [])
with sqlite3.connect(workspace / ".labfy/runtime/jobs.sqlite") as connection:
self.assertEqual(connection.execute(
"SELECT count(*) FROM research_results").fetchone()[0], 1)
self.assertEqual(connection.execute(
"SELECT count(*) FROM research_receipts").fetchone()[0], 1)
def test_research_routes_keep_host_origin_csrf_and_shape_guards(self):
_, server = self.start_workspace("guards")
cookie, csrf = self.login(server)
self.assertEqual(self.request(server, "GET", "/api/v1/research",
cookie=cookie, host="evil.test")[0], 403)
value = {"selection_ids": ["x"], "question": "SPECIMEN",
"exclusions": [],
"idempotency_key": "97000000-0000-4000-8000-000000000001"}
self.assertEqual(self.request(server, "POST", "/api/v1/research/prepare",
value, cookie=cookie, csrf=csrf, origin="http://evil.test")[0], 403)
malformed = dict(value, endpoint="http://127.0.0.1/")
self.assertEqual(self.mutate(server, cookie, csrf,
"/api/v1/research/prepare", malformed)[0], 400)
if __name__ == "__main__":
unittest.main()

View file

@ -16,6 +16,7 @@ import subprocess
import sys
import threading
import time
import webbrowser
from pathlib import Path
from web_library import WebLibrary
@ -188,7 +189,8 @@ def serve(args):
try:
server = WorkspaceServer(("127.0.0.1", args.port), Handler,
workspace=workspace, bridge=args.bridge, bootstrap=bootstrap,
library=library, instance_id=instance_id, config_id=config_id)
library=library, instance_id=instance_id, config_id=config_id,
automatic_session=args.automatic_session)
except OSError as error:
_handshake(args.handshake_fd, {"ok": False,
"error": f"Port indisponible : {error}"})
@ -209,12 +211,13 @@ def serve(args):
os.write(descriptor, (bootstrap + "\n").encode())
finally:
os.close(descriptor)
answer = {"ok": True, "origin": server.origin, "bootstrap_code": bootstrap,
answer = {"ok": True, "origin": server.origin,
"instance_id": instance_id}
_handshake(args.handshake_fd, answer)
if args.handshake_fd is None:
print(f"Labfy Web : {server.origin}/", flush=True)
print(f"Code de session éphémère : {bootstrap}", flush=True)
if not args.automatic_session:
print(f"Code de session éphémère : {bootstrap}", flush=True)
# INVARIANT: SIGTERM n'arrête que cette instance déjà authentifiée
# par le lanceur ; shutdown s'exécute hors du gestionnaire de signal.
def request_shutdown(_signum, _frame):
@ -243,14 +246,18 @@ def start(args):
except (OSError, RuntimeError, json.JSONDecodeError):
current = None
if current is not None and _identity_valid(current):
print(f"Instance déjà active sur http://127.0.0.1:{current['port']}/",
file=sys.stderr)
return 1
origin = f"http://127.0.0.1:{current['port']}/"
if args.open_browser:
webbrowser.open(origin)
print(f"Instance déjà active sur {origin}", file=sys.stderr)
return 0
files.clear_stale()
read_fd, write_fd = os.pipe()
command = [sys.executable, str(Path(__file__).resolve()), "serve",
"--port", str(args.port), "--bridge", str(args.bridge),
"--handshake-fd", str(write_fd)]
if args.automatic_session:
command.append("--automatic-session")
mode, path = _selection(args)
command.extend((f"--{mode}", str(path)))
process = subprocess.Popen(command, stdin=subprocess.DEVNULL,
@ -274,7 +281,8 @@ def start(args):
print(response.get("error", "Démarrage refusé"), file=sys.stderr)
return 1
print(f"Labfy Web : {response['origin']}/")
print(f"Code de session éphémère : {response['bootstrap_code']}")
if args.open_browser:
webbrowser.open(f"{response['origin']}/")
return 0
finally:
os.close(lifecycle)
@ -347,6 +355,11 @@ def build_parser():
targets.add_argument("--workspace", type=Path)
command.add_argument("--port", type=valid_port, default=DEFAULT_PORT)
command.add_argument("--bridge", type=Path, default=repository / "tools/local-jobs")
command.add_argument("--automatic-session", action="store_true",
help="établit une session locale sans code visible")
if name == "start":
command.add_argument("--open-browser", action="store_true",
help="ouvre l'interface locale après vérification")
if name == "serve":
command.add_argument("--handshake-fd", type=int, help=argparse.SUPPRESS)
command.set_defaults(function=function)

View file

@ -49,7 +49,8 @@ class WorkspaceServer(ThreadingHTTPServer):
def __init__(self, address, handler, *, workspace: Path, bridge: Path,
bootstrap: str, library=None, instance_id=None, config_id=None,
session_ttl_seconds=SESSION_TTL_SECONDS):
session_ttl_seconds=SESSION_TTL_SECONDS,
research_fixture_authority=None, automatic_session=False):
if (not isinstance(session_ttl_seconds, int) or
isinstance(session_ttl_seconds, bool) or session_ttl_seconds <= 0):
raise ValueError("Durée de session invalide")
@ -59,6 +60,9 @@ class WorkspaceServer(ThreadingHTTPServer):
self.authority = f"127.0.0.1:{self.server_port}"
self.origin = f"http://{self.authority}"
self.bootstrap = bootstrap
# CONTRACT: ce mode est activé seulement par le lanceur possédé. Il
# émet un cookie HttpOnly local mais ne relâche jamais Origin/CSRF.
self.automatic_session = automatic_session
self.library = library
self.active_workspace_id = None
self.library_generation = (library.snapshot(None)["generation"]
@ -89,6 +93,9 @@ class WorkspaceServer(ThreadingHTTPServer):
self.active_uploads = 0
self.reserved_upload_bytes = 0
self.upload_mutexes = {}
# CONTRACT: cette autorité de laboratoire n'est ni une donnée UI ni un
# réglage persistant. Le bridge C applique encore sa propre whitelist.
self.research_fixture_authority = research_fixture_authority
def library_snapshot(self):
if self.library is None:
@ -160,7 +167,12 @@ class WorkspaceServer(ThreadingHTTPServer):
def bridge_call(self, arguments, timeout=8):
command = [str(self.bridge), arguments[0], "--workspace",
str(self.workspace), *arguments[1:]]
environment = os.environ.copy()
if self.research_fixture_authority is not None:
environment["LABFY_RESEARCH_FIXTURE_AUTHORITY"] = \
self.research_fixture_authority
result = subprocess.run(command, cwd=self.bridge.parents[1], text=True,
env=environment,
capture_output=True, timeout=timeout, check=False)
if result.returncode != 0:
message = result.stderr.strip().splitlines()[-1] if result.stderr.strip() else "Commande C refusée"
@ -406,6 +418,69 @@ class Handler(BaseHTTPRequestHandler):
raise TypeError("Champ de plan invalide")
return value
def _research_body(self, kind):
value = self._json_body()
shapes = {
"prepare": {"selection_ids", "question", "exclusions",
"idempotency_key"},
"grant": {"plan_id", "input_revision", "selected_action_ids",
"decisions", "exclusions", "idempotency_key"},
"campaign": {"grant_id", "input_revision", "action_ids",
"idempotency_key"},
"revoke": {"grant_id"},
}
if not isinstance(value, dict) or set(value) != shapes[kind]:
raise TypeError("Champs de recherche inattendus")
if kind == "grant":
decisions = value["decisions"]
if (not isinstance(decisions, list) or not decisions or
len(decisions) > 8 or any(not isinstance(item, dict) or
set(item) != {"action_id", "decision"}
for item in decisions)):
raise TypeError("Décisions de recherche invalides")
decision_ids = []
for item in decisions:
if (not isinstance(item["action_id"], str) or
not isinstance(item["decision"], str) or
item["decision"] not in {"AUTHORIZE", "DEFER", "REFUSE"}):
raise TypeError("Décision de recherche invalide")
try:
uuid.UUID(item["action_id"])
except ValueError as error:
raise TypeError("Identifiant de décision invalide") from error
decision_ids.append(item["action_id"])
if len(set(decision_ids)) != len(decision_ids):
raise TypeError("Décision de recherche dupliquée")
authorized = {item["action_id"] for item in decisions
if item["decision"] == "AUTHORIZE"}
if not set(value["selected_action_ids"]) <= authorized:
raise TypeError("Action sélectionnée sans autorisation")
list_names = shapes[kind] & {
"selection_ids", "exclusions", "selected_action_ids", "action_ids"}
for name in list_names:
items = value[name]
if (not isinstance(items, list) or len(items) > 8 or
(name != "exclusions" and not items) or
len(set(items)) != len(items) or
any(not isinstance(item, str) or not item or
len(item) > 160 or "," in item for item in items)):
raise TypeError("Liste de recherche invalide")
for name, item in value.items():
if name in list_names or name == "decisions":
continue
if name == "input_revision":
if not isinstance(item, int) or isinstance(item, bool) or item < 0:
raise TypeError("Révision de recherche invalide")
elif (not isinstance(item, str) or not item or len(item) > 512 or
"\x00" in item):
raise TypeError("Champ de recherche invalide")
for name in shapes[kind] & {"plan_id", "grant_id", "idempotency_key"}:
try:
uuid.UUID(value[name])
except ValueError as error:
raise TypeError("Identifiant de recherche invalide") from error
return value
def _report_preview_body(self):
value = self._json_body()
if not isinstance(value, dict) or set(value) != {"object_ids", "title",
@ -487,6 +562,9 @@ class Handler(BaseHTTPRequestHandler):
"config_id": self.server.config_id})
return
if not self._authenticated():
if path == "/" and self.server.automatic_session:
self._open_automatic_session()
return
if path in {"/", "/login.js", "/styles.css"}:
self._serve_static("/login.html" if path == "/" else path)
return
@ -547,6 +625,14 @@ class Handler(BaseHTTPRequestHandler):
elif path == "/api/v1/planner":
self._serve_export("planner-snapshot.json",
"labfy.local_planner.snapshot.v1")
elif path == "/api/v1/research":
try:
value = json.loads(self.server.bridge_call(
["research-snapshot-json"]))
self._json(HTTPStatus.OK, value)
except (ValueError, json.JSONDecodeError,
subprocess.TimeoutExpired) as error:
self._error(HTTPStatus.CONFLICT, "research_unavailable", str(error))
elif path.startswith("/api/v1/reports/"):
parts=path.split("/")
report_id=parts[4] if len(parts)>4 else ""
@ -709,6 +795,45 @@ class Handler(BaseHTTPRequestHandler):
self.server.start_worker()
self._json(HTTPStatus.ACCEPTED, json.loads(output))
return
if path == "/api/v1/research/prepare":
value = self._research_body("prepare")
graph = json.loads((self.server.workspace /
"core-snapshot.json").read_text())
revision = graph.get("revision")
if (not isinstance(revision, int) or isinstance(revision, bool)
or revision < 0):
raise ValueError("Révision du snapshot cœur invalide")
output = self.server.bridge_call(["research-prepare-json",
"--selection", ",".join(value["selection_ids"]),
"--question", value["question"], "--exclusions",
",".join(value["exclusions"]), "--revision",
str(revision), "--key",
value["idempotency_key"]], timeout=15)
self._json(HTTPStatus.OK, json.loads(output)); return
if path == "/api/v1/research/grants":
value = self._research_body("grant")
output = self.server.bridge_call(["research-grant-json",
"--plan", value["plan_id"], "--revision",
str(value["input_revision"]), "--actions",
",".join(value["selected_action_ids"]), "--decisions",
",".join(f'{item["action_id"]}={item["decision"]}'
for item in value["decisions"]), "--exclusions",
",".join(value["exclusions"]), "--key",
value["idempotency_key"]], timeout=15)
self._json(HTTPStatus.OK, json.loads(output)); return
if path == "/api/v1/research/campaigns":
value = self._research_body("campaign")
output = self.server.bridge_call(["research-campaign-json",
"--grant", value["grant_id"], "--revision",
str(value["input_revision"]), "--actions",
",".join(value["action_ids"]), "--key",
value["idempotency_key"]], timeout=20)
self._json(HTTPStatus.OK, json.loads(output)); return
if path == "/api/v1/research/revoke":
value = self._research_body("revoke")
output = self.server.bridge_call(["research-revoke-json",
"--grant", value["grant_id"]])
self._json(HTTPStatus.OK, json.loads(output)); return
if path == "/api/v1/reports/preview":
value=self._report_preview_body();generated=time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime())
output=self.server.bridge_call(["prepare-report-json","--objects",",".join(value["object_ids"]),"--title",value["title"],"--comment",value["comment"],"--generated-at",generated,"--sections",",".join(value["sections"])],timeout=15)
@ -1018,6 +1143,24 @@ class Handler(BaseHTTPRequestHandler):
self._json(HTTPStatus.OK, {"contract": "labfy.workspace.session.v1",
"authenticated": True}, cookie=cookie)
def _open_automatic_session(self):
with self.server.session_lock:
self.server.session = secrets.token_urlsafe(32)
self.server.csrf = secrets.token_urlsafe(32)
self.server.session_deadline = (time.monotonic() +
self.server.session_ttl_seconds)
cookie = (f"{self.server.cookie_name}={self.server.session}; "
"HttpOnly; SameSite=Strict; "
f"Path=/; Max-Age={self.server.session_ttl_seconds}")
# INVARIANT: aucun secret n'est inclus dans l'URL ou dans le document;
# l'application récupère seulement le CSRF depuis la même origine.
self.send_response(HTTPStatus.SEE_OTHER)
self._headers()
self.send_header("Set-Cookie", cookie)
self.send_header("Location", "/")
self.send_header("Content-Length", "0")
self.end_headers()
def _serve_export(self, name, contract):
path = self.server.workspace / name
try:

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,9 @@
/******************************************************************************
* @file local_job_store.c
* @brief JobStore SQLite V3, distinct de la base métier V20.
* @brief JobStore SQLite V4, distinct de la base métier V20.
******************************************************************************/
#include "core/local_job_store.h"
#include "core/research_store.h"
#include <errno.h>
#include <glib/gstdio.h>
@ -79,6 +80,263 @@ static const char *v2_to_v3_sql =
"CREATE UNIQUE INDEX idx_jobs_active_analysis ON jobs(source_evidence_id,capability_id) "
"WHERE state IN('QUEUED','RUNNING','RETRY_WAIT');";
static const char *research_schema_sql =
"CREATE TABLE research_plans("
"plan_id TEXT PRIMARY KEY,investigation_id TEXT NOT NULL,"
"idempotency_key TEXT NOT NULL,content_fingerprint TEXT NOT NULL,"
"input_fingerprint TEXT NOT NULL,input_revision INTEGER NOT NULL CHECK(input_revision>0),"
"created_at TEXT NOT NULL,UNIQUE(investigation_id,idempotency_key));"
"CREATE TABLE research_seeds("
"plan_id TEXT NOT NULL REFERENCES research_plans(plan_id) ON DELETE RESTRICT,"
"rank INTEGER NOT NULL,kind TEXT NOT NULL,subject TEXT NOT NULL,PRIMARY KEY(plan_id,rank));"
"CREATE TABLE research_actions("
"action_id TEXT PRIMARY KEY,plan_id TEXT NOT NULL REFERENCES research_plans(plan_id) ON DELETE RESTRICT,"
"rank INTEGER NOT NULL,capability_id TEXT NOT NULL,provider_id TEXT NOT NULL,"
"endpoint TEXT NOT NULL,subject TEXT NOT NULL,contact_class TEXT NOT NULL,"
"disclosure TEXT NOT NULL,max_requests INTEGER NOT NULL CHECK(max_requests>0),"
"max_response_bytes INTEGER NOT NULL CHECK(max_response_bytes>0),"
"max_active_ms INTEGER NOT NULL CHECK(max_active_ms>0),UNIQUE(plan_id,rank));"
"CREATE TABLE scope_grants("
"grant_id TEXT PRIMARY KEY,investigation_id TEXT NOT NULL,idempotency_key TEXT NOT NULL,"
"content_fingerprint TEXT NOT NULL,plan_fingerprint TEXT NOT NULL,created_at TEXT NOT NULL,"
"expires_at TEXT NOT NULL,revoked_at TEXT,max_requests INTEGER NOT NULL CHECK(max_requests>0),"
"max_response_bytes INTEGER NOT NULL CHECK(max_response_bytes>0),"
"max_active_ms INTEGER NOT NULL CHECK(max_active_ms>0),"
"UNIQUE(investigation_id,idempotency_key),"
"FOREIGN KEY(investigation_id,plan_fingerprint) REFERENCES research_plans(investigation_id,content_fingerprint));"
"CREATE TABLE scope_grant_actions("
"grant_id TEXT NOT NULL REFERENCES scope_grants(grant_id) ON DELETE RESTRICT,"
"action_id TEXT NOT NULL REFERENCES research_actions(action_id) ON DELETE RESTRICT,"
"subject TEXT NOT NULL,provider_id TEXT NOT NULL,endpoint TEXT NOT NULL,"
"PRIMARY KEY(grant_id,action_id));"
"CREATE TABLE scope_grant_exclusions("
"grant_id TEXT NOT NULL REFERENCES scope_grants(grant_id) ON DELETE RESTRICT,"
"subject TEXT NOT NULL,PRIMARY KEY(grant_id,subject));"
"CREATE TABLE research_action_decisions("
"action_id TEXT PRIMARY KEY REFERENCES research_actions(action_id) ON DELETE RESTRICT,"
"plan_id TEXT NOT NULL REFERENCES research_plans(plan_id) ON DELETE RESTRICT,"
"decision_code TEXT NOT NULL CHECK(decision_code IN('AUTHORIZE','DEFER','REFUSE')),"
"decided_at TEXT NOT NULL);"
"CREATE TABLE research_campaigns("
"campaign_id TEXT PRIMARY KEY,investigation_id TEXT NOT NULL,"
"grant_id TEXT NOT NULL REFERENCES scope_grants(grant_id) ON DELETE RESTRICT,"
"idempotency_key TEXT NOT NULL,content_fingerprint TEXT NOT NULL,"
"created_at TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'ADMITTED',"
"UNIQUE(investigation_id,idempotency_key));"
"CREATE TABLE research_results("
"result_id TEXT PRIMARY KEY,campaign_id TEXT NOT NULL REFERENCES research_campaigns(campaign_id) ON DELETE RESTRICT,"
"action_id TEXT NOT NULL REFERENCES research_actions(action_id) ON DELETE RESTRICT,"
"raw_artifact_relative_path TEXT NOT NULL,content_sha256 TEXT NOT NULL,status TEXT NOT NULL);"
"CREATE TABLE research_receipts("
"receipt_id TEXT PRIMARY KEY,result_id TEXT NOT NULL UNIQUE REFERENCES research_results(result_id) ON DELETE RESTRICT,"
"campaign_id TEXT NOT NULL REFERENCES research_campaigns(campaign_id) ON DELETE RESTRICT,"
"grant_id TEXT NOT NULL,action_id TEXT NOT NULL,decided_at TEXT NOT NULL,decision_code TEXT NOT NULL,"
"FOREIGN KEY(grant_id,action_id) REFERENCES scope_grant_actions(grant_id,action_id));"
"CREATE UNIQUE INDEX idx_research_plan_owner_fingerprint "
"ON research_plans(investigation_id,content_fingerprint);";
/* V4 n'est pas publiée : cette extension additive permet aussi d'ouvrir une
* base V4 créée par une build intermédiaire de la tranche OSINT. */
static const char *research_decision_extension_sql =
"CREATE TABLE IF NOT EXISTS research_action_decisions("
"action_id TEXT PRIMARY KEY REFERENCES research_actions(action_id) ON DELETE RESTRICT,"
"plan_id TEXT NOT NULL REFERENCES research_plans(plan_id) ON DELETE RESTRICT,"
"decision_code TEXT NOT NULL CHECK(decision_code IN('AUTHORIZE','DEFER','REFUSE')),"
"decided_at TEXT NOT NULL);";
/* Le Makefile historique lie ce fichier seul dans plusieurs outils. Ces
* gardes internes maintiennent cette compatibilité sans rendre le JobStore
* dépendant d'un nouveau module à l'édition de liens. */
static gboolean research_text(const char *value) {
return value != NULL && value[0] != '\0';
}
static gboolean research_hash(const char *value) {
if (value == NULL || strlen(value) != 64)
return FALSE;
for (gsize i = 0; i < 64; i++)
if (!g_ascii_isxdigit(value[i]))
return FALSE;
return TRUE;
}
static gboolean research_timestamp(const char *value) {
GDateTime *parsed = value != NULL
? g_date_time_new_from_iso8601(value, NULL)
: NULL;
if (parsed == NULL)
return FALSE;
g_date_time_unref(parsed);
return TRUE;
}
static gboolean research_relative_artifact(const char *value) {
if (!research_text(value) || g_path_is_absolute(value) || strchr(value, '\\'))
return FALSE;
char **parts = g_strsplit(value, "/", -1);
gboolean valid = TRUE;
for (gsize i = 0; valid && parts[i] != NULL; i++)
valid = parts[i][0] != '\0' && g_strcmp0(parts[i], ".") != 0 &&
g_strcmp0(parts[i], "..") != 0;
g_strfreev(parts);
return valid;
}
static const char *research_seed_code(ResearchSeedKind kind) {
static const char *const codes[] = {"DOMAIN", "IP", "HTTP_URL", "EMAIL",
"SEARCH_TERM"};
return kind <= RESEARCH_SEED_SEARCH_TERM ? codes[kind] : NULL;
}
static const char *research_contact_code(ResearchContactClass contact) {
static const char *const codes[] = {"NONE", "THIRD_PARTY", "TARGET"};
return contact <= RESEARCH_CONTACT_TARGET ? codes[contact] : NULL;
}
static const char *research_decision_code(ResearchActionDecisionCode decision) {
static const char *const codes[] = {"AUTHORIZE", "DEFER", "REFUSE"};
return decision <= RESEARCH_ACTION_REFUSE ? codes[decision] : NULL;
}
static gboolean bind_text(sqlite3_stmt *statement, int index,
const char *value);
static gboolean research_action_guard(const ResearchAction *action) {
return action != NULL && g_uuid_string_is_valid(action->action_id) &&
research_text(action->capability_id) && research_text(action->provider_id) &&
research_text(action->endpoint) && research_text(action->subject) &&
research_contact_code(action->contact) != NULL &&
research_text(action->disclosure) && action->max_requests > 0 &&
action->max_response_bytes > 0 &&
action->max_response_bytes <= G_MAXINT64 && action->max_active_ms > 0;
}
static gboolean research_plan_guard(const ResearchPlan *plan) {
if (plan == NULL || g_strcmp0(plan->contract, RESEARCH_PLAN_CONTRACT) != 0 ||
!g_uuid_string_is_valid(plan->plan_id) ||
!research_text(plan->idempotency_key) ||
!research_hash(plan->content_fingerprint) ||
!research_hash(plan->input_fingerprint) || plan->input_revision == 0 ||
!research_timestamp(plan->created_at) || plan->seeds == NULL ||
plan->seed_count == 0 || plan->actions == NULL || plan->action_count == 0)
return FALSE;
GHashTable *ids = g_hash_table_new(g_str_hash, g_str_equal);
gboolean valid = TRUE;
for (gsize i = 0; valid && i < plan->seed_count; i++)
valid = research_seed_code(plan->seeds[i].kind) != NULL &&
research_text(plan->seeds[i].subject);
for (gsize i = 0; valid && i < plan->action_count; i++)
valid = research_action_guard(&plan->actions[i]) &&
g_hash_table_add(ids, (gpointer)plan->actions[i].action_id);
g_hash_table_unref(ids);
return valid;
}
static gboolean research_grant_guard(const ScopeGrant *grant,
const ResearchActionDecision *decisions, gsize decision_count,
const char *decided_at) {
if (grant == NULL || g_strcmp0(grant->contract, RESEARCH_GRANT_CONTRACT) != 0 ||
!g_uuid_string_is_valid(grant->grant_id) ||
!g_uuid_string_is_valid(grant->investigation_id) ||
!research_text(grant->idempotency_key) ||
!research_hash(grant->content_fingerprint) ||
!research_hash(grant->plan_fingerprint) ||
!research_timestamp(grant->created_at) ||
!research_timestamp(grant->expires_at) ||
grant->selected_action_ids == NULL || grant->selected_action_count == 0 ||
decisions == NULL || decision_count == 0 ||
!research_timestamp(decided_at) ||
grant->max_requests == 0 || grant->max_response_bytes == 0 ||
grant->max_response_bytes > G_MAXINT64 ||
grant->max_active_ms == 0)
return FALSE;
GHashTable *ids = g_hash_table_new(g_str_hash, g_str_equal);
for (gsize i = 0; i < grant->selected_action_count; i++)
if (!g_uuid_string_is_valid(grant->selected_action_ids[i]))
goto invalid;
for (gsize i = 0; i < decision_count; i++)
if (!g_uuid_string_is_valid(decisions[i].action_id) ||
research_decision_code(decisions[i].code) == NULL ||
!g_hash_table_add(ids, (gpointer)decisions[i].action_id))
goto invalid;
for (gsize i = 0; i < grant->excluded_subject_count; i++)
if (!research_text(grant->excluded_subjects[i]))
goto invalid;
g_hash_table_unref(ids);
return TRUE;
invalid:
g_hash_table_unref(ids);
return FALSE;
}
/* CONTRACT: chaque admission conserve une décision humaine pour exactement
* chaque action du ResearchPlan référencé. WHY: une projection partielle ne
* permettrait pas d'auditer les actions DEFER ou REFUSE. */
static gboolean research_grant_decisions_cover_plan(sqlite3 *db,
const ScopeGrant *grant, const ResearchActionDecision *decisions,
gsize decision_count) {
sqlite3_stmt *count = NULL;
gboolean ok = sqlite3_prepare_v2(db,
"SELECT count(*) FROM research_actions a JOIN research_plans p "
"ON p.plan_id=a.plan_id WHERE p.investigation_id=?1 "
"AND p.content_fingerprint=?2;", -1, &count, NULL) == SQLITE_OK &&
bind_text(count, 1, grant->investigation_id) &&
bind_text(count, 2, grant->plan_fingerprint) &&
sqlite3_step(count) == SQLITE_ROW &&
sqlite3_column_int64(count, 0) == (sqlite3_int64)decision_count;
sqlite3_finalize(count);
/* INVARIANT: les UUID sont déjà uniques via research_grant_guard(); avec
* la même cardinalité, leur appartenance au plan rend la couverture
* bijective, sans migration du schéma V4. */
for (gsize i = 0; ok && i < decision_count; i++) {
sqlite3_stmt *member = NULL;
ok = sqlite3_prepare_v2(db,
"SELECT EXISTS(SELECT 1 FROM research_actions a "
"JOIN research_plans p ON p.plan_id=a.plan_id "
"WHERE a.action_id=?1 AND p.investigation_id=?2 "
"AND p.content_fingerprint=?3);", -1, &member, NULL) == SQLITE_OK &&
bind_text(member, 1, decisions[i].action_id) &&
bind_text(member, 2, grant->investigation_id) &&
bind_text(member, 3, grant->plan_fingerprint) &&
sqlite3_step(member) == SQLITE_ROW && sqlite3_column_int(member, 0) == 1;
sqlite3_finalize(member);
}
return ok;
}
static gboolean research_campaign_guard(const ResearchCampaign *campaign) {
return campaign != NULL &&
g_strcmp0(campaign->contract, RESEARCH_CAMPAIGN_CONTRACT) == 0 &&
g_uuid_string_is_valid(campaign->campaign_id) &&
g_uuid_string_is_valid(campaign->investigation_id) &&
g_uuid_string_is_valid(campaign->grant_id) &&
research_text(campaign->idempotency_key) &&
research_hash(campaign->content_fingerprint) &&
research_timestamp(campaign->created_at);
}
static gboolean research_result_guard(const ResearchResult *result) {
return result != NULL &&
g_strcmp0(result->contract, RESEARCH_RESULT_CONTRACT) == 0 &&
g_uuid_string_is_valid(result->result_id) &&
g_uuid_string_is_valid(result->campaign_id) &&
g_uuid_string_is_valid(result->action_id) &&
research_relative_artifact(result->raw_artifact_relative_path) &&
research_hash(result->content_sha256) && research_text(result->status);
}
static gboolean research_receipt_guard(const ResearchReceipt *receipt) {
return receipt != NULL &&
g_strcmp0(receipt->contract, RESEARCH_RECEIPT_CONTRACT) == 0 &&
g_uuid_string_is_valid(receipt->receipt_id) &&
g_uuid_string_is_valid(receipt->result_id) &&
g_uuid_string_is_valid(receipt->campaign_id) &&
g_uuid_string_is_valid(receipt->grant_id) &&
g_uuid_string_is_valid(receipt->action_id) &&
research_timestamp(receipt->decided_at) &&
research_text(receipt->decision_code);
}
#ifdef LOCAL_JOB_STORE_ENABLE_TEST_HOOKS
static gboolean fail_migration_after_schema = FALSE;
void local_job_store_test_fail_migration_after_schema(gboolean enabled) {
@ -173,11 +431,11 @@ LocalJobStore *local_job_store_create(const char *path,
return NULL;
char *sql = g_strdup_printf(
"BEGIN IMMEDIATE;%s"
"%sINSERT INTO metadata VALUES('schema_version','3');"
"%s%sINSERT INTO metadata VALUES('schema_version','4');"
"INSERT INTO metadata VALUES('investigation_id','%s');"
"INSERT INTO metadata VALUES('revision','0');"
"INSERT INTO controls VALUES(1,0,0,'1970-01-01T00:00:00Z');COMMIT;",
schema_sql, plan_schema_sql, investigation_id);
schema_sql, plan_schema_sql, research_schema_sql, investigation_id);
gboolean success =
exec_sql(store->db, sql, error) && g_chmod(path, 0600) == 0;
g_free(sql);
@ -216,26 +474,28 @@ LocalJobStore *local_job_store_open(const char *path,
owner = g_strcmp0(value, investigation_id) == 0;
}
sqlite3_finalize(statement);
if ((schema_version < 1 || schema_version > 3) || !owner) {
if ((schema_version < 1 || schema_version > 4) || !owner) {
job_error(error, G_IO_ERROR_INVALID_DATA,
schema_version < 1 || schema_version > 3
schema_version < 1 || schema_version > 4
? "Version JobStore inconnue ; aucune recréation automatique."
: "Le JobStore appartient à une autre enquête.");
local_job_store_close(store);
return NULL;
}
/* WHY: V2 ne touche que l'ordonnanceur local. Le lecteur read-only conserve
* la compatibilité V1 et ne migre jamais; seul un ouvreur exclusif en écriture
* applique atomiquement l'ajout non destructif des plans et budgets. */
if (schema_version < 3 && !read_only) {
/* WHY: V4 reste une base opérationnelle distincte de V20/V21. Le lecteur
* read-only conserve les versions historiques sans les migrer; seul un
* ouvreur en écriture applique les ajouts dans une transaction unique. */
if (schema_version < 4 && !read_only) {
/* CONTRACT: schéma, données et version sont une seule transaction. La
* séparation des appels rend testable un crash logique après mutation,
* sans affaiblir l'atomicité SQLite. */
gboolean migrated = exec_sql(store->db, "BEGIN IMMEDIATE;", error);
if (migrated && schema_version == 1)
migrated = exec_sql(store->db, plan_schema_sql, error);
if (migrated)
if (migrated && schema_version < 3)
migrated = exec_sql(store->db, v2_to_v3_sql, error);
if (migrated)
migrated = exec_sql(store->db, research_schema_sql, error);
#ifdef LOCAL_JOB_STORE_ENABLE_TEST_HOOKS
if (migrated && fail_migration_after_schema) {
job_error(error, G_IO_ERROR_FAILED,
@ -245,7 +505,7 @@ LocalJobStore *local_job_store_open(const char *path,
#endif
if (migrated)
migrated = exec_sql(store->db,
"UPDATE metadata SET value='3' WHERE key='schema_version';", error);
"UPDATE metadata SET value='4' WHERE key='schema_version';", error);
if (migrated)
migrated = exec_sql(store->db, "COMMIT;", error);
if (!migrated) {
@ -254,6 +514,16 @@ LocalJobStore *local_job_store_open(const char *path,
return NULL;
}
}
if (schema_version == 4 && !read_only) {
gboolean extended = exec_sql(store->db, "BEGIN IMMEDIATE;", error) &&
exec_sql(store->db, research_decision_extension_sql, error) &&
exec_sql(store->db, "COMMIT;", error);
if (!extended) {
exec_sql(store->db, "ROLLBACK;", NULL);
local_job_store_close(store);
return NULL;
}
}
return store;
failure:
sqlite3_finalize(statement);
@ -1004,6 +1274,494 @@ gboolean local_job_store_integrity(LocalJobStore *s, GError **error) {
return ok;
}
static gboolean research_begin(LocalJobStore *store, GError **error) {
if (store == NULL) {
job_error(error, G_IO_ERROR_INVALID_ARGUMENT, "ResearchStore absent.");
return FALSE;
}
return exec_sql(store->db, "BEGIN IMMEDIATE;", error);
}
static gboolean research_existing(sqlite3 *db, const char *table,
const char *investigation_id,
const char *idempotency_key,
const char *content_fingerprint,
gboolean *out_found, gboolean *out_same) {
char *sql = g_strdup_printf(
"SELECT content_fingerprint FROM %s WHERE investigation_id=?1 AND "
"idempotency_key=?2;", table);
sqlite3_stmt *statement = NULL;
gboolean ok = sqlite3_prepare_v2(db, sql, -1, &statement, NULL) == SQLITE_OK &&
bind_text(statement, 1, investigation_id) &&
bind_text(statement, 2, idempotency_key);
g_free(sql);
if (!ok) {
sqlite3_finalize(statement);
return FALSE;
}
int step = sqlite3_step(statement);
*out_found = step == SQLITE_ROW;
*out_same = *out_found &&
g_strcmp0((const char *)sqlite3_column_text(statement, 0),
content_fingerprint) == 0;
ok = step == SQLITE_ROW || step == SQLITE_DONE;
sqlite3_finalize(statement);
return ok;
}
gboolean research_store_admit_plan(LocalJobStore *store,
const ResearchPlan *plan,
gboolean *out_reused, GError **error) {
if (out_reused != NULL)
*out_reused = FALSE;
if (store == NULL || !research_plan_guard(plan)) {
job_error(error, G_IO_ERROR_INVALID_ARGUMENT,
"Contrat ResearchPlan V1 invalide.");
return FALSE;
}
if (!research_begin(store, error))
return FALSE;
gboolean found = FALSE, same = FALSE;
gboolean ok = research_existing(store->db, "research_plans",
store->investigation_id, plan->idempotency_key,
plan->content_fingerprint, &found, &same);
if (ok && found) {
if (!same) {
job_error(error, G_IO_ERROR_EXISTS,
"Clé ResearchPlan réutilisée avec un contenu différent.");
exec_sql(store->db, "ROLLBACK;", NULL);
return FALSE;
}
if (out_reused != NULL)
*out_reused = TRUE;
return exec_sql(store->db, "COMMIT;", error);
}
sqlite3_stmt *insert = NULL;
ok = ok && sqlite3_prepare_v2(store->db,
"INSERT INTO research_plans VALUES(?1,?2,?3,?4,?5,?6,?7);",
-1, &insert, NULL) == SQLITE_OK &&
bind_text(insert, 1, plan->plan_id) &&
bind_text(insert, 2, store->investigation_id) &&
bind_text(insert, 3, plan->idempotency_key) &&
bind_text(insert, 4, plan->content_fingerprint) &&
bind_text(insert, 5, plan->input_fingerprint) &&
sqlite3_bind_int64(insert, 6, (sqlite3_int64)plan->input_revision) ==
SQLITE_OK &&
bind_text(insert, 7, plan->created_at) &&
sqlite3_step(insert) == SQLITE_DONE;
sqlite3_finalize(insert);
for (gsize i = 0; ok && i < plan->seed_count; i++) {
sqlite3_stmt *seed = NULL;
ok = sqlite3_prepare_v2(store->db,
"INSERT INTO research_seeds VALUES(?1,?2,?3,?4);", -1, &seed,
NULL) == SQLITE_OK && bind_text(seed, 1, plan->plan_id) &&
sqlite3_bind_int64(seed, 2, (sqlite3_int64)i) == SQLITE_OK &&
bind_text(seed, 3, research_seed_code(plan->seeds[i].kind)) &&
bind_text(seed, 4, plan->seeds[i].subject) &&
sqlite3_step(seed) == SQLITE_DONE;
sqlite3_finalize(seed);
}
for (gsize i = 0; ok && i < plan->action_count; i++) {
const ResearchAction *action = &plan->actions[i];
sqlite3_stmt *item = NULL;
ok = sqlite3_prepare_v2(store->db,
"INSERT INTO research_actions VALUES("
"?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12);", -1, &item,
NULL) == SQLITE_OK && bind_text(item, 1, action->action_id) &&
bind_text(item, 2, plan->plan_id) &&
sqlite3_bind_int64(item, 3, (sqlite3_int64)i) == SQLITE_OK &&
bind_text(item, 4, action->capability_id) &&
bind_text(item, 5, action->provider_id) &&
bind_text(item, 6, action->endpoint) &&
bind_text(item, 7, action->subject) &&
bind_text(item, 8, research_contact_code(action->contact)) &&
bind_text(item, 9, action->disclosure) &&
sqlite3_bind_int64(item, 10, action->max_requests) == SQLITE_OK &&
sqlite3_bind_int64(item, 11,
(sqlite3_int64)action->max_response_bytes) ==
SQLITE_OK &&
sqlite3_bind_int64(item, 12, action->max_active_ms) == SQLITE_OK &&
sqlite3_step(item) == SQLITE_DONE;
sqlite3_finalize(item);
}
if (ok)
ok = exec_sql(store->db, "COMMIT;", error);
if (!ok) {
exec_sql(store->db, "ROLLBACK;", NULL);
job_error(error, G_IO_ERROR_FAILED,
"Admission atomique du ResearchPlan impossible.");
}
return ok;
}
gboolean research_store_admit_grant(LocalJobStore *store,
const ScopeGrant *grant,
const ResearchActionDecision *decisions,
gsize decision_count,
const char *decided_at,
gboolean *out_reused, GError **error) {
if (out_reused != NULL)
*out_reused = FALSE;
if (store == NULL || !research_grant_guard(grant, decisions,
decision_count, decided_at)) {
job_error(error, G_IO_ERROR_INVALID_ARGUMENT,
"Contrat ScopeGrant V1 invalide.");
return FALSE;
}
if (g_strcmp0(store->investigation_id, grant->investigation_id) != 0) {
job_error(error, G_IO_ERROR_PERMISSION_DENIED,
"Grant refusé pour une autre enquête.");
return FALSE;
}
if (!research_begin(store, error))
return FALSE;
if (!research_grant_decisions_cover_plan(store->db, grant, decisions,
decision_count)) {
exec_sql(store->db, "ROLLBACK;", NULL);
job_error(error, G_IO_ERROR_INVALID_ARGUMENT,
"Les décisions ne couvrent pas bijectivement le ResearchPlan.");
return FALSE;
}
gboolean found = FALSE, same = FALSE;
gboolean ok = research_existing(store->db, "scope_grants",
grant->investigation_id, grant->idempotency_key,
grant->content_fingerprint, &found, &same);
if (ok && found) {
if (!same) {
job_error(error, G_IO_ERROR_EXISTS,
"Clé ScopeGrant réutilisée avec un contenu différent.");
exec_sql(store->db, "ROLLBACK;", NULL);
return FALSE;
}
if (out_reused != NULL)
*out_reused = TRUE;
return exec_sql(store->db, "COMMIT;", error);
}
sqlite3_stmt *insert = NULL;
ok = ok && sqlite3_prepare_v2(store->db,
"INSERT INTO scope_grants VALUES("
"?1,?2,?3,?4,?5,?6,?7,NULL,?8,?9,?10);", -1, &insert,
NULL) == SQLITE_OK && bind_text(insert, 1, grant->grant_id) &&
bind_text(insert, 2, grant->investigation_id) &&
bind_text(insert, 3, grant->idempotency_key) &&
bind_text(insert, 4, grant->content_fingerprint) &&
bind_text(insert, 5, grant->plan_fingerprint) &&
bind_text(insert, 6, grant->created_at) &&
bind_text(insert, 7, grant->expires_at) &&
sqlite3_bind_int64(insert, 8, grant->max_requests) == SQLITE_OK &&
sqlite3_bind_int64(insert, 9,
(sqlite3_int64)grant->max_response_bytes) ==
SQLITE_OK &&
sqlite3_bind_int64(insert, 10, grant->max_active_ms) == SQLITE_OK &&
sqlite3_step(insert) == SQLITE_DONE;
sqlite3_finalize(insert);
/* CONTRACT: la décision humaine est persistée avant de matérialiser le
* grant. REFUSE est terminal : une requête ultérieure ne peut ni l'effacer
* ni réadmettre l'action sous une nouvelle clé de grant. */
for (gsize i = 0; ok && i < decision_count; i++) {
sqlite3_stmt *decision = NULL;
ok = sqlite3_prepare_v2(store->db,
"INSERT INTO research_action_decisions(action_id,plan_id,decision_code,decided_at) "
"SELECT ?1,p.plan_id,?2,?3 FROM research_actions a "
"JOIN research_plans p ON p.plan_id=a.plan_id "
"WHERE a.action_id=?1 AND p.investigation_id=?4 "
"AND p.content_fingerprint=?5 "
"ON CONFLICT(action_id) DO UPDATE SET decision_code=excluded.decision_code,"
"decided_at=excluded.decided_at WHERE "
"research_action_decisions.decision_code!='REFUSE' "
"OR excluded.decision_code='REFUSE';", -1, &decision, NULL) == SQLITE_OK &&
bind_text(decision, 1, decisions[i].action_id) &&
bind_text(decision, 2,
research_decision_code(decisions[i].code)) &&
bind_text(decision, 3, decided_at) &&
bind_text(decision, 4, grant->investigation_id) &&
bind_text(decision, 5, grant->plan_fingerprint) &&
sqlite3_step(decision) == SQLITE_DONE &&
sqlite3_changes(store->db) == 1;
sqlite3_finalize(decision);
}
for (gsize i = 0; ok && i < grant->selected_action_count; i++) {
sqlite3_stmt *selected = NULL;
ok = sqlite3_prepare_v2(store->db,
"INSERT INTO scope_grant_actions(grant_id,action_id,subject,provider_id,endpoint) "
"SELECT ?1,a.action_id,a.subject,a.provider_id,a.endpoint FROM research_actions a "
"JOIN research_plans p ON p.plan_id=a.plan_id WHERE a.action_id=?2 "
"AND p.investigation_id=?3 AND p.content_fingerprint=?4 "
"AND EXISTS(SELECT 1 FROM research_action_decisions d "
"WHERE d.action_id=a.action_id AND d.decision_code='AUTHORIZE') "
"AND (a.rank!=1 OR EXISTS(SELECT 1 FROM research_actions first "
"JOIN research_action_decisions fd ON fd.action_id=first.action_id "
"JOIN research_results rr ON rr.action_id=first.action_id "
"JOIN research_receipts rc ON rc.result_id=rr.result_id "
"WHERE first.plan_id=a.plan_id AND first.rank=0 "
"AND fd.decision_code='AUTHORIZE'));", -1,
&selected, NULL) == SQLITE_OK &&
bind_text(selected, 1, grant->grant_id) &&
bind_text(selected, 2, grant->selected_action_ids[i]) &&
bind_text(selected, 3, grant->investigation_id) &&
bind_text(selected, 4, grant->plan_fingerprint) &&
sqlite3_step(selected) == SQLITE_DONE && sqlite3_changes(store->db) == 1;
sqlite3_finalize(selected);
}
for (gsize i = 0; ok && i < grant->excluded_subject_count; i++) {
sqlite3_stmt *excluded = NULL;
ok = sqlite3_prepare_v2(store->db,
"INSERT INTO scope_grant_exclusions VALUES(?1,?2);", -1, &excluded,
NULL) == SQLITE_OK && bind_text(excluded, 1, grant->grant_id) &&
bind_text(excluded, 2, grant->excluded_subjects[i]) &&
sqlite3_step(excluded) == SQLITE_DONE;
sqlite3_finalize(excluded);
}
if (ok)
ok = exec_sql(store->db, "COMMIT;", error);
if (!ok) {
exec_sql(store->db, "ROLLBACK;", NULL);
job_error(error, G_IO_ERROR_FAILED,
"Admission atomique du ScopeGrant impossible.");
}
return ok;
}
gboolean research_store_revoke_grant(LocalJobStore *store,
const char *grant_id,
const char *revoked_at,
GError **error) {
sqlite3_stmt *statement = NULL;
gboolean ok = store != NULL && g_uuid_string_is_valid(grant_id) &&
revoked_at != NULL && sqlite3_prepare_v2(store->db,
"UPDATE scope_grants SET revoked_at=COALESCE(revoked_at,?2) "
"WHERE grant_id=?1 AND investigation_id=?3;", -1, &statement,
NULL) == SQLITE_OK && bind_text(statement, 1, grant_id) &&
bind_text(statement, 2, revoked_at) &&
bind_text(statement, 3, store->investigation_id) &&
sqlite3_step(statement) == SQLITE_DONE && sqlite3_changes(store->db) == 1;
sqlite3_finalize(statement);
if (!ok)
job_error(error, G_IO_ERROR_NOT_FOUND, "ScopeGrant à révoquer introuvable.");
return ok;
}
gboolean research_store_admit_campaign(LocalJobStore *store,
const ResearchCampaign *campaign,
gboolean *out_reused, GError **error) {
if (out_reused != NULL)
*out_reused = FALSE;
if (store == NULL || !research_campaign_guard(campaign)) {
job_error(error, G_IO_ERROR_INVALID_ARGUMENT,
"Contrat ResearchCampaign V1 invalide.");
return FALSE;
}
if (g_strcmp0(store->investigation_id, campaign->investigation_id) != 0) {
job_error(error, G_IO_ERROR_PERMISSION_DENIED,
"Campagne refusée pour une autre enquête.");
return FALSE;
}
if (!research_begin(store, error))
return FALSE;
gboolean found = FALSE, same = FALSE;
gboolean ok = research_existing(store->db, "research_campaigns",
campaign->investigation_id, campaign->idempotency_key,
campaign->content_fingerprint, &found, &same);
if (ok && found) {
if (!same) {
job_error(error, G_IO_ERROR_EXISTS,
"Clé ResearchCampaign réutilisée avec un contenu différent.");
exec_sql(store->db, "ROLLBACK;", NULL);
return FALSE;
}
if (out_reused != NULL)
*out_reused = TRUE;
return exec_sql(store->db, "COMMIT;", error);
}
sqlite3_stmt *insert = NULL;
ok = ok && sqlite3_prepare_v2(store->db,
"INSERT INTO research_campaigns(campaign_id,investigation_id,grant_id,"
"idempotency_key,content_fingerprint,created_at) "
"SELECT ?1,?2,g.grant_id,?4,?5,?6 FROM scope_grants g "
"WHERE g.grant_id=?3 AND g.investigation_id=?2;", -1, &insert,
NULL) == SQLITE_OK && bind_text(insert, 1, campaign->campaign_id) &&
bind_text(insert, 2, campaign->investigation_id) &&
bind_text(insert, 3, campaign->grant_id) &&
bind_text(insert, 4, campaign->idempotency_key) &&
bind_text(insert, 5, campaign->content_fingerprint) &&
bind_text(insert, 6, campaign->created_at) &&
sqlite3_step(insert) == SQLITE_DONE && sqlite3_changes(store->db) == 1;
sqlite3_finalize(insert);
if (ok)
ok = exec_sql(store->db, "COMMIT;", error);
if (!ok) {
exec_sql(store->db, "ROLLBACK;", NULL);
job_error(error, G_IO_ERROR_FAILED,
"Admission atomique de la ResearchCampaign impossible.");
}
return ok;
}
gboolean research_store_record_result(LocalJobStore *store,
const ResearchResult *result,
const ResearchReceipt *receipt,
GError **error) {
if (store == NULL || !research_result_guard(result) ||
!research_receipt_guard(receipt)) {
job_error(error, G_IO_ERROR_INVALID_ARGUMENT,
"Contrat résultat ou reçu de recherche invalide.");
return FALSE;
}
if (g_strcmp0(result->result_id, receipt->result_id) != 0 ||
g_strcmp0(result->campaign_id, receipt->campaign_id) != 0 ||
g_strcmp0(result->action_id, receipt->action_id) != 0) {
job_error(error, G_IO_ERROR_INVALID_ARGUMENT,
"Résultat et reçu de recherche incohérents.");
return FALSE;
}
if (!research_begin(store, error))
return FALSE;
sqlite3_stmt *item = NULL;
gboolean ok = sqlite3_prepare_v2(store->db,
"INSERT INTO research_results VALUES(?1,?2,?3,?4,?5,?6);", -1,
&item, NULL) == SQLITE_OK && bind_text(item, 1, result->result_id) &&
bind_text(item, 2, result->campaign_id) &&
bind_text(item, 3, result->action_id) &&
bind_text(item, 4, result->raw_artifact_relative_path) &&
bind_text(item, 5, result->content_sha256) &&
bind_text(item, 6, result->status) && sqlite3_step(item) == SQLITE_DONE;
sqlite3_finalize(item);
sqlite3_stmt *proof = NULL;
ok = ok && sqlite3_prepare_v2(store->db,
"INSERT INTO research_receipts "
"SELECT ?1,?2,c.campaign_id,c.grant_id,?4,?5,?6 "
"FROM research_campaigns c JOIN scope_grant_actions ga "
"ON ga.grant_id=c.grant_id AND ga.action_id=?4 "
"WHERE c.campaign_id=?3 AND c.investigation_id=?7 AND c.grant_id=?8;",
-1, &proof, NULL) == SQLITE_OK &&
bind_text(proof, 1, receipt->receipt_id) &&
bind_text(proof, 2, receipt->result_id) &&
bind_text(proof, 3, receipt->campaign_id) &&
bind_text(proof, 4, receipt->action_id) &&
bind_text(proof, 5, receipt->decided_at) &&
bind_text(proof, 6, receipt->decision_code) &&
bind_text(proof, 7, store->investigation_id) &&
bind_text(proof, 8, receipt->grant_id) &&
sqlite3_step(proof) == SQLITE_DONE && sqlite3_changes(store->db) == 1;
sqlite3_finalize(proof);
if (ok)
ok = exec_sql(store->db, "COMMIT;", error);
if (!ok) {
exec_sql(store->db, "ROLLBACK;", NULL);
job_error(error, G_IO_ERROR_FAILED,
"Publication atomique du résultat et du reçu impossible.");
}
return ok;
}
gboolean research_store_policy_decide(LocalJobStore *store,
const char *grant_id,
const ResearchPolicyRequest *request,
ResearchPolicyDecision *out_decision,
GError **error) {
if (store == NULL || !g_uuid_string_is_valid(grant_id) || request == NULL ||
!research_action_guard(request->action) || out_decision == NULL ||
!research_timestamp(request->now)) {
job_error(error, G_IO_ERROR_INVALID_ARGUMENT,
"Requête de policy de recherche invalide.");
return FALSE;
}
sqlite3_stmt *query = NULL;
gboolean ok = sqlite3_prepare_v2(store->db,
"SELECT g.investigation_id,g.plan_fingerprint,g.expires_at,g.revoked_at,"
"g.max_requests,g.max_response_bytes,g.max_active_ms,"
"EXISTS(SELECT 1 FROM scope_grant_exclusions e WHERE e.grant_id=g.grant_id AND e.subject=?2) "
"FROM scope_grants g WHERE g.grant_id=?1;", -1, &query, NULL) ==
SQLITE_OK && bind_text(query, 1, grant_id) &&
bind_text(query, 2, request->action->subject) &&
sqlite3_step(query) == SQLITE_ROW;
if (!ok) {
sqlite3_finalize(query);
job_error(error, G_IO_ERROR_NOT_FOUND, "ScopeGrant de policy introuvable.");
return FALSE;
}
const char *owner = (const char *)sqlite3_column_text(query, 0);
const char *plan = (const char *)sqlite3_column_text(query, 1);
const char *expires = (const char *)sqlite3_column_text(query, 2);
GDateTime *now_value = g_date_time_new_from_iso8601(request->now, NULL);
GDateTime *expires_value = g_date_time_new_from_iso8601(expires, NULL);
if (now_value == NULL || expires_value == NULL) {
if (now_value != NULL)
g_date_time_unref(now_value);
if (expires_value != NULL)
g_date_time_unref(expires_value);
sqlite3_finalize(query);
job_error(error, G_IO_ERROR_INVALID_DATA,
"Horodatage persistant du ScopeGrant invalide.");
return FALSE;
}
gboolean expired = g_date_time_compare(now_value, expires_value) >= 0;
g_date_time_unref(now_value);
g_date_time_unref(expires_value);
gboolean revoked = sqlite3_column_type(query, 3) != SQLITE_NULL;
guint64 max_requests = (guint64)sqlite3_column_int64(query, 4);
guint64 max_bytes = (guint64)sqlite3_column_int64(query, 5);
guint64 max_ms = (guint64)sqlite3_column_int64(query, 6);
gboolean excluded = sqlite3_column_int(query, 7) != 0;
ResearchPolicyDecision decision = RESEARCH_POLICY_ALLOW;
if (excluded)
decision = RESEARCH_POLICY_DENY_EXCLUDED;
else if (revoked)
decision = RESEARCH_POLICY_DENY_REVOKED;
else if (expired)
decision = RESEARCH_POLICY_DENY_EXPIRED;
else if (g_strcmp0(owner, store->investigation_id) != 0 ||
g_strcmp0(owner, request->investigation_id) != 0)
decision = RESEARCH_POLICY_DENY_INVESTIGATION;
else if (g_strcmp0(plan, request->plan_fingerprint) != 0)
decision = RESEARCH_POLICY_DENY_PLAN;
else if (request->requested_requests > max_requests ||
request->requested_response_bytes > max_bytes ||
request->requested_active_ms > max_ms)
decision = RESEARCH_POLICY_DENY_BUDGET;
sqlite3_finalize(query);
if (decision == RESEARCH_POLICY_ALLOW) {
const ResearchAction *action = request->action;
sqlite3_stmt *selected = NULL;
ok = sqlite3_prepare_v2(store->db,
"SELECT a.capability_id,a.contact_class,a.disclosure FROM scope_grant_actions ga "
"JOIN research_actions a ON a.action_id=ga.action_id WHERE ga.grant_id=?1 "
"AND ga.action_id=?2 AND ga.subject=?3 AND ga.provider_id=?4 AND ga.endpoint=?5 "
"AND EXISTS(SELECT 1 FROM research_action_decisions d "
"WHERE d.action_id=a.action_id AND d.decision_code='AUTHORIZE') "
"AND (a.rank!=1 OR EXISTS(SELECT 1 FROM research_actions first "
"JOIN research_action_decisions fd ON fd.action_id=first.action_id "
"JOIN research_results rr ON rr.action_id=first.action_id "
"JOIN research_receipts rc ON rc.result_id=rr.result_id "
"WHERE first.plan_id=a.plan_id AND first.rank=0 "
"AND fd.decision_code='AUTHORIZE'));",
-1, &selected, NULL) == SQLITE_OK && bind_text(selected, 1, grant_id) &&
bind_text(selected, 2, action->action_id) &&
bind_text(selected, 3, action->subject) &&
bind_text(selected, 4, action->provider_id) &&
bind_text(selected, 5, action->endpoint);
int step = ok ? sqlite3_step(selected) : SQLITE_ERROR;
if (step == SQLITE_DONE)
decision = RESEARCH_POLICY_DENY_ACTION;
else if (step != SQLITE_ROW)
ok = FALSE;
else if (g_strcmp0((const char *)sqlite3_column_text(selected, 0),
action->capability_id) != 0 ||
g_strcmp0((const char *)sqlite3_column_text(selected, 1),
research_contact_code(action->contact)) != 0 ||
g_strcmp0((const char *)sqlite3_column_text(selected, 2),
action->disclosure) != 0)
decision = RESEARCH_POLICY_DENY_ACTION_CONTENT;
sqlite3_finalize(selected);
}
if (!ok) {
job_error(error, G_IO_ERROR_FAILED, "Évaluation de policy impossible.");
return FALSE;
}
*out_decision = decision;
return TRUE;
}
gboolean local_job_store_export_atomic(LocalJobStore *s, const char *path,
GError **error) {
GPtrArray *jobs = local_job_store_list(s, error);

View file

@ -0,0 +1,26 @@
#include "core/research_adapter_cdx.h"
#include <string.h>
static gboolean timestamp(const char *s){if(!s||strlen(s)!=14)return FALSE;for(guint i=0;i<14;i++)if(!g_ascii_isdigit(s[i]))return FALSE;return TRUE;}
static const char *host_of(const char *url,GUri **owned){*owned=g_uri_parse(url,G_URI_FLAGS_NONE,NULL);return *owned?g_uri_get_host(*owned):NULL;}
gboolean research_adapter_cdx_normalize(const ResearchAction *a,ResearchCdxMatch match,const ResearchCdxFixture *f,ResearchCdxResult *out,GError **error){
if (!a || !a->subject || !f || !out || match > RESEARCH_CDX_EXACT_HOST ||
(f->row_count > 0 && f->rows == NULL)) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"Réponse CDX invalide.");
return FALSE;
}
ResearchCdxResult r={.captures=g_ptr_array_new_with_free_func(g_free)};
GUri *subject_uri=NULL;const char *subject_host=match==RESEARCH_CDX_EXACT_HOST?host_of(a->subject,&subject_uri):NULL;
if(match==RESEARCH_CDX_EXACT_HOST&&!subject_host)subject_host=a->subject;
for(gsize i=0;i<f->row_count;i++){const ResearchCdxRow *row=&f->rows[i];GUri *u=NULL;const char *h=host_of(row->original_url,&u);
gboolean exact=match==RESEARCH_CDX_EXACT_URL?g_strcmp0(row->original_url,a->subject)==0:(h&&g_ascii_strcasecmp(h,subject_host)==0);
if(!exact||!timestamp(row->timestamp)||!row->digest){if(u)g_uri_unref(u);if(subject_uri)g_uri_unref(subject_uri);research_cdx_result_clear(&r);g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_DATA,"Capture CDX hors sujet ou date invalide.");return FALSE;}
g_ptr_array_add(r.captures,g_strdup_printf("%s\t%s\t%u\t%s",row->timestamp,row->original_url,row->status_code,row->digest));if(u)g_uri_unref(u);}
if (subject_uri)
g_uri_unref(subject_uri);
r.next_cursor = g_strdup(f->next_cursor);
r.next_piste = r.captures->len > 0;
*out = r;
return TRUE;
}
void research_cdx_result_clear(ResearchCdxResult *r){if(!r)return;g_clear_pointer(&r->captures,g_ptr_array_unref);g_free(r->next_cursor);*r=(ResearchCdxResult){0};}

View file

@ -0,0 +1,48 @@
#include "core/research_adapter_dns.h"
#include <string.h>
static gboolean dns_name(const char *s) {
if (!s || !*s || strlen(s) > 253 || strchr(s, '\\') || strchr(s, '@')) return FALSE;
for (const unsigned char *p=(const unsigned char *)s; *p; p++)
if (*p < 0x21 || *p == 0x7f) return FALSE;
return TRUE;
}
static char *record_string(const ResearchDnsRecord *r) {
return g_strdup_printf("%s\t%u\t%u\t%s", r->owner, r->ttl,
r->preference, r->value);
}
gboolean research_adapter_dns_normalize(const ResearchAction *action,
ResearchDnsType requested_type, const ResearchDnsFixture *fixture,
ResearchDnsResult *out, GError **error) {
if (!action || !dns_name(action->subject) || !fixture || !out ||
(fixture->record_count > 0 && fixture->records == NULL) ||
requested_type > RESEARCH_DNS_PTR || fixture->status > RESEARCH_DNS_STATUS_TIMEOUT) {
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_ARGUMENT,"Réponse DNS typée invalide."); return FALSE;
}
ResearchDnsResult result={.status=fixture->status,
.records=g_ptr_array_new_with_free_func(g_free)};
GHashTable *edges=g_hash_table_new_full(g_str_hash,g_str_equal,g_free,g_free);
for (gsize i=0;i<fixture->record_count;i++) {
const ResearchDnsRecord *r=&fixture->records[i];
if (r->type != requested_type || !dns_name(r->owner) || !r->value || !*r->value) {
g_hash_table_unref(edges); research_dns_result_clear(&result);
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_DATA,"Enregistrement DNS incohérent."); return FALSE;
}
g_ptr_array_add(result.records,record_string(r));
if (r->type == RESEARCH_DNS_CNAME) g_hash_table_replace(edges,g_ascii_strdown(r->owner,-1),g_ascii_strdown(r->value,-1));
}
if (requested_type == RESEARCH_DNS_CNAME) {
GHashTableIter iter; gpointer start;
g_hash_table_iter_init(&iter,edges);
while (g_hash_table_iter_next(&iter,&start,NULL)) {
GHashTable *seen=g_hash_table_new(g_str_hash,g_str_equal); const char *cur=start;
while (cur && g_hash_table_add(seen,(gpointer)cur)) cur=g_hash_table_lookup(edges,cur);
if (cur) result.cname_cycle=TRUE;
g_hash_table_unref(seen);
}
}
g_hash_table_unref(edges);
/* INVARIANT: une cible CNAME est une piste, jamais une propagation de scope. */
result.next_piste=fixture->status==RESEARCH_DNS_STATUS_OK && result.records->len>0 && !result.cname_cycle;
*out=result; return TRUE;
}
void research_dns_result_clear(ResearchDnsResult *r) { if (!r) return; g_clear_pointer(&r->records,g_ptr_array_unref); *r=(ResearchDnsResult){0}; }

View file

@ -0,0 +1,19 @@
#include "core/research_adapter_page.h"
#include <string.h>
static gboolean append_bounded(GString *s,char c,gsize max){if(s->len>=max)return FALSE;g_string_append_c(s,c);return TRUE;}
gboolean research_adapter_page_normalize(const ResearchAction *a,const ResearchPageFixture *f,gsize max,ResearchPageResult *out,GError **error){
if(!a||!a->subject||!f||!f->content_type||!f->body||!max||f->body_size>a->max_response_bytes||!g_utf8_validate((const char*)f->body,f->body_size,NULL)){
g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_INVALID_ARGUMENT,"Page absente, trop grande ou non UTF-8.");return FALSE;}
gboolean html=g_ascii_strncasecmp(f->content_type,"text/html",9)==0,text=g_ascii_strncasecmp(f->content_type,"text/plain",10)==0;
if(!html&&!text){g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_NOT_SUPPORTED,"Type de page non inerte refusé.");return FALSE;}
GString *s=g_string_new(NULL);gboolean tag=FALSE,space=FALSE;
for(gsize i=0;i<f->body_size;i++){char c=(char)f->body[i];if(html&&c=='<'){tag=TRUE;space=TRUE;continue;}if(html&&tag){if(c=='>')tag=FALSE;continue;}if(g_ascii_isspace(c)){if(!space&&s->len&&!append_bounded(s,' ',max))goto large;space=TRUE;}else{if(!append_bounded(s,c,max))goto large;space=FALSE;}}
g_strstrip(s->str);
s->len = strlen(s->str);
gboolean has_text = s->len > 0;
char *inert_text = g_string_free(s, FALSE);
*out = (ResearchPageResult){inert_text, html, TRUE, has_text};
return TRUE;
large:g_string_free(s,TRUE);g_set_error_literal(error,G_IO_ERROR,G_IO_ERROR_NO_SPACE,"Texte de page hors limite.");return FALSE;
}
void research_page_result_clear(ResearchPageResult *r){if(!r)return;g_free(r->inert_text);*r=(ResearchPageResult){0};}

View file

@ -0,0 +1,43 @@
#include "core/research_adapter_rdap.h"
#include <string.h>
static gboolean domain_suffix(const char *subject,const char *suffix) {
gsize a=strlen(subject),b=strlen(suffix); if (!b || b>a) return FALSE;
return g_ascii_strcasecmp(subject+a-b,suffix)==0 && (a==b || subject[a-b-1]=='.');
}
static gboolean ip_prefix(const char *subject,const char *prefix,guint *bits) {
char **p=g_strsplit(prefix,"/",2); GInetAddress *a=g_inet_address_new_from_string(subject),*n=g_inet_address_new_from_string(p[0]);
char *end=NULL; guint64 count=p[1]?g_ascii_strtoull(p[1],&end,10):999;
gboolean ok=a&&n&&g_inet_address_get_family(a)==g_inet_address_get_family(n);
guint max=ok&&g_inet_address_get_family(a)==G_SOCKET_FAMILY_IPV4?32:128;
ok=ok&&p[1]&&end&&*end=='\0'&&count<=max;
if(ok){const guint8 *x=g_inet_address_to_bytes(a),*y=g_inet_address_to_bytes(n); guint full=count/8,rem=count%8; ok=memcmp(x,y,full)==0 && (!rem || (x[full]>>(8-rem))==(y[full]>>(8-rem)));}
if (ok)
*bits = (guint)count;
g_clear_object(&a);
g_clear_object(&n);
g_strfreev(p);
return ok;
}
gboolean research_adapter_rdap_select(const ResearchAction *action,
ResearchRdapServiceKind kind,const ResearchRdapBootstrap *b,ResearchRdapResult *out,GError **error){
if (!action || !action->subject || !b || !out || !b->fetched_at ||
!b->expires_at || (b->entry_count > 0 && b->entries == NULL)) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"Bootstrap RDAP invalide.");
return FALSE;
}
const ResearchRdapBootstrapEntry *best=NULL;guint score=0;
for(gsize i=0;i<b->entry_count;i++){const ResearchRdapBootstrapEntry *e=&b->entries[i];guint s=0;gboolean match=FALSE;
if(e->kind!=kind||!e->prefix||!e->base_url)continue;
if(kind==RESEARCH_RDAP_DOMAIN_SERVICE){match=domain_suffix(action->subject,e->prefix);s=(guint)strlen(e->prefix)*8;}else match=ip_prefix(action->subject,e->prefix,&s);
if(match&&(!best||s>score)){best=e;score=s;}}
if (!best) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND,
"Service RDAP bootstrap introuvable.");
return FALSE;
}
*out = (ResearchRdapResult){g_strdup(best->base_url), score,
g_strdup(b->fetched_at), g_strdup(b->expires_at), g_strdup(b->etag), TRUE};
return TRUE;
}
void research_rdap_result_clear(ResearchRdapResult *r){if(!r)return;g_free(r->service_url);g_free(r->cache_fetched_at);g_free(r->cache_expires_at);g_free(r->cache_etag);*r=(ResearchRdapResult){0};}

View file

@ -0,0 +1,34 @@
#include "core/research_adapter_search.h"
ResearchSearchAvailability research_adapter_search_availability(const ResearchSearchConfig *c){
if(!c||!c->endpoint||!*c->endpoint||(c->provider==RESEARCH_SEARCH_BRAVE&&(!c->secret_reference||!*c->secret_reference)))return RESEARCH_SEARCH_UNAVAILABLE_CONFIG;
if(!c->storage_authorized)return RESEARCH_SEARCH_UNAVAILABLE_STORAGE_AUTHORIZATION;
return RESEARCH_SEARCH_AVAILABLE;
}
gboolean research_adapter_search_normalize(const ResearchAction *a,const ResearchSearchConfig *c,const ResearchSearchHit *hits,gsize n,ResearchSearchResult *out,GError **error){
if (!a || !a->subject || !out || (n > 0 && hits == NULL)) {
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT,
"Recherche invalide.");
return FALSE;
}
ResearchSearchAvailability available=research_adapter_search_availability(c);
ResearchSearchResult r={.availability=available,.hits=g_ptr_array_new_with_free_func(g_free)};
if(available!=RESEARCH_SEARCH_AVAILABLE){*out=r;return TRUE;}
for(gsize i=0;i<n;i++){
GUri *uri=hits&&hits[i].url?g_uri_parse(hits[i].url,G_URI_FLAGS_NONE,NULL):NULL;
const char *scheme = uri != NULL ? g_uri_get_scheme(uri) : NULL;
if (!uri || (g_strcmp0(scheme, "http") != 0 &&
g_strcmp0(scheme, "https") != 0) ||
!hits[i].title || !hits[i].snippet) {
if (uri)
g_uri_unref(uri);
research_search_result_clear(&r);
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
"Résultat de recherche invalide.");
return FALSE;
}
/* INVARIANT: l'URL reste une piste textuelle, jamais une nouvelle portée. */
g_ptr_array_add(r.hits,g_strdup_printf("%s\t%s\t%s",hits[i].title,hits[i].url,hits[i].snippet));g_uri_unref(uri);
}
r.next_piste=r.hits->len>0;*out=r;return TRUE;
}
void research_search_result_clear(ResearchSearchResult *r){if(!r)return;g_clear_pointer(&r->hits,g_ptr_array_unref);*r=(ResearchSearchResult){0};}

View file

@ -0,0 +1,189 @@
/******************************************************************************
* @file research_contracts.c
* @brief Validation des contrats de recherche assistée V1.
******************************************************************************/
#include "core/research_contracts.h"
#include <string.h>
static void contract_error(GError **error, const char *message) {
if (error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT, message);
}
static gboolean nonempty(const char *value) {
return value != NULL && value[0] != '\0';
}
static gboolean fingerprint_valid(const char *value) {
if (value == NULL || strlen(value) != 64)
return FALSE;
for (gsize i = 0; i < 64; i++)
if (!g_ascii_isxdigit(value[i]))
return FALSE;
return TRUE;
}
static gboolean timestamp_valid(const char *value) {
GDateTime *parsed = value != NULL
? g_date_time_new_from_iso8601(value, NULL)
: NULL;
if (parsed == NULL)
return FALSE;
g_date_time_unref(parsed);
return TRUE;
}
static gboolean relative_artifact_valid(const char *value) {
if (!nonempty(value) || g_path_is_absolute(value) || strchr(value, '\\'))
return FALSE;
char **parts = g_strsplit(value, "/", -1);
gboolean valid = TRUE;
for (gsize i = 0; valid && parts[i] != NULL; i++)
valid = parts[i][0] != '\0' && g_strcmp0(parts[i], ".") != 0 &&
g_strcmp0(parts[i], "..") != 0;
g_strfreev(parts);
return valid;
}
const char *research_seed_kind_code(ResearchSeedKind kind) {
static const char *const codes[] = {"DOMAIN", "IP", "HTTP_URL", "EMAIL",
"SEARCH_TERM"};
return kind <= RESEARCH_SEED_SEARCH_TERM ? codes[kind] : NULL;
}
const char *research_contact_class_code(ResearchContactClass contact) {
static const char *const codes[] = {"NONE", "THIRD_PARTY", "TARGET"};
return contact <= RESEARCH_CONTACT_TARGET ? codes[contact] : NULL;
}
const char *research_policy_decision_code(ResearchPolicyDecision decision) {
static const char *const codes[] = {
"ALLOW", "DENY_EXCLUDED", "DENY_REVOKED",
"DENY_EXPIRED", "DENY_INVESTIGATION", "DENY_PLAN",
"DENY_ACTION", "DENY_ACTION_CONTENT", "DENY_BUDGET"};
return decision <= RESEARCH_POLICY_DENY_BUDGET ? codes[decision]
: "DENY_ACTION";
}
const char *research_action_decision_code(ResearchActionDecisionCode decision) {
static const char *const codes[] = {"AUTHORIZE", "DEFER", "REFUSE"};
return decision <= RESEARCH_ACTION_REFUSE ? codes[decision] : NULL;
}
static gboolean action_valid(const ResearchAction *action) {
return action != NULL && g_uuid_string_is_valid(action->action_id) &&
nonempty(action->capability_id) && nonempty(action->provider_id) &&
nonempty(action->endpoint) && nonempty(action->subject) &&
research_contact_class_code(action->contact) != NULL &&
nonempty(action->disclosure) && action->max_requests > 0 &&
action->max_response_bytes > 0 &&
action->max_response_bytes <= G_MAXINT64 && action->max_active_ms > 0;
}
gboolean research_plan_validate(const ResearchPlan *plan, GError **error) {
if (plan == NULL || g_strcmp0(plan->contract, RESEARCH_PLAN_CONTRACT) != 0 ||
!g_uuid_string_is_valid(plan->plan_id) ||
!nonempty(plan->idempotency_key) ||
!fingerprint_valid(plan->content_fingerprint) ||
!fingerprint_valid(plan->input_fingerprint) || plan->input_revision == 0 ||
!timestamp_valid(plan->created_at) || plan->seeds == NULL ||
plan->seed_count == 0 || plan->actions == NULL ||
plan->action_count == 0) {
contract_error(error, "Contrat ResearchPlan V1 invalide.");
return FALSE;
}
GHashTable *identities = g_hash_table_new(g_str_hash, g_str_equal);
gboolean valid = TRUE;
for (gsize i = 0; valid && i < plan->seed_count; i++)
valid = research_seed_kind_code(plan->seeds[i].kind) != NULL &&
nonempty(plan->seeds[i].subject);
for (gsize i = 0; valid && i < plan->action_count; i++) {
const ResearchAction *action = &plan->actions[i];
valid = action_valid(action) &&
g_hash_table_add(identities, (gpointer)action->action_id);
}
g_hash_table_unref(identities);
if (!valid)
contract_error(error, "Seed ou action ResearchPlan invalide ou dupliquée.");
return valid;
}
gboolean research_scope_grant_validate(const ScopeGrant *grant,
GError **error) {
if (grant == NULL ||
g_strcmp0(grant->contract, RESEARCH_GRANT_CONTRACT) != 0 ||
!g_uuid_string_is_valid(grant->grant_id) ||
!g_uuid_string_is_valid(grant->investigation_id) ||
!nonempty(grant->idempotency_key) ||
!fingerprint_valid(grant->content_fingerprint) ||
!fingerprint_valid(grant->plan_fingerprint) ||
!timestamp_valid(grant->created_at) ||
!timestamp_valid(grant->expires_at) ||
grant->selected_action_ids == NULL || grant->selected_action_count == 0 ||
grant->max_requests == 0 || grant->max_response_bytes == 0 ||
grant->max_response_bytes > G_MAXINT64 ||
grant->max_active_ms == 0) {
contract_error(error, "Contrat ScopeGrant V1 invalide.");
return FALSE;
}
GHashTable *identities = g_hash_table_new(g_str_hash, g_str_equal);
gboolean valid = TRUE;
for (gsize i = 0; valid && i < grant->selected_action_count; i++)
valid = g_uuid_string_is_valid(grant->selected_action_ids[i]) &&
g_hash_table_add(identities,
(gpointer)grant->selected_action_ids[i]);
g_hash_table_remove_all(identities);
for (gsize i = 0; valid && i < grant->excluded_subject_count; i++)
valid = nonempty(grant->excluded_subjects[i]) &&
g_hash_table_add(identities,
(gpointer)grant->excluded_subjects[i]);
g_hash_table_unref(identities);
if (!valid)
contract_error(error, "Sélection ou exclusion ScopeGrant invalide.");
return valid;
}
gboolean research_campaign_validate(const ResearchCampaign *campaign,
GError **error) {
gboolean valid = campaign != NULL &&
g_strcmp0(campaign->contract, RESEARCH_CAMPAIGN_CONTRACT) == 0 &&
g_uuid_string_is_valid(campaign->campaign_id) &&
g_uuid_string_is_valid(campaign->investigation_id) &&
g_uuid_string_is_valid(campaign->grant_id) &&
nonempty(campaign->idempotency_key) &&
fingerprint_valid(campaign->content_fingerprint) &&
timestamp_valid(campaign->created_at);
if (!valid)
contract_error(error, "Contrat ResearchCampaign V1 invalide.");
return valid;
}
gboolean research_result_validate(const ResearchResult *result,
GError **error) {
gboolean valid = result != NULL &&
g_strcmp0(result->contract, RESEARCH_RESULT_CONTRACT) == 0 &&
g_uuid_string_is_valid(result->result_id) &&
g_uuid_string_is_valid(result->campaign_id) &&
g_uuid_string_is_valid(result->action_id) &&
relative_artifact_valid(result->raw_artifact_relative_path) &&
fingerprint_valid(result->content_sha256) && nonempty(result->status);
if (!valid)
contract_error(error, "Contrat ResearchResult V1 invalide.");
return valid;
}
gboolean research_receipt_validate(const ResearchReceipt *receipt,
GError **error) {
gboolean valid = receipt != NULL &&
g_strcmp0(receipt->contract, RESEARCH_RECEIPT_CONTRACT) == 0 &&
g_uuid_string_is_valid(receipt->receipt_id) &&
g_uuid_string_is_valid(receipt->result_id) &&
g_uuid_string_is_valid(receipt->campaign_id) &&
g_uuid_string_is_valid(receipt->grant_id) &&
g_uuid_string_is_valid(receipt->action_id) &&
timestamp_valid(receipt->decided_at) && nonempty(receipt->decision_code);
if (!valid)
contract_error(error, "Contrat ResearchReceipt V1 invalide.");
return valid;
}

View file

@ -0,0 +1,249 @@
#include "core/research_http_transport_curl.h"
#include <arpa/inet.h>
#include <curl/curl.h>
#include <sys/socket.h>
#include <unistd.h>
typedef struct {
const ResearchTransportRequest *request;
ResearchTransportReply *reply;
GByteArray *body;
guint64 header_bytes;
gboolean too_large;
gboolean allow_loopback;
} CurlContext;
static gboolean socket_address_public(const struct curl_sockaddr *address,
gboolean allow_loopback) {
char text[INET6_ADDRSTRLEN] = {0};
const void *source = NULL;
if (address->family == AF_INET)
source = &((const struct sockaddr_in *)&address->addr)->sin_addr;
else if (address->family == AF_INET6)
source = &((const struct sockaddr_in6 *)&address->addr)->sin6_addr;
if (source == NULL || inet_ntop(address->family, source, text, sizeof text) == NULL)
return FALSE;
GInetAddress *inet = g_inet_address_new_from_string(text);
gboolean loopback = inet != NULL && g_inet_address_get_is_loopback(inet);
g_clear_object(&inet);
return research_policy_address_is_public(text) || (allow_loopback && loopback);
}
static curl_socket_t open_socket(void *data, curlsocktype purpose,
struct curl_sockaddr *address) {
CurlContext *context = data;
if (purpose != CURLSOCKTYPE_IPCXN ||
!socket_address_public(address, context->allow_loopback))
return CURL_SOCKET_BAD;
return socket(address->family, address->socktype, address->protocol);
}
static size_t write_body(char *data, size_t size, size_t count, void *user_data) {
CurlContext *context = user_data;
gsize length = size * count;
if (length > context->request->max_decompressed_bytes - context->body->len) {
context->too_large = TRUE;
return 0;
}
g_byte_array_append(context->body, (const guint8 *)data, length);
return length;
}
static void store_header(CurlContext *context, const char *line, gsize length) {
const char *colon = memchr(line, ':', length);
if (colon == NULL)
return;
gsize name_length = (gsize)(colon - line);
const char *value = colon + 1;
const char *end = line + length;
while (value < end && g_ascii_isspace(*value))
value++;
while (end > value && g_ascii_isspace(end[-1]))
end--;
char *name = g_ascii_strdown(line, name_length);
char *copy = g_strndup(value, (gsize)(end - value));
g_hash_table_replace(context->reply->headers, name, copy);
}
static size_t write_header(char *data, size_t size, size_t count,
void *user_data) {
CurlContext *context = user_data;
gsize length = size * count;
if (length > context->request->max_header_bytes - context->header_bytes) {
context->too_large = TRUE;
return 0;
}
context->header_bytes += length;
store_header(context, data, length);
return length;
}
static int progress_cancel(void *data, curl_off_t down_total,
curl_off_t down_now, curl_off_t up_total,
curl_off_t up_now) {
(void)down_total;
(void)up_total;
(void)up_now;
CurlContext *context = data;
/* CONTRACT: dlnow est le compteur libcurl des octets téléchargés avant
* décompression. Il rend le plafond compressé opposable même sans
* Content-Length (notamment en transfert chunked). */
if (down_now < 0 || (guint64)down_now >
context->request->max_compressed_bytes) {
context->too_large = TRUE;
return 1;
}
return context->request->cancellable != NULL &&
g_cancellable_is_cancelled(context->request->cancellable);
}
static ResearchTransportState response_state(CURLcode code, long status,
const CurlContext *context) {
if (context->request->cancellable != NULL &&
g_cancellable_is_cancelled(context->request->cancellable))
return RESEARCH_TRANSPORT_CANCELLED;
if (context->too_large)
return RESEARCH_TRANSPORT_TOO_LARGE;
if (code == CURLE_FILESIZE_EXCEEDED)
return RESEARCH_TRANSPORT_TOO_LARGE;
if (code == CURLE_OPERATION_TIMEDOUT)
return RESEARCH_TRANSPORT_TIMEOUT;
if (code != CURLE_OK)
return RESEARCH_TRANSPORT_FAILED;
if (status >= 200 && status < 300)
return RESEARCH_TRANSPORT_OK;
if (status >= 300 && status < 400)
return RESEARCH_TRANSPORT_REDIRECT;
if (status == 403)
return RESEARCH_TRANSPORT_FORBIDDEN;
if (status == 429)
return RESEARCH_TRANSPORT_RATE_LIMITED;
return RESEARCH_TRANSPORT_FAILED;
}
static void parse_reply_headers(ResearchTransportReply *reply) {
const char *retry = g_hash_table_lookup(reply->headers, "retry-after");
if (retry != NULL) {
char *end = NULL;
guint64 seconds = g_ascii_strtoull(retry, &end, 10);
if (end != retry && *end == '\0' && seconds <= G_MAXUINT) {
reply->retry_after_present = TRUE;
reply->retry_after_seconds = (guint)seconds;
}
}
const char *location = g_hash_table_lookup(reply->headers, "location");
if (reply->state == RESEARCH_TRANSPORT_REDIRECT && location != NULL)
reply->redirect_location = g_strdup(location);
}
static gboolean execute_internal(const ResearchTransportRequest *request,
const char *resolve_entry,
gboolean allow_loopback,
ResearchTransportReply *out_reply,
GError **error) {
ResearchHttpTarget target = {0};
if (!research_transport_request_validate(request, &target, error))
return FALSE;
CURL *curl = curl_easy_init();
if (curl == NULL) {
research_http_target_clear(&target);
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
"Initialisation libcurl impossible.");
return FALSE;
}
ResearchTransportReply reply = {0};
reply.headers = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
CurlContext context = {request, &reply, g_byte_array_new(), 0, FALSE,
allow_loopback};
struct curl_slist *headers = NULL;
for (gsize i = 0; i < request->header_count; i++) {
char *line = g_strdup_printf("%s: %s", request->headers[i].name,
request->headers[i].value);
headers = curl_slist_append(headers, line);
g_free(line);
}
struct curl_slist *resolve = NULL;
if (resolve_entry != NULL)
resolve = curl_slist_append(resolve, resolve_entry);
curl_easy_setopt(curl, CURLOPT_URL, request->action->endpoint);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "http,https");
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 0L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
curl_easy_setopt(curl, CURLOPT_PROXY, "");
curl_easy_setopt(curl, CURLOPT_NOPROXY, "*");
curl_easy_setopt(curl, CURLOPT_NETRC, CURL_NETRC_IGNORED);
curl_easy_setopt(curl, CURLOPT_HTTPAUTH, CURLAUTH_NONE);
curl_easy_setopt(curl, CURLOPT_UNRESTRICTED_AUTH, 0L);
curl_easy_setopt(curl, CURLOPT_COOKIEFILE, NULL);
curl_easy_setopt(curl, CURLOPT_COOKIEJAR, NULL);
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, (long)request->action->max_active_ms);
curl_easy_setopt(curl, CURLOPT_MAXFILESIZE_LARGE,
(curl_off_t)request->max_compressed_bytes);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_body);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &context);
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, write_header);
curl_easy_setopt(curl, CURLOPT_HEADERDATA, &context);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, progress_cancel);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &context);
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_OPENSOCKETFUNCTION, open_socket);
curl_easy_setopt(curl, CURLOPT_OPENSOCKETDATA, &context);
if (resolve != NULL)
curl_easy_setopt(curl, CURLOPT_RESOLVE, resolve);
CURLcode code = curl_easy_perform(curl);
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
reply.status_code = status >= 0 && status <= G_MAXUINT ? (guint)status : 0;
reply.state = response_state(code, status, &context);
if (reply.state != RESEARCH_TRANSPORT_TOO_LARGE)
reply.body = g_byte_array_free_to_bytes(context.body);
else
g_byte_array_unref(context.body);
parse_reply_headers(&reply);
curl_slist_free_all(headers);
curl_slist_free_all(resolve);
curl_easy_cleanup(curl);
research_http_target_clear(&target);
*out_reply = reply;
return TRUE;
}
gboolean research_http_transport_curl_execute(
const ResearchTransportRequest *request, ResearchTransportReply *out_reply,
GError **error) {
return execute_internal(request, NULL, FALSE, out_reply, error);
}
#ifdef RESEARCH_TRANSPORT_ENABLE_TEST_HOOKS
gboolean research_http_transport_curl_execute_fixture(
const ResearchTransportRequest *request, const char *authority,
const char *loopback_address, ResearchTransportReply *out_reply,
GError **error) {
ResearchHttpTarget target = {0};
if (authority == NULL || loopback_address == NULL ||
!research_transport_request_validate(request, &target, error))
return FALSE;
GInetAddress *loopback = g_inet_address_new_from_string(loopback_address);
gboolean valid = loopback != NULL && g_inet_address_get_is_loopback(loopback) &&
g_ascii_strcasecmp(authority, target.host) == 0;
g_clear_object(&loopback);
if (!valid) {
research_http_target_clear(&target);
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_PERMISSION_DENIED,
"Autorité loopback de fixture interdite.");
return FALSE;
}
char *resolve = g_strdup_printf("%s:%u:%s", authority, target.port,
loopback_address);
research_http_target_clear(&target);
gboolean ok = execute_internal(request, resolve, TRUE, out_reply, error);
g_free(resolve);
return ok;
}
#endif

226
src/core/research_policy.c Normal file
View file

@ -0,0 +1,226 @@
/******************************************************************************
* @file research_policy.c
* @brief Validation sans I/O des destinations de recherche assistée.
******************************************************************************/
#include "core/research_policy.h"
#include <netinet/in.h>
#include <string.h>
static void policy_error(GError **error, const char *message) {
if (error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT, message);
}
static gboolean clean_text(const char *value) {
if (value == NULL || value[0] == '\0' || strchr(value, '\\') != NULL)
return FALSE;
for (const unsigned char *p = (const unsigned char *)value; *p != '\0'; p++)
if (*p < 0x20 || *p == 0x7f)
return FALSE;
return TRUE;
}
static gboolean exact_member(const char *value, const char *const *values,
gsize count) {
if (value == NULL || values == NULL)
return FALSE;
for (gsize i = 0; i < count; i++)
if (g_strcmp0(value, values[i]) == 0)
return TRUE;
return FALSE;
}
static gboolean port_allowed(guint16 port, const ResearchNetworkProfile *profile) {
if (profile == NULL || profile->allowed_ports == NULL)
return FALSE;
for (gsize i = 0; i < profile->allowed_port_count; i++)
if (port == profile->allowed_ports[i])
return TRUE;
return FALSE;
}
static gboolean parse_http_target(const char *text,
const ResearchNetworkProfile *profile,
gboolean require_endpoint,
ResearchHttpTarget *out, GError **error) {
if (!clean_text(text) || strchr(text, '@') != NULL || profile == NULL ||
out == NULL) {
policy_error(error, "Destination HTTP(S) absente ou ambiguë.");
return FALSE;
}
GError *uri_error = NULL;
GUri *uri = g_uri_parse(text, G_URI_FLAGS_PARSE_RELAXED, &uri_error);
if (uri == NULL) {
g_clear_error(&uri_error);
policy_error(error, "Destination HTTP(S) invalide.");
return FALSE;
}
const char *scheme = g_uri_get_scheme(uri);
const char *host = g_uri_get_host(uri);
const char *userinfo = g_uri_get_userinfo(uri);
gint parsed_port = g_uri_get_port(uri);
guint16 port = parsed_port >= 0 ? (guint16)parsed_port
: (g_strcmp0(scheme, "https") == 0 ? 443 : 80);
gboolean valid = (g_strcmp0(scheme, "http") == 0 ||
g_strcmp0(scheme, "https") == 0) &&
clean_text(host) && userinfo == NULL && parsed_port <= 65535 &&
port_allowed(port, profile) &&
(!require_endpoint ||
exact_member(text, profile->allowed_endpoints,
profile->allowed_endpoint_count));
if (valid) {
GInetAddress *literal = g_inet_address_new_from_string(host);
if (literal != NULL) {
valid = research_policy_address_is_public(host);
g_object_unref(literal);
}
}
if (!valid) {
g_uri_unref(uri);
policy_error(error, "Destination hors profil réseau ou non publique.");
return FALSE;
}
const char *path = g_uri_get_path(uri);
const char *query = g_uri_get_query(uri);
out->scheme = g_ascii_strdown(scheme, -1);
out->host = g_ascii_strdown(host, -1);
out->port = port;
out->path_and_query = query != NULL
? g_strdup_printf("%s?%s", path[0] ? path : "/", query)
: g_strdup(path[0] ? path : "/");
g_uri_unref(uri);
return TRUE;
}
static gboolean subject_matches_host(const char *subject,
const ResearchHttpTarget *target) {
if (!clean_text(subject))
return FALSE;
if (g_str_has_prefix(subject, "http://") ||
g_str_has_prefix(subject, "https://")) {
GUri *uri = g_uri_parse(subject, G_URI_FLAGS_NONE, NULL);
const char *host = uri != NULL ? g_uri_get_host(uri) : NULL;
gboolean match = host != NULL && g_uri_get_userinfo(uri) == NULL &&
g_ascii_strcasecmp(host, target->host) == 0;
if (uri != NULL)
g_uri_unref(uri);
return match;
}
return strchr(subject, '/') == NULL && strchr(subject, '@') == NULL &&
g_ascii_strcasecmp(subject, target->host) == 0;
}
gboolean research_policy_validate_http_action(
const ResearchAction *action, const ScopeGrant *grant,
const ResearchNetworkProfile *profile, ResearchHttpTarget *out_target,
GError **error) {
if (action == NULL || grant == NULL || out_target == NULL ||
!research_scope_grant_validate(grant, error))
return FALSE;
gboolean selected = FALSE;
for (gsize i = 0; i < grant->selected_action_count; i++)
selected |= g_strcmp0(action->action_id, grant->selected_action_ids[i]) == 0;
for (gsize i = 0; i < grant->excluded_subject_count; i++)
if (g_strcmp0(action->subject, grant->excluded_subjects[i]) == 0) {
policy_error(error, "Sujet explicitement exclu du ScopeGrant.");
return FALSE;
}
if (!selected || action->max_requests == 0 ||
action->max_requests > grant->max_requests ||
action->max_response_bytes == 0 ||
action->max_response_bytes > grant->max_response_bytes ||
action->max_active_ms == 0 ||
action->max_active_ms > grant->max_active_ms) {
policy_error(error, "Action absente du grant ou budget hors grant.");
return FALSE;
}
ResearchHttpTarget parsed = {0};
if (!parse_http_target(action->endpoint, profile, TRUE, &parsed, error))
return FALSE;
if (!subject_matches_host(action->subject, &parsed)) {
research_http_target_clear(&parsed);
policy_error(error, "Le sujet ne correspond pas exactement à la destination.");
return FALSE;
}
*out_target = parsed;
return TRUE;
}
gboolean research_policy_address_is_public(const char *address) {
GInetAddress *inet = g_inet_address_new_from_string(address);
if (inet == NULL)
return FALSE;
const guint8 *bytes = g_inet_address_to_bytes(inet);
gboolean public = TRUE;
if (g_inet_address_get_family(inet) == G_SOCKET_FAMILY_IPV4) {
/* CONTRACT: seules les adresses global-unicast sont admissibles. Les
* préfixes de documentation et d'essai ne deviennent jamais des cibles
* réseau parce qu'ils sont syntaxiquement valides. */
public = !(bytes[0] == 0 || bytes[0] == 10 || bytes[0] == 127 ||
(bytes[0] == 169 && bytes[1] == 254) ||
(bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) ||
(bytes[0] == 192 && bytes[1] == 0 && bytes[2] == 0) ||
(bytes[0] == 192 && bytes[1] == 0 && bytes[2] == 2) ||
(bytes[0] == 192 && bytes[1] == 88 && bytes[2] == 99) ||
(bytes[0] == 192 && bytes[1] == 168) ||
(bytes[0] == 100 && bytes[1] >= 64 && bytes[1] <= 127) ||
(bytes[0] == 198 && (bytes[1] == 18 || bytes[1] == 19)) ||
(bytes[0] == 198 && bytes[1] == 51 && bytes[2] == 100) ||
(bytes[0] == 203 && bytes[1] == 0 && bytes[2] == 113) ||
bytes[0] >= 224);
} else {
const struct in6_addr *ipv6 = (const struct in6_addr *)bytes;
if (IN6_IS_ADDR_V4MAPPED(ipv6)) {
char *mapped = g_strdup_printf("%u.%u.%u.%u", bytes[12], bytes[13],
bytes[14], bytes[15]);
public = research_policy_address_is_public(mapped);
g_free(mapped);
} else {
public = !(IN6_IS_ADDR_UNSPECIFIED(ipv6) ||
IN6_IS_ADDR_LOOPBACK(ipv6) ||
(bytes[0] == 0x00 && bytes[1] == 0x64 &&
bytes[2] == 0xff && bytes[3] == 0x9b &&
bytes[4] == 0x00 && bytes[5] == 0x01) ||
(bytes[0] == 0x01 && bytes[1] == 0x00 &&
bytes[2] == 0x00 && bytes[3] == 0x00 &&
bytes[4] == 0x00 && bytes[5] == 0x00 &&
bytes[6] == 0x00 && bytes[7] == 0x00) ||
(bytes[0] == 0x20 && bytes[1] == 0x01 &&
bytes[2] == 0x00 && bytes[3] == 0x02 &&
bytes[4] == 0x00 && bytes[5] == 0x00) ||
(bytes[0] == 0x20 && bytes[1] == 0x01 &&
bytes[2] == 0x0d && bytes[3] == 0xb8) ||
(bytes[0] == 0x20 && bytes[1] == 0x01 &&
bytes[2] == 0x00 &&
((bytes[3] & 0xf0) == 0x10 ||
(bytes[3] & 0xf0) == 0x20)) ||
(bytes[0] == 0x20 && bytes[1] == 0x02) ||
(bytes[0] == 0x3f && bytes[1] == 0xff &&
(bytes[2] & 0xf0) == 0x00) ||
(bytes[0] == 0x5f && bytes[1] == 0x00) ||
(bytes[0] == 0xfc || bytes[0] == 0xfd) ||
(bytes[0] == 0xfe && (bytes[1] & 0x80) == 0x80) ||
bytes[0] == 0xff);
}
}
g_object_unref(inet);
return public;
}
ResearchRedirectDecision research_policy_check_redirect(
const char *location, const ResearchNetworkProfile *profile,
ResearchHttpTarget *out_target, GError **error) {
if (!parse_http_target(location, profile, FALSE, out_target, error))
return RESEARCH_REDIRECT_INVALID;
return RESEARCH_REDIRECT_REQUIRES_DECISION;
}
void research_http_target_clear(ResearchHttpTarget *target) {
if (target == NULL)
return;
g_clear_pointer(&target->scheme, g_free);
g_clear_pointer(&target->host, g_free);
g_clear_pointer(&target->path_and_query, g_free);
target->port = 0;
}

View file

@ -0,0 +1,211 @@
/******************************************************************************
* @file research_transport.c
* @brief Validation et classification d'un transport de recherche borné.
******************************************************************************/
#include "core/research_transport.h"
#include <errno.h>
#include <stdlib.h>
#include <string.h>
static void transport_error(GError **error, GIOErrorEnum code,
const char *message) {
if (error != NULL && *error == NULL)
g_set_error_literal(error, G_IO_ERROR, code, message);
}
static gboolean header_text_valid(const char *value) {
if (value == NULL)
return FALSE;
for (const unsigned char *p = (const unsigned char *)value; *p != '\0'; p++)
if (*p < 0x20 || *p == 0x7f)
return FALSE;
return TRUE;
}
static gboolean request_header_allowed(const ResearchHeader *header) {
static const char *const allowed[] = {
"accept", "accept-language", "user-agent", "if-none-match",
"if-modified-since"};
if (header == NULL || !header_text_valid(header->name) ||
!header_text_valid(header->value))
return FALSE;
for (gsize i = 0; i < G_N_ELEMENTS(allowed); i++)
if (g_ascii_strcasecmp(header->name, allowed[i]) == 0)
return TRUE;
return FALSE;
}
gboolean research_transport_request_validate(
const ResearchTransportRequest *request, ResearchHttpTarget *out_target,
GError **error) {
if (request == NULL || request->action == NULL || out_target == NULL ||
g_strcmp0(request->method, "GET") != 0 ||
request->max_compressed_bytes == 0 ||
request->max_decompressed_bytes == 0 ||
request->max_header_bytes == 0 ||
(request->header_count > 0 && request->headers == NULL) ||
request->max_compressed_bytes > request->action->max_response_bytes ||
request->max_decompressed_bytes > request->action->max_response_bytes) {
transport_error(error, G_IO_ERROR_INVALID_ARGUMENT,
"Requête de transport non bornée ou invalide.");
return FALSE;
}
guint64 header_bytes = 0;
for (gsize i = 0; i < request->header_count; i++) {
if (!request_header_allowed(&request->headers[i])) {
transport_error(error, G_IO_ERROR_INVALID_ARGUMENT,
"En-tête de requête interdit.");
return FALSE;
}
header_bytes += strlen(request->headers[i].name) +
strlen(request->headers[i].value) + 4;
}
if (header_bytes > request->max_header_bytes) {
transport_error(error, G_IO_ERROR_NO_SPACE, "En-têtes trop volumineux.");
return FALSE;
}
return research_policy_validate_http_action(
request->action, request->grant, request->profile, out_target, error);
}
gboolean research_transport_execute(const ResearchTransportRequest *request,
ResearchTransportReply *out_reply,
GError **error) {
ResearchHttpTarget target = {0};
if (out_reply == NULL ||
!research_transport_request_validate(request, &target, error))
return FALSE;
research_http_target_clear(&target);
*out_reply = (ResearchTransportReply){
.state = RESEARCH_TRANSPORT_UNAVAILABLE,
.diagnostic = g_strdup("Transport réseau non configuré.")};
return TRUE;
}
#ifdef RESEARCH_TRANSPORT_ENABLE_TEST_HOOKS
static guint retry_after_parse(const char *value, gboolean *present) {
if (value == NULL) {
*present = FALSE;
return 0;
}
errno = 0;
char *end = NULL;
guint64 seconds = g_ascii_strtoull(value, &end, 10);
if (errno != 0 || end == value || *end != '\0' || seconds > G_MAXUINT) {
*present = FALSE;
return 0;
}
*present = TRUE;
return (guint)seconds;
}
static ResearchTransportState status_state(guint status) {
if (status >= 200 && status < 300)
return RESEARCH_TRANSPORT_OK;
if (status >= 300 && status < 400)
return RESEARCH_TRANSPORT_REDIRECT;
if (status == 403)
return RESEARCH_TRANSPORT_FORBIDDEN;
if (status == 429)
return RESEARCH_TRANSPORT_RATE_LIMITED;
return RESEARCH_TRANSPORT_FAILED;
}
static gboolean fixture_loopback_allowed(const char *host,
const char *const *authorities,
gsize count) {
for (gsize i = 0; i < count; i++)
if (g_ascii_strcasecmp(host, authorities[i]) == 0)
return TRUE;
return FALSE;
}
gboolean research_transport_execute_fixture(
const ResearchTransportRequest *request,
const ResearchTransportFixture *fixture,
const char *const *loopback_authorities, gsize authority_count,
ResearchTransportReply *out_reply, GError **error) {
ResearchHttpTarget target = {0};
if (fixture == NULL || out_reply == NULL || fixture->peer_address == NULL ||
(fixture->header_count > 0 && fixture->headers == NULL) ||
(fixture->body_size > 0 && fixture->body == NULL) ||
!research_transport_request_validate(request, &target, error))
return FALSE;
GInetAddress *peer = g_inet_address_new_from_string(fixture->peer_address);
gboolean peer_ok = peer != NULL &&
(research_policy_address_is_public(fixture->peer_address) ||
(g_inet_address_get_is_loopback(peer) &&
fixture_loopback_allowed(target.host, loopback_authorities,
authority_count)));
g_clear_object(&peer);
if (!peer_ok) {
research_http_target_clear(&target);
transport_error(error, G_IO_ERROR_PERMISSION_DENIED,
"Adresse de connexion interdite avant contact.");
return FALSE;
}
ResearchTransportReply reply = {0};
reply.status_code = fixture->status_code;
reply.state = fixture->unavailable ? RESEARCH_TRANSPORT_UNAVAILABLE
: fixture->timed_out ? RESEARCH_TRANSPORT_TIMEOUT
: status_state(fixture->status_code);
if (request->cancellable != NULL &&
g_cancellable_is_cancelled(request->cancellable))
reply.state = RESEARCH_TRANSPORT_CANCELLED;
guint64 header_bytes = 0;
reply.headers = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
for (gsize i = 0; i < fixture->header_count; i++) {
const ResearchHeader *header = &fixture->headers[i];
if (!header_text_valid(header->name) || !header_text_valid(header->value)) {
reply.state = RESEARCH_TRANSPORT_INVALID_RESPONSE;
break;
}
header_bytes += strlen(header->name) + strlen(header->value) + 4;
char *name = g_ascii_strdown(header->name, -1);
g_hash_table_replace(reply.headers, name, g_strdup(header->value));
}
if (header_bytes > request->max_header_bytes ||
fixture->body_size > request->max_compressed_bytes ||
fixture->decompressed_size > request->max_decompressed_bytes)
reply.state = RESEARCH_TRANSPORT_TOO_LARGE;
const char *retry = g_hash_table_lookup(reply.headers, "retry-after");
reply.retry_after_seconds = retry_after_parse(retry, &reply.retry_after_present);
reply.body_is_compressed = fixture->compressed;
if (reply.state != RESEARCH_TRANSPORT_TOO_LARGE)
reply.body = g_bytes_new(fixture->body, fixture->body_size);
if (reply.state == RESEARCH_TRANSPORT_REDIRECT) {
ResearchHttpTarget redirected = {0};
if (fixture->redirect_location == NULL ||
research_policy_check_redirect(fixture->redirect_location,
request->profile, &redirected,
NULL) !=
RESEARCH_REDIRECT_REQUIRES_DECISION)
reply.state = RESEARCH_TRANSPORT_INVALID_RESPONSE;
else
reply.redirect_location = g_strdup(fixture->redirect_location);
research_http_target_clear(&redirected);
}
research_http_target_clear(&target);
*out_reply = reply;
return TRUE;
}
#endif
void research_transport_reply_clear(ResearchTransportReply *reply) {
if (reply == NULL)
return;
g_clear_pointer(&reply->body, g_bytes_unref);
g_clear_pointer(&reply->headers, g_hash_table_unref);
g_clear_pointer(&reply->redirect_location, g_free);
g_clear_pointer(&reply->diagnostic, g_free);
*reply = (ResearchTransportReply){0};
}
const char *research_transport_state_code(ResearchTransportState state) {
static const char *const codes[] = {
"OK", "UNAVAILABLE", "CANCELLED", "TIMEOUT", "RATE_LIMITED",
"FORBIDDEN", "REDIRECT_REQUIRES_DECISION", "TOO_LARGE",
"INVALID_RESPONSE", "FAILED"};
return state <= RESEARCH_TRANSPORT_FAILED ? codes[state] : "FAILED";
}

View file

@ -1,27 +0,0 @@
/******************************************************************************
* @file main.c
* @brief Point d'entrée de Labfy Investigation.
******************************************************************************/
#include "core/application.h"
#include <stdlib.h>
int main(int argc, char **argv)
{
Application *application = NULL;
int exit_status = EXIT_FAILURE;
application = application_new();
if (application == NULL)
{
return EXIT_FAILURE;
}
exit_status = application_run(application, argc, argv);
application_free(application);
return exit_status;
}

File diff suppressed because it is too large Load diff

View file

@ -1,864 +0,0 @@
/******************************************************************************
* @file create_investigation_dialog.c
* @brief Dialogue GTK de création d'une nouvelle enquête.
******************************************************************************/
#include "views/create_investigation_dialog.h"
#include <gio/gio.h>
#include <glib.h>
/**
* @brief Largeur initiale du dialogue.
*/
#define CREATE_INVESTIGATION_DIALOG_WIDTH 620
/**
* @brief Marge extérieure du dialogue.
*/
#define CREATE_INVESTIGATION_DIALOG_MARGIN 16
/**
* @brief Espacement entre les composants principaux.
*/
#define CREATE_INVESTIGATION_DIALOG_SPACING 12
/**
* @brief Contexte conservé pendant la durée de vie du dialogue.
*/
typedef struct
{
GtkWindow *window;
GtkWidget *parent_directory_entry;
GtkWidget *investigation_name_entry;
GtkWidget *create_button;
char *parent_directory;
CreateInvestigationDialogCallback callback;
gpointer user_data;
gboolean completed;
} CreateInvestigationDialogContext;
/**
* @brief Libère le contexte associé au dialogue.
*
* Les widgets GTK sont possédés par la fenêtre et ne sont donc pas
* libérés directement ici.
*
* @param context Contexte à libérer.
*/
static void create_investigation_dialog_context_free(
CreateInvestigationDialogContext *context
)
{
if (context == NULL)
{
return;
}
g_free(context->parent_directory);
g_free(context);
}
/**
* @brief Valide et normalise un nom d'enquête.
*
* La fonction :
*
* - vérifie que la chaîne est un UTF-8 valide ;
* - refuse les noms constitués uniquement d'espaces ;
* - reconnaît les espaces Unicode ;
* - refuse les séparateurs '/' et '\' ;
* - retire les espaces placés au début et à la fin.
*
* Si normalized_name n'est pas NULL, la fonction y place une nouvelle
* chaîne allouée que l'appelant devra libérer avec g_free().
*
* @param investigation_name Nom à vérifier.
* @param normalized_name Adresse recevant le nom normalisé, ou NULL.
*
* @return TRUE si le nom est valide, sinon FALSE.
*/
static gboolean create_investigation_dialog_normalize_name(
const char *investigation_name,
char **normalized_name
)
{
const char *cursor = NULL;
const char *first_character = NULL;
const char *end_after_last_character = NULL;
if (normalized_name != NULL)
{
*normalized_name = NULL;
}
if (investigation_name == NULL ||
!g_utf8_validate(
investigation_name,
-1,
NULL
))
{
return FALSE;
}
cursor = investigation_name;
while (*cursor != '\0')
{
gunichar character = 0;
const char *next_character = NULL;
character = g_utf8_get_char(
cursor
);
next_character = g_utf8_next_char(
cursor
);
if (character == '/' ||
character == '\\')
{
return FALSE;
}
if (!g_unichar_isspace(character))
{
if (first_character == NULL)
{
first_character = cursor;
}
end_after_last_character = next_character;
}
cursor = next_character;
}
/*
* Aucun caractère visible n'a été trouvé.
* Le nom était vide ou uniquement composé d'espaces.
*/
if (first_character == NULL ||
end_after_last_character == NULL)
{
return FALSE;
}
if (normalized_name != NULL)
{
*normalized_name = g_strndup(
first_character,
(gsize) (
end_after_last_character -
first_character
)
);
if (*normalized_name == NULL)
{
return FALSE;
}
}
return TRUE;
}
/**
* @brief Met à jour l'état du bouton de création.
*
* Le bouton est actif uniquement lorsqu'un dossier parent valide a été
* sélectionné et que le nom saisi est acceptable.
*
* @param context Contexte du dialogue.
*/
static void create_investigation_dialog_update_create_button(
CreateInvestigationDialogContext *context
)
{
const char *investigation_name = NULL;
gboolean has_valid_parent_directory = FALSE;
gboolean has_valid_name = FALSE;
if (context == NULL ||
context->create_button == NULL ||
context->investigation_name_entry == NULL)
{
return;
}
has_valid_parent_directory =
context->parent_directory != NULL &&
context->parent_directory[0] != '\0' &&
g_file_test(
context->parent_directory,
G_FILE_TEST_IS_DIR
);
investigation_name = gtk_editable_get_text(
GTK_EDITABLE(
context->investigation_name_entry
)
);
has_valid_name =
create_investigation_dialog_normalize_name(
investigation_name,
NULL
);
gtk_widget_set_sensitive(
context->create_button,
has_valid_parent_directory && has_valid_name
);
}
/**
* @brief Termine le dialogue et appelle son callback une seule fois.
*
* @param context Contexte du dialogue.
* @param parent_directory Dossier parent ou NULL en cas d'annulation.
* @param investigation_name Nom nettoyé ou NULL en cas d'annulation.
*/
static void create_investigation_dialog_complete(
CreateInvestigationDialogContext *context,
const char *parent_directory,
const char *investigation_name
)
{
if (context == NULL ||
context->completed)
{
return;
}
context->completed = TRUE;
if (context->callback != NULL)
{
context->callback(
parent_directory,
investigation_name,
context->user_data
);
}
if (context->window != NULL)
{
gtk_window_destroy(
context->window
);
}
}
/**
* @brief Traite les modifications du nom de l'enquête.
*
* @param editable Champ ayant été modifié.
* @param user_data Contexte du dialogue.
*/
static void create_investigation_dialog_on_name_changed(
GtkEditable *editable,
gpointer user_data
)
{
CreateInvestigationDialogContext *context = user_data;
(void) editable;
create_investigation_dialog_update_create_button(
context
);
}
/**
* @brief Traite le résultat du sélecteur de dossier parent.
*
* La référence passée dans user_data maintient la fenêtre en vie pendant
* toute l'opération asynchrone.
*
* @param source_object GtkFileDialog ayant lancé l'opération.
* @param result Résultat asynchrone.
* @param user_data Référence vers la fenêtre du dialogue.
*/
static void create_investigation_dialog_on_parent_selected(
GObject *source_object,
GAsyncResult *result,
gpointer user_data
)
{
GtkFileDialog *file_dialog = NULL;
GtkWindow *dialog_window = NULL;
CreateInvestigationDialogContext *context = NULL;
GFile *selected_folder = NULL;
GError *error = NULL;
char *selected_path = NULL;
file_dialog = GTK_FILE_DIALOG(
source_object
);
dialog_window = GTK_WINDOW(
user_data
);
context = g_object_get_data(
G_OBJECT(dialog_window),
"create-investigation-dialog-context"
);
selected_folder = gtk_file_dialog_select_folder_finish(
file_dialog,
result,
&error
);
/*
* Le dialogue de création a pu être fermé pendant que le sélecteur
* asynchrone était encore actif.
*/
if (context == NULL ||
context->completed)
{
g_clear_error(&error);
if (selected_folder != NULL)
{
g_object_unref(selected_folder);
}
g_object_unref(dialog_window);
return;
}
if (selected_folder == NULL)
{
if (error != NULL &&
!g_error_matches(
error,
GTK_DIALOG_ERROR,
GTK_DIALOG_ERROR_DISMISSED
))
{
g_warning(
"Impossible de sélectionner le dossier parent : %s",
error->message
);
}
g_clear_error(&error);
g_object_unref(dialog_window);
return;
}
selected_path = g_file_get_path(
selected_folder
);
if (selected_path == NULL)
{
g_warning(
"Le dossier sélectionné ne possède pas de chemin local."
);
g_object_unref(selected_folder);
g_object_unref(dialog_window);
return;
}
if (!g_file_test(
selected_path,
G_FILE_TEST_IS_DIR
))
{
g_warning(
"Le chemin sélectionné n'est pas un dossier existant : '%s'.",
selected_path
);
g_free(selected_path);
g_object_unref(selected_folder);
g_object_unref(dialog_window);
return;
}
g_free(context->parent_directory);
context->parent_directory = selected_path;
selected_path = NULL;
gtk_editable_set_text(
GTK_EDITABLE(
context->parent_directory_entry
),
context->parent_directory
);
create_investigation_dialog_update_create_button(
context
);
g_object_unref(selected_folder);
g_object_unref(dialog_window);
}
/**
* @brief Ouvre le sélecteur du dossier parent.
*
* @param button Bouton ayant reçu le clic.
* @param user_data Contexte du dialogue.
*/
static void create_investigation_dialog_on_browse_clicked(
GtkButton *button,
gpointer user_data
)
{
CreateInvestigationDialogContext *context = user_data;
GtkFileDialog *file_dialog = NULL;
(void) button;
if (context == NULL ||
context->completed ||
context->window == NULL)
{
return;
}
file_dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(
file_dialog,
"Sélectionner le dossier parent"
);
gtk_file_dialog_set_modal(
file_dialog,
TRUE
);
gtk_file_dialog_select_folder(
file_dialog,
context->window,
NULL,
create_investigation_dialog_on_parent_selected,
g_object_ref(context->window)
);
g_object_unref(file_dialog);
}
/**
* @brief Annule la création.
*
* @param button Bouton ayant reçu le clic.
* @param user_data Contexte du dialogue.
*/
static void create_investigation_dialog_on_cancel_clicked(
GtkButton *button,
gpointer user_data
)
{
CreateInvestigationDialogContext *context = user_data;
(void) button;
create_investigation_dialog_complete(
context,
NULL,
NULL
);
}
/**
* @brief Valide les informations et termine le dialogue.
*
* @param button Bouton ayant reçu le clic.
* @param user_data Contexte du dialogue.
*/
static void create_investigation_dialog_on_create_clicked(
GtkButton *button,
gpointer user_data
)
{
CreateInvestigationDialogContext *context = user_data;
const char *entry_text = NULL;
char *trimmed_name = NULL;
(void) button;
if (context == NULL ||
context->completed)
{
return;
}
if (context->parent_directory == NULL ||
!g_file_test(
context->parent_directory,
G_FILE_TEST_IS_DIR
))
{
create_investigation_dialog_update_create_button(
context
);
return;
}
entry_text = gtk_editable_get_text(
GTK_EDITABLE(
context->investigation_name_entry
)
);
if (!create_investigation_dialog_normalize_name(
entry_text,
&trimmed_name
))
{
create_investigation_dialog_update_create_button(
context
);
return;
}
create_investigation_dialog_complete(
context,
context->parent_directory,
trimmed_name
);
g_free(trimmed_name);
}
/**
* @brief Intercepte la fermeture de la fenêtre.
*
* Une fermeture avec le bouton système est traitée comme une annulation.
*
* @param window Fenêtre demandant sa fermeture.
* @param user_data Contexte du dialogue.
*
* @return TRUE car la destruction est gérée par le module.
*/
static gboolean create_investigation_dialog_on_close_request(
GtkWindow *window,
gpointer user_data
)
{
CreateInvestigationDialogContext *context = user_data;
(void) window;
create_investigation_dialog_complete(
context,
NULL,
NULL
);
return TRUE;
}
void create_investigation_dialog_present(
GtkWindow *parent_window,
CreateInvestigationDialogCallback callback,
gpointer user_data
)
{
CreateInvestigationDialogContext *context = NULL;
GtkWidget *main_box = NULL;
GtkWidget *parent_label = NULL;
GtkWidget *parent_row = NULL;
GtkWidget *browse_button = NULL;
GtkWidget *name_label = NULL;
GtkWidget *action_box = NULL;
GtkWidget *cancel_button = NULL;
context = g_new0(
CreateInvestigationDialogContext,
1
);
context->callback = callback;
context->user_data = user_data;
context->window = GTK_WINDOW(
gtk_window_new()
);
gtk_window_set_title(
context->window,
"Nouvelle enquête"
);
gtk_window_set_default_size(
context->window,
CREATE_INVESTIGATION_DIALOG_WIDTH,
-1
);
gtk_window_set_modal(
context->window,
TRUE
);
gtk_window_set_resizable(
context->window,
FALSE
);
if (parent_window != NULL)
{
gtk_window_set_transient_for(
context->window,
parent_window
);
gtk_window_set_destroy_with_parent(
context->window,
TRUE
);
}
/*
* Le contexte est attaché à la fenêtre.
*
* Il sera libéré lorsque la dernière référence vers la fenêtre
* disparaîtra, y compris si un sélecteur asynchrone est encore actif.
*/
g_object_set_data_full(
G_OBJECT(context->window),
"create-investigation-dialog-context",
context,
(GDestroyNotify)
create_investigation_dialog_context_free
);
main_box = gtk_box_new(
GTK_ORIENTATION_VERTICAL,
CREATE_INVESTIGATION_DIALOG_SPACING
);
gtk_widget_set_margin_start(
main_box,
CREATE_INVESTIGATION_DIALOG_MARGIN
);
gtk_widget_set_margin_end(
main_box,
CREATE_INVESTIGATION_DIALOG_MARGIN
);
gtk_widget_set_margin_top(
main_box,
CREATE_INVESTIGATION_DIALOG_MARGIN
);
gtk_widget_set_margin_bottom(
main_box,
CREATE_INVESTIGATION_DIALOG_MARGIN
);
parent_label = gtk_label_new(
"Dossier parent :"
);
gtk_widget_set_halign(
parent_label,
GTK_ALIGN_START
);
parent_row = gtk_box_new(
GTK_ORIENTATION_HORIZONTAL,
8
);
context->parent_directory_entry = gtk_entry_new();
gtk_editable_set_editable(
GTK_EDITABLE(
context->parent_directory_entry
),
FALSE
);
gtk_widget_set_hexpand(
context->parent_directory_entry,
TRUE
);
gtk_entry_set_placeholder_text(
GTK_ENTRY(
context->parent_directory_entry
),
"Aucun dossier sélectionné"
);
browse_button = gtk_button_new_with_label(
"Parcourir"
);
gtk_box_append(
GTK_BOX(parent_row),
context->parent_directory_entry
);
gtk_box_append(
GTK_BOX(parent_row),
browse_button
);
name_label = gtk_label_new(
"Nom de l'enquête :"
);
gtk_widget_set_halign(
name_label,
GTK_ALIGN_START
);
context->investigation_name_entry = gtk_entry_new();
gtk_entry_set_placeholder_text(
GTK_ENTRY(
context->investigation_name_entry
),
"Exemple : Arnaque_Billets"
);
action_box = gtk_box_new(
GTK_ORIENTATION_HORIZONTAL,
8
);
gtk_widget_set_halign(
action_box,
GTK_ALIGN_END
);
cancel_button = gtk_button_new_with_label(
"Annuler"
);
context->create_button = gtk_button_new_with_label(
"Créer"
);
gtk_widget_add_css_class(
context->create_button,
"suggested-action"
);
gtk_widget_set_sensitive(
context->create_button,
FALSE
);
gtk_box_append(
GTK_BOX(action_box),
cancel_button
);
gtk_box_append(
GTK_BOX(action_box),
context->create_button
);
gtk_box_append(
GTK_BOX(main_box),
parent_label
);
gtk_box_append(
GTK_BOX(main_box),
parent_row
);
gtk_box_append(
GTK_BOX(main_box),
name_label
);
gtk_box_append(
GTK_BOX(main_box),
context->investigation_name_entry
);
gtk_box_append(
GTK_BOX(main_box),
action_box
);
gtk_window_set_child(
context->window,
main_box
);
g_signal_connect(
context->window,
"close-request",
G_CALLBACK(
create_investigation_dialog_on_close_request
),
context
);
g_signal_connect(
browse_button,
"clicked",
G_CALLBACK(
create_investigation_dialog_on_browse_clicked
),
context
);
g_signal_connect(
context->investigation_name_entry,
"changed",
G_CALLBACK(
create_investigation_dialog_on_name_changed
),
context
);
g_signal_connect(
cancel_button,
"clicked",
G_CALLBACK(
create_investigation_dialog_on_cancel_clicked
),
context
);
g_signal_connect(
context->create_button,
"clicked",
G_CALLBACK(
create_investigation_dialog_on_create_clicked
),
context
);
gtk_window_present(
context->window
);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,294 +0,0 @@
/******************************************************************************
* @file create_social_account_dialog.c
* @brief Dialogue GTK de création d'un compte social observé.
******************************************************************************/
#include "views/create_social_account_dialog.h"
#include "views/dialog_geometry.h"
struct CreateSocialAccountDialogResult
{
char *platform;
char *profile_url;
char *username;
char *platform_identifier;
char *first_observed_at;
char *account_state;
char *notes;
char *evidence_identifier;
};
typedef struct
{
GtkWindow *window;
GtkDropDown *platform;
GtkEntry *url;
GtkEntry *username;
GtkEntry *platform_identifier;
GtkEntry *observed_at;
GtkDropDown *state;
GtkTextView *notes;
GtkDropDown *evidence;
GtkLabel *error;
GPtrArray *evidence_identifiers;
CreateSocialAccountDialogCallback callback;
gpointer user_data;
gboolean completed;
} CreateSocialAccountDialogState;
static const char *const platform_codes[] = {
"tiktok", "instagram", "facebook", "x", "telegram", "other"
};
static const char *const state_codes[] = {
"unknown", "active", "private", "suspended", "deleted"
};
/** @brief Retourne une copie nettoyée, ou NULL pour un texte vide. */
static char *create_social_account_dialog_copy(const char *text)
{
char *copy = text != NULL ? g_strdup(text) : NULL;
if (copy == NULL) return NULL;
g_strstrip(copy);
if (copy[0] == '\0') { g_free(copy); return NULL; }
return copy;
}
/** @brief Extrait le texte du champ de notes. */
static char *create_social_account_dialog_notes(CreateSocialAccountDialogState *state)
{
GtkTextBuffer *buffer = gtk_text_view_get_buffer(state->notes);
GtkTextIter start;
GtkTextIter end;
gtk_text_buffer_get_bounds(buffer, &start, &end);
return gtk_text_buffer_get_text(buffer, &start, &end, FALSE);
}
/** @brief Libère l'état privé attaché à la fenêtre. */
static void create_social_account_dialog_state_free(gpointer user_data)
{
CreateSocialAccountDialogState *state = user_data;
if (state == NULL) return;
g_clear_pointer(&state->evidence_identifiers, g_ptr_array_unref);
g_free(state);
}
/** @brief Signale une annulation au callback une seule fois. */
static void create_social_account_dialog_cancel(CreateSocialAccountDialogState *state)
{
if (state == NULL || state->completed) return;
state->completed = TRUE;
if (state->callback != NULL) state->callback(NULL, state->user_data);
}
/** @brief Traite la fermeture native du dialogue. */
static gboolean create_social_account_dialog_on_close(GtkWindow *window, gpointer user_data)
{
(void) window;
create_social_account_dialog_cancel(user_data);
return FALSE;
}
/** @brief Traite le bouton d'annulation. */
static void create_social_account_dialog_on_cancel(GtkButton *button, gpointer user_data)
{
CreateSocialAccountDialogState *state = user_data;
(void) button;
create_social_account_dialog_cancel(state);
gtk_window_close(state->window);
}
/** @brief Valide le formulaire et transmet un résultat possédé. */
static void create_social_account_dialog_on_create(GtkButton *button, gpointer user_data)
{
CreateSocialAccountDialogState *state = user_data;
CreateSocialAccountDialogResult *result = NULL;
GDateTime *date = NULL;
char *notes = NULL;
guint platform = gtk_drop_down_get_selected(state->platform);
guint account_state = gtk_drop_down_get_selected(state->state);
guint evidence = gtk_drop_down_get_selected(state->evidence);
const char *url = gtk_editable_get_text(GTK_EDITABLE(state->url));
const char *username = gtk_editable_get_text(GTK_EDITABLE(state->username));
const char *observed_at = gtk_editable_get_text(GTK_EDITABLE(state->observed_at));
(void) button;
if (platform >= G_N_ELEMENTS(platform_codes) ||
account_state >= G_N_ELEMENTS(state_codes))
{
gtk_label_set_text(state->error, "Sélectionnez une plateforme et un état.");
gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE);
return;
}
if (url == NULL ||
(!g_str_has_prefix(url, "https://") && !g_str_has_prefix(url, "http://")))
{
gtk_label_set_text(state->error,
"L'URL du profil doit commencer par https:// ou http://.");
gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE);
return;
}
if (username == NULL || username[0] == '\0')
{
gtk_label_set_text(state->error,
"Le pseudonyme affiché est obligatoire (par exemple @compte).");
gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE);
return;
}
date = g_date_time_new_from_iso8601(observed_at, NULL);
if (date == NULL)
{
gtk_label_set_text(state->error,
"La date doit respecter le format UTC AAAA-MM-JJTHH:MM:SSZ.");
gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE);
return;
}
notes = create_social_account_dialog_notes(state);
result = g_new0(CreateSocialAccountDialogResult, 1);
result->platform = g_strdup(platform_codes[platform]);
result->profile_url = create_social_account_dialog_copy(url);
result->username = create_social_account_dialog_copy(username);
result->platform_identifier = create_social_account_dialog_copy(
gtk_editable_get_text(GTK_EDITABLE(state->platform_identifier)));
result->first_observed_at = create_social_account_dialog_copy(observed_at);
result->account_state = g_strdup(state_codes[account_state]);
result->notes = create_social_account_dialog_copy(notes);
if (evidence > 0 && evidence - 1 < state->evidence_identifiers->len)
result->evidence_identifier = g_strdup(g_ptr_array_index(
state->evidence_identifiers, evidence - 1));
state->completed = TRUE;
if (state->callback != NULL) state->callback(result, state->user_data);
else create_social_account_dialog_result_free(result);
g_free(notes);
g_date_time_unref(date);
gtk_window_close(state->window);
}
/** @brief Ajoute une ligne libellée au formulaire. */
static void create_social_account_dialog_add_row(
GtkGrid *grid, int row, const char *label, GtkWidget *field)
{
GtkWidget *caption = gtk_label_new(label);
gtk_label_set_xalign(GTK_LABEL(caption), 0.0f);
gtk_widget_set_halign(caption, GTK_ALIGN_START);
gtk_widget_set_hexpand(field, TRUE);
gtk_grid_attach(grid, caption, 0, row, 1, 1);
gtk_grid_attach(grid, field, 1, row, 1, 1);
}
gboolean create_social_account_dialog_present(
GtkWindow *parent, const GPtrArray *evidence_records,
CreateSocialAccountDialogCallback callback, gpointer user_data)
{
static const char *const platform_labels[] = {
"TikTok", "Instagram", "Facebook", "X", "Telegram", "Autre", NULL
};
static const char *const state_labels[] = {
"Inconnu", "Actif", "Privé", "Suspendu", "Supprimé", NULL
};
CreateSocialAccountDialogState *state = NULL;
GtkWidget *box = NULL;
GtkWidget *grid = NULL;
GtkWidget *actions = NULL;
GtkWidget *cancel = NULL;
GtkWidget *create = NULL;
GtkStringList *evidence_labels = NULL;
GDateTime *now = NULL;
char *now_text = NULL;
guint index = 0;
if (parent == NULL) return FALSE;
state = g_new0(CreateSocialAccountDialogState, 1);
state->callback = callback;
state->user_data = user_data;
state->evidence_identifiers = g_ptr_array_new_with_free_func(g_free);
state->window = GTK_WINDOW(gtk_window_new());
gtk_window_set_title(state->window, "Ajouter un compte social");
labfy_dialog_prepare(state->window, parent, TRUE, TRUE);
box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
gtk_widget_set_margin_start(box, 16); gtk_widget_set_margin_end(box, 16);
gtk_widget_set_margin_top(box, 16); gtk_widget_set_margin_bottom(box, 16);
grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 8);
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
state->platform = GTK_DROP_DOWN(gtk_drop_down_new_from_strings(platform_labels));
state->url = GTK_ENTRY(gtk_entry_new());
gtk_entry_set_placeholder_text(state->url, "https://www.tiktok.com/@compte");
state->username = GTK_ENTRY(gtk_entry_new());
gtk_entry_set_placeholder_text(state->username, "@pseudonyme affiché");
state->platform_identifier = GTK_ENTRY(gtk_entry_new());
gtk_entry_set_placeholder_text(state->platform_identifier, "Identifiant stable, si visible");
state->observed_at = GTK_ENTRY(gtk_entry_new());
now = g_date_time_new_now_utc();
now_text = g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ");
gtk_editable_set_text(GTK_EDITABLE(state->observed_at), now_text);
state->state = GTK_DROP_DOWN(gtk_drop_down_new_from_strings(state_labels));
evidence_labels = gtk_string_list_new(NULL);
gtk_string_list_append(evidence_labels, "Aucune preuve associée");
for (index = 0; evidence_records != NULL && index < evidence_records->len; index++)
{
EvidenceRecord *record = g_ptr_array_index((GPtrArray *) evidence_records, index);
const char *identifier = evidence_record_get_identifier(record);
const char *name = evidence_record_get_original_name(record);
if (identifier == NULL || name == NULL) continue;
gtk_string_list_append(evidence_labels, name);
g_ptr_array_add(state->evidence_identifiers, g_strdup(identifier));
}
state->evidence = GTK_DROP_DOWN(gtk_drop_down_new(
G_LIST_MODEL(evidence_labels), NULL));
/* gtk_drop_down_new() prend possession de la référence du modèle. */
evidence_labels = NULL;
state->notes = GTK_TEXT_VIEW(gtk_text_view_new());
gtk_text_view_set_wrap_mode(state->notes, GTK_WRAP_WORD_CHAR);
gtk_widget_set_size_request(GTK_WIDGET(state->notes), -1, 100);
create_social_account_dialog_add_row(GTK_GRID(grid), 0, "Plateforme", GTK_WIDGET(state->platform));
create_social_account_dialog_add_row(GTK_GRID(grid), 1, "URL complète", GTK_WIDGET(state->url));
create_social_account_dialog_add_row(GTK_GRID(grid), 2, "Pseudonyme", GTK_WIDGET(state->username));
create_social_account_dialog_add_row(GTK_GRID(grid), 3, "ID plateforme", GTK_WIDGET(state->platform_identifier));
create_social_account_dialog_add_row(GTK_GRID(grid), 4, "Première observation (UTC)", GTK_WIDGET(state->observed_at));
create_social_account_dialog_add_row(GTK_GRID(grid), 5, "État observé", GTK_WIDGET(state->state));
create_social_account_dialog_add_row(GTK_GRID(grid), 6, "Preuve associée", GTK_WIDGET(state->evidence));
create_social_account_dialog_add_row(GTK_GRID(grid), 7, "Notes factuelles", GTK_WIDGET(state->notes));
state->error = GTK_LABEL(gtk_label_new(NULL));
gtk_label_set_wrap(state->error, TRUE);
gtk_widget_add_css_class(GTK_WIDGET(state->error), "error");
gtk_widget_set_visible(GTK_WIDGET(state->error), FALSE);
actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_halign(actions, GTK_ALIGN_END);
cancel = gtk_button_new_with_label("Annuler");
create = gtk_button_new_with_label("Ajouter au graphe");
gtk_widget_add_css_class(create, "suggested-action");
gtk_box_append(GTK_BOX(actions), cancel); gtk_box_append(GTK_BOX(actions), create);
gtk_box_append(GTK_BOX(box), grid);
gtk_box_append(GTK_BOX(box), GTK_WIDGET(state->error));
gtk_box_append(GTK_BOX(box), actions);
gtk_window_set_child(state->window, box);
g_signal_connect(state->window, "close-request", G_CALLBACK(create_social_account_dialog_on_close), state);
g_signal_connect(cancel, "clicked", G_CALLBACK(create_social_account_dialog_on_cancel), state);
g_signal_connect(create, "clicked", G_CALLBACK(create_social_account_dialog_on_create), state);
g_object_set_data_full(G_OBJECT(state->window), "social-account-state", state,
create_social_account_dialog_state_free);
labfy_dialog_present(state->window);
g_free(now_text); g_date_time_unref(now);
return TRUE;
}
void create_social_account_dialog_result_free(CreateSocialAccountDialogResult *result)
{
if (result == NULL) return;
g_free(result->platform); g_free(result->profile_url); g_free(result->username);
g_free(result->platform_identifier); g_free(result->first_observed_at);
g_free(result->account_state); g_free(result->notes);
g_free(result->evidence_identifier); g_free(result);
}
#define SOCIAL_RESULT_GETTER(name, field) \
const char *create_social_account_dialog_result_get_##name( \
const CreateSocialAccountDialogResult *result) \
{ return result != NULL ? result->field : NULL; }
SOCIAL_RESULT_GETTER(platform, platform)
SOCIAL_RESULT_GETTER(profile_url, profile_url)
SOCIAL_RESULT_GETTER(username, username)
SOCIAL_RESULT_GETTER(platform_identifier, platform_identifier)
SOCIAL_RESULT_GETTER(first_observed_at, first_observed_at)
SOCIAL_RESULT_GETTER(account_state, account_state)
SOCIAL_RESULT_GETTER(notes, notes)
SOCIAL_RESULT_GETTER(evidence_identifier, evidence_identifier)

View file

@ -1,170 +0,0 @@
#include "views/dialog_geometry.h"
#define LABFY_DIALOG_INITIAL_WIDTH 1200
#define LABFY_DIALOG_INITIAL_HEIGHT 800
#define LABFY_DIALOG_MINIMUM_WIDTH 800
#define LABFY_DIALOG_MINIMUM_HEIGHT 600
#define LABFY_DIALOG_WORKAREA_FACTOR 0.90
typedef struct {
double ratio;
int minimum_start;
int minimum_end;
int previous_width;
guint stable_frames;
} LabfyPanedGeometry;
LabfyDialogGeometry labfy_dialog_geometry_calculate(
int workarea_width, int workarea_height)
{
LabfyDialogGeometry geometry = {0};
int usable_width = workarea_width > 0
? (workarea_width >= LABFY_DIALOG_MINIMUM_WIDTH
? workarea_width
: MAX(1, (int) (
workarea_width * LABFY_DIALOG_WORKAREA_FACTOR)))
: LABFY_DIALOG_INITIAL_WIDTH;
int usable_height = workarea_height > 0
? (workarea_height >= LABFY_DIALOG_MINIMUM_HEIGHT
? workarea_height
: MAX(1, (int) (
workarea_height * LABFY_DIALOG_WORKAREA_FACTOR)))
: LABFY_DIALOG_INITIAL_HEIGHT;
geometry.initial_width = MIN(LABFY_DIALOG_INITIAL_WIDTH, usable_width);
geometry.initial_height = MIN(LABFY_DIALOG_INITIAL_HEIGHT, usable_height);
geometry.minimum_width = workarea_width >= LABFY_DIALOG_MINIMUM_WIDTH
? LABFY_DIALOG_MINIMUM_WIDTH : usable_width;
geometry.minimum_height = workarea_height >= LABFY_DIALOG_MINIMUM_HEIGHT
? LABFY_DIALOG_MINIMUM_HEIGHT : usable_height;
geometry.minimum_width = MIN(
geometry.minimum_width, geometry.initial_width);
geometry.minimum_height = MIN(
geometry.minimum_height, geometry.initial_height);
return geometry;
}
static GdkMonitor *dialog_geometry_find_monitor(
GtkWindow *window, GtkWindow *parent)
{
GdkDisplay *display = gtk_widget_get_display(GTK_WIDGET(window));
GtkNative *native = parent != NULL
? gtk_widget_get_native(GTK_WIDGET(parent)) : NULL;
GdkSurface *surface = native != NULL
? gtk_native_get_surface(native) : NULL;
if (surface != NULL) {
GdkMonitor *monitor =
gdk_display_get_monitor_at_surface(display, surface);
if (monitor != NULL) return g_object_ref(monitor);
}
GListModel *monitors = gdk_display_get_monitors(display);
return monitors != NULL && g_list_model_get_n_items(monitors) > 0
? g_list_model_get_item(monitors, 0) : NULL;
}
void labfy_dialog_apply_standard_geometry(
GtkWindow *window, GtkWindow *parent)
{
GdkRectangle workarea = {0};
GdkMonitor *monitor;
LabfyDialogGeometry geometry;
if (!GTK_IS_WINDOW(window)) return;
monitor = dialog_geometry_find_monitor(window, parent);
if (monitor != NULL) gdk_monitor_get_geometry(monitor, &workarea);
geometry = labfy_dialog_geometry_calculate(
workarea.width, workarea.height);
gtk_window_set_resizable(window, TRUE);
gtk_window_set_default_size(window,
geometry.initial_width, geometry.initial_height);
gtk_widget_set_size_request(GTK_WIDGET(window),
geometry.minimum_width, geometry.minimum_height);
g_clear_object(&monitor);
}
void labfy_dialog_prepare(
GtkWindow *window,
GtkWindow *parent,
gboolean modal,
gboolean destroy_with_parent)
{
if (!GTK_IS_WINDOW(window)) return;
gtk_window_set_transient_for(window,
GTK_IS_WINDOW(parent) ? parent : NULL);
gtk_window_set_modal(window, modal);
gtk_window_set_destroy_with_parent(window,
destroy_with_parent && GTK_IS_WINDOW(parent));
}
void labfy_dialog_present(GtkWindow *window)
{
GtkWindow *parent;
if (!GTK_IS_WINDOW(window)) return;
parent = gtk_window_get_transient_for(window);
labfy_dialog_apply_standard_geometry(window, parent);
gtk_window_present(window);
}
static void paned_geometry_apply(
GtkPaned *paned, LabfyPanedGeometry *geometry)
{
int width = gtk_widget_get_width(GTK_WIDGET(paned));
int position;
if (geometry == NULL ||
width < geometry->minimum_start + geometry->minimum_end) return;
position = (int) (width * geometry->ratio);
position = MAX(geometry->minimum_start, position);
position = MIN(position, MAX(0, width - geometry->minimum_end));
gtk_paned_set_position(paned, position);
}
static gboolean paned_geometry_tick(GtkWidget *widget, GdkFrameClock *clock,
gpointer data)
{
GtkPaned *paned = GTK_PANED(widget);
LabfyPanedGeometry *geometry = g_object_get_data(
G_OBJECT(paned), "labfy-paned-initial-geometry");
int width;
(void) clock;
(void) data;
if (geometry == NULL) return G_SOURCE_REMOVE;
width = gtk_widget_get_width(widget);
if (width < geometry->minimum_start + geometry->minimum_end) {
geometry->previous_width = width;
geometry->stable_frames = 0;
return G_SOURCE_CONTINUE;
}
if (width != geometry->previous_width) {
geometry->previous_width = width;
geometry->stable_frames = 0;
return G_SOURCE_CONTINUE;
}
if (geometry->stable_frames++ < 1) return G_SOURCE_CONTINUE;
paned_geometry_apply(paned, geometry);
g_object_set_data(G_OBJECT(paned), "labfy-paned-initial-ratio-applied",
GINT_TO_POINTER(1));
return G_SOURCE_REMOVE;
}
gboolean labfy_paned_initial_ratio_applied(GtkPaned *paned)
{
return GTK_IS_PANED(paned) && g_object_get_data(G_OBJECT(paned),
"labfy-paned-initial-ratio-applied") != NULL;
}
void labfy_paned_apply_initial_ratio(
GtkPaned *paned, double ratio, int minimum_start, int minimum_end)
{
LabfyPanedGeometry *geometry;
if (!GTK_IS_PANED(paned)) return;
if (g_object_get_data(
G_OBJECT(paned), "labfy-paned-initial-geometry") != NULL)
return;
geometry = g_new0(LabfyPanedGeometry, 1);
geometry->ratio = CLAMP(ratio, 0.1, 0.9);
geometry->minimum_start = MAX(0, minimum_start);
geometry->minimum_end = MAX(0, minimum_end);
g_object_set_data_full(G_OBJECT(paned),
"labfy-paned-initial-geometry", geometry, g_free);
gtk_widget_add_tick_callback(GTK_WIDGET(paned), paned_geometry_tick,
NULL, NULL);
}

View file

@ -1,52 +0,0 @@
#include "views/document_authenticity_editor.h"
#include "core/document_authenticity_service.h"
#include "dao/identity_ocr_dao.h"
struct DocumentAuthenticityEditor{Database*database;char*evidence;GtkWidget*root;
GtkDropDown*status;GtkStringList*status_labels;GtkDropDown*run;GtkStringList*run_labels;
GPtrArray*run_ids;GtkTextBuffer*justification;GtkTextBuffer*note;GtkLabel*error;GtkBox*history;};
static void clear_box(GtkBox*b){GtkWidget*c=gtk_widget_get_first_child(GTK_WIDGET(b));while(c){GtkWidget*n=gtk_widget_get_next_sibling(c);gtk_box_remove(b,c);c=n;}}
static char *buffer_text(GtkTextBuffer*b){GtkTextIter s,e;gtk_text_buffer_get_bounds(b,&s,&e);char*t=gtk_text_buffer_get_text(b,&s,&e,FALSE);g_strstrip(t);return t;}
gboolean document_authenticity_editor_refresh(DocumentAuthenticityEditor*x,GError**error)
{
if(!x)return FALSE;
clear_box(x->history);GPtrArray*h=document_authenticity_service_history(x->database,x->evidence,error);if(!h)return FALSE;
if(h->len==0)gtk_box_append(x->history,gtk_label_new("Aucune appréciation humaine enregistrée."));
for(gint i=(gint)h->len-1;i>=0;i--){DocumentAuthenticityAssessment*a=g_ptr_array_index(h,(guint)i);
const DocumentAuthenticityStatus*s=document_authenticity_service_status(document_authenticity_assessment_get_status(a));
const char*j=document_authenticity_assessment_get_justification(a),*n=document_authenticity_assessment_get_technical_note(a),*r=document_authenticity_assessment_get_ocr_run_identifier(a);
char*t=g_strdup_printf("%s — %s — origine humaine%s%s%s%s%s%s",s?s->label:"Statut inconnu",
document_authenticity_assessment_get_assessed_at(a),r?" — OcrRun : ":"",r?r:"",j?"\nJustification : ":"",j?j:"",n?"\nNote technique : ":"",n?n:"");
GtkWidget*l=gtk_label_new(t);gtk_label_set_xalign(GTK_LABEL(l),0);gtk_label_set_wrap(GTK_LABEL(l),TRUE);gtk_box_append(x->history,l);g_free(t);}
g_ptr_array_unref(h);return TRUE;
}
static void save(GtkButton*b,gpointer data)
{
(void)b;DocumentAuthenticityEditor*x=data;guint si=gtk_drop_down_get_selected(x->status),ri=gtk_drop_down_get_selected(x->run);gsize count=0;
const DocumentAuthenticityStatus*s=document_authenticity_service_statuses(&count);if(si>=count)return;
char*j=buffer_text(x->justification),*n=buffer_text(x->note);const char*run=ri>0&&ri-1<x->run_ids->len?g_ptr_array_index(x->run_ids,ri-1):NULL;GError*error=NULL;
if(!document_authenticity_service_add(x->database,x->evidence,s[si].code,j,
n&&*n?n:NULL,run,NULL,&error)){
gtk_label_set_text(x->error,error?error->message:"Erreur d’enregistrement.");gtk_widget_set_visible(GTK_WIDGET(x->error),TRUE);g_clear_error(&error);
}else{gtk_widget_set_visible(GTK_WIDGET(x->error),FALSE);gtk_text_buffer_set_text(x->justification,"",-1);gtk_text_buffer_set_text(x->note,"",-1);document_authenticity_editor_refresh(x,NULL);}
g_free(j);g_free(n);
}
DocumentAuthenticityEditor *document_authenticity_editor_new(Database*d,const char*evidence)
{
if(!d||!g_uuid_string_is_valid(evidence))return NULL;
DocumentAuthenticityEditor*x=g_new0(DocumentAuthenticityEditor,1);x->database=d;x->evidence=g_strdup(evidence);
x->root=gtk_box_new(GTK_ORIENTATION_VERTICAL,6);gtk_widget_set_name(x->root,"document-authenticity-editor");
GtkWidget*title=gtk_label_new("Authenticité documentaire — appréciation humaine");gtk_label_set_xalign(GTK_LABEL(title),0);gtk_box_append(GTK_BOX(x->root),title);
x->status_labels=gtk_string_list_new(NULL);gsize count=0;const DocumentAuthenticityStatus*s=document_authenticity_service_statuses(&count);for(guint i=0;i<count;i++)gtk_string_list_append(x->status_labels,s[i].label);
x->status=GTK_DROP_DOWN(gtk_drop_down_new(G_LIST_MODEL(g_object_ref(x->status_labels)),NULL));gtk_widget_set_name(GTK_WIDGET(x->status),"authenticity-status");gtk_box_append(GTK_BOX(x->root),GTK_WIDGET(x->status));
x->run_labels=gtk_string_list_new(NULL);gtk_string_list_append(x->run_labels,"Aucune exécution OCR");x->run_ids=g_ptr_array_new_with_free_func(g_free);GPtrArray*runs=document_authenticity_service_list_runs(d,evidence,NULL);
if(runs){for(guint i=0;i<runs->len;i++){IdentityOcrRunRecord*r=g_ptr_array_index(runs,i);char*l=g_strdup_printf("%s — %s",r->executed_at,r->engine);gtk_string_list_append(x->run_labels,l);g_ptr_array_add(x->run_ids,g_strdup(r->id));g_free(l);}g_ptr_array_unref(runs);}
x->run=GTK_DROP_DOWN(gtk_drop_down_new(G_LIST_MODEL(g_object_ref(x->run_labels)),NULL));gtk_widget_set_name(GTK_WIDGET(x->run),"authenticity-ocr-run");gtk_box_append(GTK_BOX(x->root),GTK_WIDGET(x->run));
GtkWidget*jv=gtk_text_view_new();x->justification=gtk_text_view_get_buffer(GTK_TEXT_VIEW(jv));gtk_widget_set_name(jv,"authenticity-justification");gtk_widget_set_size_request(jv,-1,60);gtk_box_append(GTK_BOX(x->root),gtk_label_new("Justification factuelle"));gtk_box_append(GTK_BOX(x->root),jv);
GtkWidget*nv=gtk_text_view_new();x->note=gtk_text_view_get_buffer(GTK_TEXT_VIEW(nv));gtk_widget_set_name(nv,"authenticity-technical-note");gtk_widget_set_size_request(nv,-1,45);gtk_box_append(GTK_BOX(x->root),gtk_label_new("Note technique facultative"));gtk_box_append(GTK_BOX(x->root),nv);
x->error=GTK_LABEL(gtk_label_new(""));gtk_widget_set_name(GTK_WIDGET(x->error),"authenticity-error");gtk_widget_add_css_class(GTK_WIDGET(x->error),"error");gtk_widget_set_visible(GTK_WIDGET(x->error),FALSE);gtk_box_append(GTK_BOX(x->root),GTK_WIDGET(x->error));
GtkWidget*button=gtk_button_new_with_label("Enregistrer l’appréciation humaine");gtk_widget_set_name(button,"authenticity-save");g_signal_connect(button,"clicked",G_CALLBACK(save),x);gtk_box_append(GTK_BOX(x->root),button);
x->history=GTK_BOX(gtk_box_new(GTK_ORIENTATION_VERTICAL,4));gtk_widget_set_name(GTK_WIDGET(x->history),"authenticity-history");gtk_box_append(GTK_BOX(x->root),GTK_WIDGET(x->history));document_authenticity_editor_refresh(x,NULL);return x;
}
GtkWidget *document_authenticity_editor_get_widget(DocumentAuthenticityEditor*x){return x?x->root:NULL;}
void document_authenticity_editor_free(DocumentAuthenticityEditor*x){if(!x)return;g_clear_object(&x->status_labels);g_clear_object(&x->run_labels);g_ptr_array_unref(x->run_ids);g_free(x->evidence);g_free(x);}

View file

@ -1,49 +0,0 @@
#include "views/document_identity_misuse_editor.h"
#include "core/document_identity_misuse_service.h"
#include "dao/identity_ocr_dao.h"
struct DocumentIdentityMisuseEditor{Database*database;char*evidence;GtkWidget*root;
GtkDropDown*status;GtkStringList*status_labels;GtkDropDown*run;GtkStringList*run_labels;
GPtrArray*run_ids;GtkTextBuffer*justification;GtkLabel*error;GtkBox*history;};
static void clear_box(GtkBox*b){GtkWidget*c=gtk_widget_get_first_child(GTK_WIDGET(b));while(c){GtkWidget*n=gtk_widget_get_next_sibling(c);gtk_box_remove(b,c);c=n;}}
static char *buffer_text(GtkTextBuffer*b){GtkTextIter s,e;gtk_text_buffer_get_bounds(b,&s,&e);char*t=gtk_text_buffer_get_text(b,&s,&e,FALSE);g_strstrip(t);return t;}
gboolean document_identity_misuse_editor_refresh(DocumentIdentityMisuseEditor*x,GError**error)
{
if(!x)return FALSE;
clear_box(x->history);GPtrArray*h=document_identity_misuse_service_history(x->database,x->evidence,error);if(!h)return FALSE;
if(h->len==0)gtk_box_append(x->history,gtk_label_new("Aucune évaluation humaine enregistrée."));
for(gint i=(gint)h->len-1;i>=0;i--){DocumentIdentityMisuseAssessment*a=g_ptr_array_index(h,(guint)i);
const DocumentIdentityMisuseStatus*s=document_identity_misuse_service_status(document_identity_misuse_assessment_get_status(a));
const char*j=document_identity_misuse_assessment_get_justification(a),*r=document_identity_misuse_assessment_get_ocr_run_identifier(a);
char*t=g_strdup_printf("%s — %s\nOrigine : humaine%s%s\nJustification : %s",
s?s->label:"Statut inconnu",document_identity_misuse_assessment_get_assessed_at(a),
r?" — OcrRun : ":"",r?r:"",j&&*j?j:"Aucune (statut indéterminé)");
GtkWidget*l=gtk_label_new(t);gtk_label_set_xalign(GTK_LABEL(l),0);gtk_label_set_wrap(GTK_LABEL(l),TRUE);gtk_box_append(x->history,l);g_free(t);}
g_ptr_array_unref(h);return TRUE;
}
static void save(GtkButton*b,gpointer data)
{
(void)b;DocumentIdentityMisuseEditor*x=data;guint si=gtk_drop_down_get_selected(x->status),ri=gtk_drop_down_get_selected(x->run);gsize count=0;
const DocumentIdentityMisuseStatus*s=document_identity_misuse_service_statuses(&count);if(si>=count)return;
char*j=buffer_text(x->justification);const char*run=ri>0&&ri-1<x->run_ids->len?g_ptr_array_index(x->run_ids,ri-1):NULL;GError*error=NULL;
if(!document_identity_misuse_service_add(x->database,x->evidence,s[si].code,j,run,NULL,&error)){
gtk_label_set_text(x->error,error?error->message:"Erreur d’enregistrement.");gtk_widget_set_visible(GTK_WIDGET(x->error),TRUE);g_clear_error(&error);
}else{gtk_widget_set_visible(GTK_WIDGET(x->error),FALSE);gtk_text_buffer_set_text(x->justification,"",-1);document_identity_misuse_editor_refresh(x,NULL);}g_free(j);
}
DocumentIdentityMisuseEditor *document_identity_misuse_editor_new(Database*d,const char*evidence)
{
if(!d||!g_uuid_string_is_valid(evidence))return NULL;
DocumentIdentityMisuseEditor*x=g_new0(DocumentIdentityMisuseEditor,1);x->database=d;x->evidence=g_strdup(evidence);
x->root=gtk_box_new(GTK_ORIENTATION_VERTICAL,6);gtk_widget_set_name(x->root,"document-identity-misuse-editor");
GtkWidget*title=gtk_label_new("Usage de l’identité du document — évaluation humaine distincte");gtk_label_set_xalign(GTK_LABEL(title),0);gtk_box_append(GTK_BOX(x->root),title);
x->status_labels=gtk_string_list_new(NULL);gsize count=0;const DocumentIdentityMisuseStatus*s=document_identity_misuse_service_statuses(&count);for(guint i=0;i<count;i++)gtk_string_list_append(x->status_labels,s[i].label);
x->status=GTK_DROP_DOWN(gtk_drop_down_new(G_LIST_MODEL(g_object_ref(x->status_labels)),NULL));gtk_widget_set_name(GTK_WIDGET(x->status),"identity-misuse-status");gtk_box_append(GTK_BOX(x->root),GTK_WIDGET(x->status));
x->run_labels=gtk_string_list_new(NULL);gtk_string_list_append(x->run_labels,"Aucune exécution OCR");x->run_ids=g_ptr_array_new_with_free_func(g_free);GPtrArray*runs=document_identity_misuse_service_list_runs(d,evidence,NULL);
if(runs){for(guint i=0;i<runs->len;i++){IdentityOcrRunRecord*r=g_ptr_array_index(runs,i);char*l=g_strdup_printf("%s — %s",r->executed_at,r->engine);gtk_string_list_append(x->run_labels,l);g_ptr_array_add(x->run_ids,g_strdup(r->id));g_free(l);}g_ptr_array_unref(runs);}
x->run=GTK_DROP_DOWN(gtk_drop_down_new(G_LIST_MODEL(g_object_ref(x->run_labels)),NULL));gtk_widget_set_name(GTK_WIDGET(x->run),"identity-misuse-ocr-run");gtk_box_append(GTK_BOX(x->root),GTK_WIDGET(x->run));
gtk_box_append(GTK_BOX(x->root),gtk_label_new("Justification factuelle"));GtkWidget*jv=gtk_text_view_new();x->justification=gtk_text_view_get_buffer(GTK_TEXT_VIEW(jv));gtk_widget_set_name(jv,"identity-misuse-justification");gtk_widget_set_size_request(jv,-1,60);gtk_box_append(GTK_BOX(x->root),jv);
x->error=GTK_LABEL(gtk_label_new(""));gtk_widget_set_name(GTK_WIDGET(x->error),"identity-misuse-error");gtk_widget_add_css_class(GTK_WIDGET(x->error),"error");gtk_widget_set_visible(GTK_WIDGET(x->error),FALSE);gtk_box_append(GTK_BOX(x->root),GTK_WIDGET(x->error));
GtkWidget*button=gtk_button_new_with_label("Enregistrer l’évaluation humaine");gtk_widget_set_name(button,"identity-misuse-save");g_signal_connect(button,"clicked",G_CALLBACK(save),x);gtk_box_append(GTK_BOX(x->root),button);
x->history=GTK_BOX(gtk_box_new(GTK_ORIENTATION_VERTICAL,4));gtk_widget_set_name(GTK_WIDGET(x->history),"identity-misuse-history");gtk_box_append(GTK_BOX(x->root),GTK_WIDGET(x->history));document_identity_misuse_editor_refresh(x,NULL);return x;
}
GtkWidget *document_identity_misuse_editor_get_widget(DocumentIdentityMisuseEditor*x){return x?x->root:NULL;}
void document_identity_misuse_editor_free(DocumentIdentityMisuseEditor*x){if(!x)return;g_clear_object(&x->status_labels);g_clear_object(&x->run_labels);g_ptr_array_unref(x->run_ids);g_free(x->evidence);g_free(x);}

View file

@ -1,545 +0,0 @@
/******************************************************************************
* @file eml_analysis_dialog.c
* @brief Présentation en lecture seule d'une analyse EML.
******************************************************************************/
#include "views/eml_analysis_dialog.h"
#include "views/dialog_geometry.h"
#include "widgets/controlled_vocab_dropdown.h"
typedef struct { GtkWindow *window; GtkWidget *proposals_box;
GtkWidget *summary_label;
EmlAnalysisDialogCallback callback; gpointer user_data; gboolean completed;
} EmlAnalysisDialogState;
static void eml_analysis_dialog_update_summary(GtkCheckButton *button,
gpointer data)
{
EmlAnalysisDialogState *state = data; guint kept = 0, promoted = 0;
(void) button;
for (GtkWidget *row = gtk_widget_get_first_child(state->proposals_box);
row != NULL; row = gtk_widget_get_next_sibling(row))
{
GtkWidget *keep = g_object_get_data(G_OBJECT(row), "eml-keep");
GtkWidget *promote = g_object_get_data(G_OBJECT(row), "eml-promote");
gboolean active = keep != NULL &&
gtk_check_button_get_active(GTK_CHECK_BUTTON(keep));
if (promote != NULL)
{
gtk_widget_set_sensitive(promote, active);
if (!active)
gtk_check_button_set_active(GTK_CHECK_BUTTON(promote), FALSE);
}
if (active) kept++;
if (promote != NULL &&
gtk_check_button_get_active(GTK_CHECK_BUTTON(promote))) promoted++;
}
char *summary = g_strdup_printf(
"%u observation(s) seront conservée(s) dans la fiche ; "
"%u seront promue(s) en entité(s).", kept, promoted);
gtk_label_set_text(GTK_LABEL(state->summary_label), summary);
g_free(summary);
}
static void eml_analysis_dialog_bind_summary(EmlAnalysisDialogState *state,
GtkWidget *content)
{
state->summary_label = gtk_label_new(
"0 observation sera conservée dans la fiche ; "
"0 sera promue en entité.");
gtk_label_set_xalign(GTK_LABEL(state->summary_label), 0.0f);
gtk_box_append(GTK_BOX(content), state->summary_label);
for (GtkWidget *row = gtk_widget_get_first_child(state->proposals_box);
row != NULL; row = gtk_widget_get_next_sibling(row))
{
g_signal_connect(g_object_get_data(G_OBJECT(row), "eml-keep"),
"toggled", G_CALLBACK(eml_analysis_dialog_update_summary), state);
g_signal_connect(g_object_get_data(G_OBJECT(row), "eml-promote"),
"toggled", G_CALLBACK(eml_analysis_dialog_update_summary), state);
}
}
/** @brief Libère l'état de révision. */
static void eml_analysis_dialog_state_free(gpointer data) { g_free(data); }
/** @brief Signale une annulation une seule fois. */
static void eml_analysis_dialog_cancel(EmlAnalysisDialogState *state)
{ if (state == NULL || state->completed) return; state->completed = TRUE;
if (state->callback != NULL) state->callback(NULL, state->user_data); }
/** @brief Traite la fermeture native. */
static gboolean eml_analysis_dialog_on_window_close(GtkWindow *window, gpointer data)
{ (void) window; eml_analysis_dialog_cancel(data); return FALSE; }
/** @brief Ferme la fenêtre de résultat. */
static void eml_analysis_dialog_on_close(GtkButton *button, gpointer data)
{ EmlAnalysisDialogState *state = data; (void) button;
eml_analysis_dialog_cancel(state); gtk_window_close(state->window); }
/** @brief Transmet uniquement les propositions explicitement cochées. */
static void eml_analysis_dialog_on_integrate(GtkButton *button, gpointer data)
{
EmlAnalysisDialogState *state = data;
GPtrArray *selected = g_ptr_array_new_with_free_func(
(GDestroyNotify) eml_entity_proposal_free);
(void) button;
for (GtkWidget *child = gtk_widget_get_first_child(state->proposals_box);
child != NULL; child = gtk_widget_get_next_sibling(child))
{
GtkWidget *check = g_object_get_data(G_OBJECT(child), "eml-keep");
if (check != NULL && GTK_IS_CHECK_BUTTON(check) &&
gtk_check_button_get_active(GTK_CHECK_BUTTON(check)))
{
const char *type = g_object_get_data(G_OBJECT(child), "eml-type");
const char *value = g_object_get_data(G_OBJECT(child), "eml-value");
GtkWidget *status = g_object_get_data(G_OBJECT(child), "eml-status");
GtkWidget *provenance = g_object_get_data(G_OBJECT(child),
"eml-provenance");
const char *raw = g_object_get_data(G_OBJECT(child), "eml-raw");
const char *role = g_object_get_data(G_OBJECT(child), "eml-role");
const char *header = g_object_get_data(G_OBJECT(child), "eml-header");
guint occurrence = GPOINTER_TO_UINT(g_object_get_data(
G_OBJECT(child), "eml-occurrence"));
EmlEntityProposal *proposal = eml_entity_proposal_new_observation(
type, raw != NULL ? raw : value, value,
role != NULL ? role : "other",
header != NULL ? header : "manual",
occurrence > 0 ? occurrence : 1,
controlled_vocab_dropdown_get_selected_code(status),
controlled_vocab_dropdown_get_selected_code(provenance));
GtkWidget *promote = g_object_get_data(G_OBJECT(child),
"eml-promote");
proposal->promote_to_entity = promote != NULL &&
gtk_check_button_get_active(GTK_CHECK_BUTTON(promote));
g_ptr_array_add(selected, proposal);
}
}
if (selected->len == 0)
{ g_ptr_array_unref(selected); return; }
state->completed = TRUE;
if (state->callback != NULL) state->callback(selected, state->user_data);
else g_ptr_array_unref(selected);
gtk_window_close(state->window);
}
static void eml_analysis_dialog_add_observations(GtkWidget *box,
const GPtrArray *observations)
{
for (guint i = 0; observations != NULL && i < observations->len; i++)
{
const EmlObservation *observation = g_ptr_array_index(
(GPtrArray *) observations, i);
char *text = g_strdup_printf("%s — %s — rôle : %s — origine : %s #%u",
observation->type_identifier, observation->value_normalized,
observation->role, observation->source_header,
observation->occurrence);
GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget *check = gtk_check_button_new_with_label(
"Conserver dans la fiche");
GtkWidget *value_label = gtk_label_new(text);
GtkWidget *promote = gtk_check_button_new_with_label(
"Promouvoir en entité");
gtk_widget_set_sensitive(promote, FALSE);
GtkWidget *status = controlled_vocab_dropdown_new(
CONTROLLED_VOCAB_VERIFICATION_STATUS, "proposed");
GtkWidget *provenance = controlled_vocab_dropdown_new(
CONTROLLED_VOCAB_PROVENANCE_KIND, observation->provenance_kind);
g_object_set_data_full(G_OBJECT(row), "eml-type",
g_strdup(observation->type_identifier), g_free);
g_object_set_data_full(G_OBJECT(row), "eml-value",
g_strdup(observation->value_normalized), g_free);
g_object_set_data_full(G_OBJECT(row), "eml-raw",
g_strdup(observation->value_raw), g_free);
g_object_set_data_full(G_OBJECT(row), "eml-role",
g_strdup(observation->role), g_free);
g_object_set_data_full(G_OBJECT(row), "eml-header",
g_strdup(observation->source_header), g_free);
g_object_set_data(G_OBJECT(row), "eml-occurrence",
GUINT_TO_POINTER(observation->occurrence));
g_object_set_data(G_OBJECT(row), "eml-status", status);
g_object_set_data(G_OBJECT(row), "eml-provenance", provenance);
g_object_set_data(G_OBJECT(row), "eml-keep", check);
g_object_set_data(G_OBJECT(row), "eml-promote", promote);
gtk_widget_set_hexpand(check, TRUE);
gtk_box_append(GTK_BOX(row), check);
gtk_box_append(GTK_BOX(row), value_label);
gtk_box_append(GTK_BOX(row), promote);
gtk_box_append(GTK_BOX(row), status);
gtk_box_append(GTK_BOX(row), provenance);
gtk_box_append(GTK_BOX(box), row);
g_free(text);
}
}
/** @brief Ajoute les propositions d'un type sous forme de cases décochées. */
static void eml_analysis_dialog_add_proposals(GtkWidget *box,
const char *type, const char *label, const GPtrArray *values)
{
for (guint i = 0; values != NULL && i < values->len; i++)
{
const char *value = g_ptr_array_index((GPtrArray *) values, i);
char *text = g_strdup_printf("%s : %s", label, value);
GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget *check = gtk_check_button_new_with_label(
"Conserver dans la fiche");
GtkWidget *value_label = gtk_label_new(text);
GtkWidget *promote = gtk_check_button_new_with_label(
"Promouvoir en entité");
gtk_widget_set_sensitive(promote, FALSE);
GtkWidget *status = controlled_vocab_dropdown_new(
CONTROLLED_VOCAB_VERIFICATION_STATUS, "proposed");
GtkWidget *provenance = controlled_vocab_dropdown_new(
CONTROLLED_VOCAB_PROVENANCE_KIND,
g_strcmp0(type, "ip_address") == 0 ? "header" : "header");
g_object_set_data_full(G_OBJECT(row), "eml-type", g_strdup(type), g_free);
g_object_set_data_full(G_OBJECT(row), "eml-value", g_strdup(value), g_free);
g_object_set_data(G_OBJECT(row), "eml-status", status);
g_object_set_data(G_OBJECT(row), "eml-provenance", provenance);
g_object_set_data(G_OBJECT(row), "eml-keep", check);
g_object_set_data(G_OBJECT(row), "eml-promote", promote);
gtk_widget_set_hexpand(check, TRUE);
gtk_box_append(GTK_BOX(row), check);
gtk_box_append(GTK_BOX(row), value_label);
gtk_box_append(GTK_BOX(row), promote);
gtk_box_append(GTK_BOX(row), status);
gtk_box_append(GTK_BOX(row), provenance);
gtk_box_append(GTK_BOX(box), row); g_free(text);
}
}
/** @brief Ajoute une ligne de métadonnée sélectionnable. */
static void eml_analysis_dialog_add_field(GtkGrid *grid, int row,
const char *title, const char *value)
{
GtkWidget *name = gtk_label_new(title);
GtkWidget *content = gtk_label_new(value != NULL ? value : "Non présent");
gtk_label_set_xalign(GTK_LABEL(name), 0.0f);
gtk_label_set_xalign(GTK_LABEL(content), 0.0f);
gtk_label_set_selectable(GTK_LABEL(content), TRUE);
gtk_label_set_wrap(GTK_LABEL(content), TRUE);
gtk_widget_set_valign(name, GTK_ALIGN_START);
gtk_grid_attach(grid, name, 0, row, 1, 1);
gtk_grid_attach(grid, content, 1, row, 1, 1);
}
/** @brief Concatène un tableau de chaînes pour son affichage. */
static char *eml_analysis_dialog_join(const GPtrArray *values)
{
GString *text = g_string_new(NULL);
for (guint i = 0; values != NULL && i < values->len; i++)
g_string_append_printf(text, "%s%s", i > 0 ? "\n" : "",
(const char *) g_ptr_array_index((GPtrArray *) values, i));
return g_string_free(text, FALSE);
}
void eml_analysis_dialog_present(GtkWindow *parent,
const EmlProcessingResult *result, EmlAnalysisDialogCallback callback,
gpointer user_data)
{
const EmlAnalysis *analysis = eml_processing_result_get_analysis(result);
GtkWindow *window = NULL;
GtkWidget *box = NULL, *content = NULL, *scroll = NULL;
GtkWidget *grid = NULL, *raw_scroll = NULL, *raw = NULL;
GtkWidget *close = NULL, *integrate = NULL, *actions = NULL;
EmlAnalysisDialogState *state = NULL;
char *received = NULL, *emails = NULL, *domains = NULL;
char *sender_ips = NULL, *destination_ips = NULL;
if (parent == NULL || analysis == NULL) return;
state = g_new0(EmlAnalysisDialogState, 1);
state->callback = callback; state->user_data = user_data;
window = GTK_WINDOW(gtk_window_new());
gtk_window_set_title(window, "Analyse locale du courriel EML");
labfy_dialog_prepare(window, parent, TRUE, TRUE);
state->window = window;
box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
gtk_widget_set_margin_start(box, 14); gtk_widget_set_margin_end(box, 14);
gtk_widget_set_margin_top(box, 14); gtk_widget_set_margin_bottom(box, 14);
content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
gtk_box_append(GTK_BOX(content), gtk_label_new(
"Analyse effectuée sur la copie vérifiée de 02_Preuves_Traitees/Extractions."));
grid = gtk_grid_new(); gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
eml_analysis_dialog_add_field(GTK_GRID(grid), 0, "Copie analysée",
eml_processing_result_get_copy_path(result));
eml_analysis_dialog_add_field(GTK_GRID(grid), 1, "From",
eml_analysis_get_first_header(analysis, "from"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 2, "Reply-To",
eml_analysis_get_first_header(analysis, "reply-to"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 3, "To",
eml_analysis_get_first_header(analysis, "to"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 4, "Sujet",
eml_analysis_get_first_header(analysis, "subject"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 5, "Date",
eml_analysis_get_first_header(analysis, "date"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 6, "Message-ID",
eml_analysis_get_first_header(analysis, "message-id"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 7, "Authentification",
eml_analysis_get_first_header(analysis, "authentication-results"));
received = eml_analysis_dialog_join(
eml_analysis_get_header_values(analysis, "received"));
emails = eml_analysis_dialog_join(eml_analysis_get_email_addresses(analysis));
domains = eml_analysis_dialog_join(eml_analysis_get_domains(analysis));
sender_ips = eml_analysis_dialog_join(
eml_analysis_get_sender_ip_addresses(analysis));
destination_ips = eml_analysis_dialog_join(
eml_analysis_get_destination_ip_addresses(analysis));
eml_analysis_dialog_add_field(GTK_GRID(grid), 8, "Received (ordre original)", received);
eml_analysis_dialog_add_field(GTK_GRID(grid), 9, "Emails proposés", emails);
eml_analysis_dialog_add_field(GTK_GRID(grid), 10, "Domaines proposés", domains);
eml_analysis_dialog_add_field(GTK_GRID(grid), 11, "IP expéditeur",
sender_ips);
eml_analysis_dialog_add_field(GTK_GRID(grid), 12, "IP destinataire",
destination_ips);
gtk_box_append(GTK_BOX(content), grid);
gtk_box_append(GTK_BOX(content), gtk_label_new(
"Choisir les observations à conserver ; la promotion vers le graphe "
"est une action séparée."));
state->proposals_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
eml_analysis_dialog_add_observations(state->proposals_box,
eml_analysis_get_observations(analysis));
gtk_box_append(GTK_BOX(content), state->proposals_box);
eml_analysis_dialog_bind_summary(state, content);
gtk_box_append(GTK_BOX(content), gtk_label_new("En-têtes bruts (lecture seule)"));
raw = gtk_text_view_new(); gtk_text_view_set_editable(GTK_TEXT_VIEW(raw), FALSE);
gtk_text_view_set_monospace(GTK_TEXT_VIEW(raw), TRUE);
gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(raw), GTK_WRAP_WORD_CHAR);
gtk_text_buffer_set_text(gtk_text_view_get_buffer(GTK_TEXT_VIEW(raw)),
eml_analysis_get_raw_headers(analysis), -1);
raw_scroll = gtk_scrolled_window_new();
gtk_widget_set_size_request(raw_scroll, -1, 180);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(raw_scroll), raw);
gtk_box_append(GTK_BOX(content), raw_scroll);
scroll = gtk_scrolled_window_new();
gtk_widget_set_vexpand(scroll, TRUE);
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll),
GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), content);
gtk_box_append(GTK_BOX(box), scroll);
actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_halign(actions, GTK_ALIGN_END);
close = gtk_button_new_with_label("Fermer");
integrate = gtk_button_new_with_label("Intégrer la sélection");
gtk_widget_add_css_class(integrate, "suggested-action");
g_signal_connect(close, "clicked", G_CALLBACK(eml_analysis_dialog_on_close), state);
g_signal_connect(integrate, "clicked", G_CALLBACK(eml_analysis_dialog_on_integrate), state);
gtk_box_append(GTK_BOX(actions), close); gtk_box_append(GTK_BOX(actions), integrate);
gtk_box_append(GTK_BOX(box), actions); gtk_window_set_child(window, box);
g_signal_connect(window, "close-request",
G_CALLBACK(eml_analysis_dialog_on_window_close), state);
g_object_set_data_full(G_OBJECT(window), "eml-dialog-state", state,
eml_analysis_dialog_state_free);
labfy_dialog_present(window);
g_free(received); g_free(emails); g_free(domains);
g_free(sender_ips); g_free(destination_ips);
}
static void eml_analysis_dialog_append_attachment_summary(
GString *text,
const EmlPipelineResult *result)
{
for (guint index = 0; result->mime_result != NULL &&
result->mime_result->attachments != NULL &&
index < result->mime_result->attachments->len; index++)
{
EmlAttachment *attachment = g_ptr_array_index(
result->mime_result->attachments, index);
g_string_append_printf(text,
"%s → %s | MIME %s / %s | partie %s | %"
G_GSIZE_FORMAT " octets | SHA-256 %s%s\n",
attachment->declared_filename != NULL
? attachment->declared_filename : "(sans nom)",
attachment->sanitized_filename != NULL
? attachment->sanitized_filename : "(sans nom)",
attachment->content_type != NULL
? attachment->content_type : "inconnu",
attachment->detected_mime != NULL
? attachment->detected_mime : "inconnu",
attachment->part_index != NULL ? attachment->part_index : "?",
attachment->decoded_size,
attachment->sha256 != NULL ? attachment->sha256 : "indisponible",
attachment->is_truncated ? " | TRONQUÉ" : "");
}
}
static void eml_analysis_dialog_append_document_summary(
GString *text,
GString *metadata,
GString *warnings,
const EmlPipelineResult *result)
{
for (guint index = 0; result->document_analyses != NULL &&
index < result->document_analyses->len; index++)
{
DocumentFileAnalysis *document = g_ptr_array_index(
result->document_analyses, index);
if (document->ocr != NULL && document->ocr->text != NULL)
g_string_append_printf(text, "\nOCR (%s)%s\n%s\n",
document->ocr->requested_languages,
document->ocr->execution->stdout_truncated
? " — texte tronqué/partiel" : "",
document->ocr->text);
if (document->pdf != NULL)
for (guint page_index = 0;
page_index < document->pdf->pages->len; page_index++)
{
PdfPageAnalysis *page = g_ptr_array_index(
document->pdf->pages, page_index);
g_string_append_printf(text,
"\nPDF page %u — méthode %s — état %s\n%s\n",
page->page_number,
page->method == PDF_PAGE_METHOD_NATIVE
? "texte natif" : "OCR",
document_analysis_state_code(page->state),
page->text != NULL ? page->text : "(aucun texte)");
}
if (document->metadata != NULL)
{
DocumentToolExecution *execution =
document->metadata->execution;
if (execution != NULL &&
execution->state == DOCUMENT_ANALYSIS_STATE_UNAVAILABLE)
g_string_append(warnings, "ExifTool indisponible.\n");
for (guint metadata_index = 0;
metadata_index < document->metadata->metadata->len;
metadata_index++)
{
DocumentMetadataEntry *entry = g_ptr_array_index(
document->metadata->metadata, metadata_index);
g_string_append_printf(metadata,
"%s | %s:%s | %s | %s%s\n",
entry->code, entry->original_group,
entry->original_tag, entry->raw_value,
execution != NULL && execution->version != NULL
? execution->version : "version inconnue",
entry->sensitive
? " | SENSIBLE — confirmation explicite requise" : "");
}
}
}
}
void eml_analysis_dialog_present_pipeline(
GtkWindow *parent,
const EmlPipelineResult *result,
const char *evidence_name,
const char *relative_path,
const char *source_sha256,
EmlAnalysisDialogCallback callback,
gpointer user_data)
{
if (parent == NULL || result == NULL || result->analysis == NULL) return;
EmlAnalysisDialogState *state = g_new0(EmlAnalysisDialogState, 1);
state->callback = callback; state->user_data = user_data;
state->window = GTK_WINDOW(gtk_window_new());
gtk_window_set_title(state->window, "Révision de l’analyse EML");
labfy_dialog_prepare(state->window, parent, TRUE, TRUE);
GtkWidget *outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
gtk_widget_set_margin_start(outer, 14);
gtk_widget_set_margin_end(outer, 14);
gtk_widget_set_margin_top(outer, 14);
gtk_widget_set_margin_bottom(outer, 14);
GtkWidget *content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
GtkWidget *grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
eml_analysis_dialog_add_field(GTK_GRID(grid), 0, "Preuve", evidence_name);
eml_analysis_dialog_add_field(GTK_GRID(grid), 1, "Chemin relatif", relative_path);
eml_analysis_dialog_add_field(GTK_GRID(grid), 2, "SHA-256", source_sha256);
eml_analysis_dialog_add_field(GTK_GRID(grid), 3, "État global",
document_analysis_state_code(result->state));
eml_analysis_dialog_add_field(GTK_GRID(grid), 4, "From",
eml_analysis_get_first_header(result->analysis, "from"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 5, "Sender",
eml_analysis_get_first_header(result->analysis, "sender"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 6, "Reply-To",
eml_analysis_get_first_header(result->analysis, "reply-to"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 7, "Return-Path",
eml_analysis_get_first_header(result->analysis, "return-path"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 8, "To",
eml_analysis_get_first_header(result->analysis, "to"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 9, "Cc",
eml_analysis_get_first_header(result->analysis, "cc"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 10, "Subject",
eml_analysis_get_first_header(result->analysis, "subject"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 11, "Date brute",
eml_analysis_get_first_header(result->analysis, "date"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 12, "Message-ID",
eml_analysis_get_first_header(result->analysis, "message-id"));
char *received = eml_analysis_dialog_join(
eml_analysis_get_header_values(result->analysis, "received"));
eml_analysis_dialog_add_field(GTK_GRID(grid), 13,
"Received (ordre original)", received);
gtk_box_append(GTK_BOX(content), grid);
GString *attachments = g_string_new(NULL);
GString *texts = g_string_new(NULL);
GString *metadata = g_string_new(NULL);
GString *warnings = g_string_new(NULL);
eml_analysis_dialog_append_attachment_summary(attachments, result);
eml_analysis_dialog_append_document_summary(
texts, metadata, warnings, result);
for (guint index = 0; result->warnings != NULL &&
index < result->warnings->len; index++)
g_string_append_printf(warnings, "%s\n",
(char *) g_ptr_array_index(result->warnings, index));
for (guint index = 0; result->bank_proposals != NULL &&
index < result->bank_proposals->len; index++)
{
BankProposal *proposal = g_ptr_array_index(
result->bank_proposals, index);
if (!proposal->is_iban_valid ||
proposal->suggested_ocr_fix != NULL)
g_string_append_printf(warnings,
"Proposition bancaire invalide ou corrigée, "
"désélectionnée : %s\n",
proposal->raw_iban != NULL ? proposal->raw_iban : "(vide)");
}
GtkWidget *details = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(details), 6);
gtk_grid_set_column_spacing(GTK_GRID(details), 12);
eml_analysis_dialog_add_field(GTK_GRID(details), 0,
"Pièces jointes", attachments->str);
eml_analysis_dialog_add_field(GTK_GRID(details), 1,
"Texte PDF et OCR", texts->str);
eml_analysis_dialog_add_field(GTK_GRID(details), 2,
"Métadonnées", metadata->str);
eml_analysis_dialog_add_field(GTK_GRID(details), 3,
"Avertissements", warnings->str);
gtk_box_append(GTK_BOX(content), details);
state->proposals_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
eml_analysis_dialog_add_observations(state->proposals_box,
eml_analysis_get_observations(result->analysis));
GPtrArray *valid_ibans = g_ptr_array_new();
for (guint index = 0; result->bank_proposals != NULL &&
index < result->bank_proposals->len; index++)
{
BankProposal *proposal = g_ptr_array_index(
result->bank_proposals, index);
if (proposal->is_iban_valid && proposal->normalized_iban != NULL &&
proposal->suggested_ocr_fix == NULL)
g_ptr_array_add(valid_ibans, proposal->normalized_iban);
}
eml_analysis_dialog_add_proposals(state->proposals_box, "iban",
"IBAN valide", valid_ibans);
g_ptr_array_unref(valid_ibans);
gtk_box_append(GTK_BOX(content), gtk_label_new(
"Propositions à intégrer explicitement"));
gtk_box_append(GTK_BOX(content), state->proposals_box);
eml_analysis_dialog_bind_summary(state, content);
GtkWidget *scroll = gtk_scrolled_window_new();
gtk_widget_set_vexpand(scroll, TRUE);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), content);
gtk_box_append(GTK_BOX(outer), scroll);
GtkWidget *actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_halign(actions, GTK_ALIGN_END);
GtkWidget *close = gtk_button_new_with_label("Rejeter et fermer");
GtkWidget *integrate = gtk_button_new_with_label(
"Intégrer les éléments sélectionnés");
gtk_widget_add_css_class(integrate, "suggested-action");
g_signal_connect(close, "clicked",
G_CALLBACK(eml_analysis_dialog_on_close), state);
g_signal_connect(integrate, "clicked",
G_CALLBACK(eml_analysis_dialog_on_integrate), state);
gtk_box_append(GTK_BOX(actions), close);
gtk_box_append(GTK_BOX(actions), integrate);
gtk_box_append(GTK_BOX(outer), actions);
gtk_window_set_child(state->window, outer);
g_signal_connect(state->window, "close-request",
G_CALLBACK(eml_analysis_dialog_on_window_close), state);
g_object_set_data_full(G_OBJECT(state->window), "eml-dialog-state",
state, eml_analysis_dialog_state_free);
labfy_dialog_present(state->window);
g_free(received);
g_string_free(attachments, TRUE);
g_string_free(texts, TRUE);
g_string_free(metadata, TRUE);
g_string_free(warnings, TRUE);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,691 +0,0 @@
/******************************************************************************
* @file evidence_metadata_dialog.c
* @brief Dialogue de modification des métadonnées éditables d'une preuve.
******************************************************************************/
#include "views/evidence_metadata_dialog.h"
#include "views/dialog_geometry.h"
#include "models/evidence_type.h"
#include "dao/identity_ocr_dao.h"
#include "dao/identity_traceability_dao.h"
#include "dao/entity_dao.h"
#include "views/evidence_identity_ocr_dialog.h"
#include "views/person_vocabulary_adapter.h"
#include "views/document_authenticity_editor.h"
#include "views/document_identity_misuse_editor.h"
#include "widgets/ocr_provenance_overlay.h"
struct EvidenceMetadataDialogResult
{
char *type_identifier;
char *source;
char *description;
};
typedef struct
{
GtkWindow *window;
GtkDropDown *type_dropdown;
GtkEditable *source_entry;
GtkTextBuffer *description_buffer;
GtkStringList *type_labels;
GPtrArray *type_identifiers;
EvidenceMetadataDialogCallback callback;
EvidenceMetadataDialogAnalyzeCallback analyze_callback;
char *evidence_identifier;
char *investigation_root;
GPtrArray *ocr_runs;
GPtrArray *ocr_documents;
GPtrArray *ocr_run_identifiers;
GtkStringList *ocr_run_labels;
GtkDropDown *ocr_run_dropdown;
GtkLabel *ocr_summary;
GtkTextBuffer *ocr_raw_buffer;
GtkTextBuffer *ocr_corrected_buffer;
GtkBox *ocr_details;
GtkButton *ocr_revise_button;
OcrProvenanceOverlay *ocr_overlay;
gulong ocr_selection_handler;
guint64 ocr_generation;
gpointer user_data;
gboolean completed;
DocumentAuthenticityEditor *authenticity_editor;
DocumentIdentityMisuseEditor *identity_misuse_editor;
} EvidenceMetadataDialogContext;
static void evidence_metadata_dialog_complete(
EvidenceMetadataDialogContext *context,
EvidenceMetadataDialogResult *result
);
/** @brief Ferme le dialogue avec Échap sans enregistrer. */
static gboolean evidence_metadata_dialog_on_key_pressed(
GtkEventControllerKey *controller, guint keyval, guint keycode,
GdkModifierType state, gpointer user_data
)
{
(void) controller;
(void) keycode;
(void) state;
if (keyval != GDK_KEY_Escape) return FALSE;
evidence_metadata_dialog_complete(user_data, NULL);
return TRUE;
}
/** @brief Copie un texte facultatif et transforme une chaîne vide en NULL. */
static char *evidence_metadata_dialog_copy_optional(const char *text)
{
char *copy = text != NULL ? g_strdup(text) : NULL;
if (copy != NULL) g_strstrip(copy);
if (copy != NULL && copy[0] == '\0') g_clear_pointer(&copy, g_free);
return copy;
}
/** @brief Libère le contexte détenu par la fenêtre. */
static void evidence_metadata_dialog_context_free(gpointer data)
{
EvidenceMetadataDialogContext *context = data;
if (context == NULL) return;
g_clear_pointer(&context->type_identifiers, g_ptr_array_unref);
g_clear_object(&context->type_labels);
g_clear_pointer(&context->ocr_runs, g_ptr_array_unref);
g_clear_pointer(&context->ocr_documents, g_ptr_array_unref);
g_clear_pointer(&context->ocr_run_identifiers, g_ptr_array_unref);
g_clear_object(&context->ocr_run_labels);
document_authenticity_editor_free(context->authenticity_editor);
document_identity_misuse_editor_free(context->identity_misuse_editor);
g_free(context->investigation_root);
g_free(context->evidence_identifier);
g_free(context);
}
static void evidence_metadata_dialog_on_analyze(
GtkButton *button, gpointer user_data)
{
EvidenceMetadataDialogContext *context = user_data;
(void) button;
if (context != NULL && context->analyze_callback != NULL) {
gboolean revise=GPOINTER_TO_INT(g_object_get_data(
G_OBJECT(button),"revise-existing"));
const char *run_identifier = NULL;
if (revise && context->ocr_run_dropdown != NULL) {
guint selected = gtk_drop_down_get_selected(
context->ocr_run_dropdown);
if (selected < context->ocr_run_identifiers->len)
run_identifier = g_ptr_array_index(
context->ocr_run_identifiers, selected);
}
if (!revise || run_identifier != NULL)
context->analyze_callback(context->window,
context->evidence_identifier, revise, run_identifier,
context->user_data);
evidence_metadata_dialog_complete(context, NULL);
}
}
/** @brief Détruit la fenêtre hors de la pile du signal GTK courant. */
static gboolean evidence_metadata_dialog_destroy_idle(gpointer data)
{
GtkWindow *window = GTK_WINDOW(data);
gtk_window_destroy(window);
g_object_unref(window);
return G_SOURCE_REMOVE;
}
/** @brief Termine le dialogue et transmet éventuellement un résultat. */
static void evidence_metadata_dialog_complete(
EvidenceMetadataDialogContext *context,
EvidenceMetadataDialogResult *result
)
{
EvidenceMetadataDialogCallback callback = NULL;
gpointer callback_data = NULL;
if (context == NULL || context->completed) return;
context->completed = TRUE;
callback = context->callback;
callback_data = context->user_data;
gtk_widget_set_visible(GTK_WIDGET(context->window), FALSE);
g_idle_add(
evidence_metadata_dialog_destroy_idle,
g_object_ref(context->window)
);
if (callback != NULL) callback(result, callback_data);
else evidence_metadata_dialog_result_free(result);
}
/** @brief Annule la modification. */
static void evidence_metadata_dialog_on_cancel(
GtkButton *button, gpointer user_data
)
{
(void) button;
evidence_metadata_dialog_complete(user_data, NULL);
}
/** @brief Traite la fermeture native comme une annulation explicite. */
static gboolean evidence_metadata_dialog_on_close_request(
GtkWindow *window, gpointer user_data
)
{
(void) window;
evidence_metadata_dialog_complete(user_data, NULL);
return TRUE;
}
/** @brief Construit et transmet les valeurs modifiées. */
static void evidence_metadata_dialog_on_save(
GtkButton *button, gpointer user_data
)
{
EvidenceMetadataDialogContext *context = user_data;
EvidenceMetadataDialogResult *result = NULL;
GtkTextIter start; GtkTextIter end; char *description = NULL;
guint selected = 0U;
(void) button;
if (context == NULL) return;
selected = gtk_drop_down_get_selected(context->type_dropdown);
if (selected >= context->type_identifiers->len) return;
gtk_text_buffer_get_bounds(context->description_buffer, &start, &end);
description = gtk_text_buffer_get_text(
context->description_buffer, &start, &end, FALSE);
result = g_new0(EvidenceMetadataDialogResult, 1);
result->type_identifier = g_strdup(
g_ptr_array_index(context->type_identifiers, selected));
result->source = evidence_metadata_dialog_copy_optional(
gtk_editable_get_text(context->source_entry));
result->description = evidence_metadata_dialog_copy_optional(description);
g_free(description);
evidence_metadata_dialog_complete(context, result);
}
static void evidence_metadata_dialog_clear_box(GtkBox *box)
{
GtkWidget *child = gtk_widget_get_first_child(GTK_WIDGET(box));
while (child != NULL) {
GtkWidget *next = gtk_widget_get_next_sibling(child);
gtk_box_remove(box, child);
child = next;
}
}
static IdentityFieldObservation *evidence_metadata_dialog_field_model(
const IdentityFieldObservationRecord *record)
{
IdentitySourceBox box = {
.page = (gint) record->page_number,
.x = (gint) record->source_x,
.y = (gint) record->source_y,
.width = (gint) record->source_width,
.height = (gint) record->source_height,
.image_width = (gint) record->source_image_width,
.image_height = (gint) record->source_image_height,
.available = record->has_source_box
};
IdentityFieldObservation *field = record->raw_value != NULL
? identity_field_observation_new(record->field_code,
record->raw_value,
record->has_confidence ? record->confidence : -1.0,
&box, (guint) record->display_order)
: identity_field_observation_new_manual(record->field_code,
record->corrected_value, (guint) record->display_order);
if (field != NULL)
identity_field_observation_set_origin(field, record->origin);
return field;
}
static void evidence_metadata_dialog_show_region(
GtkButton *button, gpointer user_data)
{
EvidenceMetadataDialogContext *context = user_data;
IdentityFieldObservationRecord *record = g_object_get_data(
G_OBJECT(button), "ocr-field-record");
IdentityFieldObservation *field =
evidence_metadata_dialog_field_model(record);
context->ocr_generation++;
ocr_provenance_overlay_set_field(
context->ocr_overlay, field, context->ocr_generation);
identity_field_observation_free(field);
}
static void evidence_metadata_dialog_render_ocr(
EvidenceMetadataDialogContext *context)
{
guint selected = gtk_drop_down_get_selected(context->ocr_run_dropdown);
evidence_metadata_dialog_clear_box(context->ocr_details);
context->ocr_generation++;
ocr_provenance_overlay_clear(
context->ocr_overlay, context->ocr_generation);
gtk_text_buffer_set_text(context->ocr_raw_buffer, "", -1);
gtk_text_buffer_set_text(context->ocr_corrected_buffer, "", -1);
gtk_label_set_text(context->ocr_summary,
"Aucune analyse OCR sélectionnée.");
if (context->ocr_revise_button != NULL)
gtk_widget_set_sensitive(
GTK_WIDGET(context->ocr_revise_button), FALSE);
if (selected >= context->ocr_runs->len ||
selected >= context->ocr_run_identifiers->len) return;
IdentityOcrRunRecord *run = g_ptr_array_index(
context->ocr_runs, selected);
ocr_provenance_overlay_set_page(context->ocr_overlay,
(guint) run->page_number, context->ocr_generation);
const char *selected_identifier = g_ptr_array_index(
context->ocr_run_identifiers, selected);
if (g_strcmp0(run->id, selected_identifier) != 0) return;
char *summary = g_strdup_printf(
"Run actif : %s\nUTC : %s\nMoteur : %s %s\n"
"Langues : %s\nDocument : %s — %s — page %" G_GINT64_FORMAT
"\nStatut : %s\nSHA-256 preuve : %s\n"
"Artefact texte : %s\nSHA-256 texte : %s\n"
"Artefact TSV : %s\nSHA-256 TSV : %s",
run->id, run->executed_at, run->engine, run->engine_version,
run->requested_languages, run->document_type, run->document_side,
run->page_number, run->status, run->expected_sha256,
run->text_relative_path, run->text_sha256,
run->tsv_relative_path, run->tsv_sha256);
gtk_label_set_text(context->ocr_summary, summary);
g_free(summary);
char *raw_path = g_build_filename(context->investigation_root,
run->text_relative_path, NULL);
char *raw = NULL;
if (!g_file_get_contents(raw_path, &raw, NULL, NULL))
raw = g_strdup("Artefact OCR brut indisponible");
gtk_text_buffer_set_text(context->ocr_raw_buffer, raw, -1);
gtk_text_buffer_set_text(context->ocr_corrected_buffer,
run->corrected_transcription != NULL
? run->corrected_transcription
: "Aucune transcription corrigée", -1);
g_free(raw_path);
g_free(raw);
IdentityDocumentObservationRecord *document = NULL;
for (guint index = 0; index < context->ocr_documents->len; index++) {
IdentityDocumentObservationRecord *candidate =
g_ptr_array_index(context->ocr_documents, index);
if (g_strcmp0(candidate->ocr_run_id, run->id) == 0) {
document = candidate;
break;
}
}
if (document == NULL) {
gtk_box_append(context->ocr_details,
gtk_label_new("Observation OCR persistée indisponible."));
return;
}
char *description = g_strdup_printf(
"Observation %s\nPersonne liée : %s\nDocument : %s — %s — "
"page %" G_GINT64_FORMAT "\nÉtat : %s\nNotes : %s",
document->id,
document->person_id != NULL ? document->person_id
: "Personne indisponible",
document->document_type, document->document_side,
document->page_number, document->review_state,
document->factual_notes != NULL ? document->factual_notes
: "Aucune note");
GtkWidget *document_label = gtk_label_new(description);
gtk_widget_set_name(document_label, "evidence-ocr-observation");
gtk_label_set_wrap(GTK_LABEL(document_label), TRUE);
gtk_label_set_xalign(GTK_LABEL(document_label), 0.0f);
gtk_box_append(context->ocr_details, document_label);
g_free(description);
IdentityOcrDao *dao = g_object_get_data(
G_OBJECT(context->window), "identity-ocr-dao");
GError *error = NULL;
GPtrArray *fields = identity_ocr_dao_list_fields_by_document(
dao, document->id, &error);
for (guint index = 0; fields != NULL && index < fields->len; index++) {
IdentityFieldObservationRecord *field =
g_ptr_array_index(fields, index);
char *field_text = g_strdup_printf(
"%s — brut : %s — corrigé : %s — statut : %s — "
"origine : %s — page %" G_GINT64_FORMAT,
field->field_code,
field->raw_value != NULL ? field->raw_value : "absent",
field->corrected_value != NULL
? field->corrected_value : "absente",
field->review_status, field->origin, field->page_number);
GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget *label = gtk_label_new(field_text);
gtk_label_set_wrap(GTK_LABEL(label), TRUE);
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_widget_set_hexpand(label, TRUE);
gtk_box_append(GTK_BOX(row), label);
GtkWidget *show = gtk_button_new_with_label("Voir la zone");
gtk_widget_set_sensitive(show, field->has_source_box);
g_object_set_data(G_OBJECT(show), "ocr-field-record", field);
g_signal_connect(show, "clicked",
G_CALLBACK(evidence_metadata_dialog_show_region), context);
gtk_box_append(GTK_BOX(row), show);
gtk_box_append(context->ocr_details, row);
g_free(field_text);
}
g_object_set_data_full(G_OBJECT(context->ocr_details),
"active-ocr-fields", fields, fields != NULL
? (GDestroyNotify) g_ptr_array_unref : NULL);
g_clear_error(&error);
if (context->ocr_revise_button != NULL)
gtk_widget_set_sensitive(
GTK_WIDGET(context->ocr_revise_button), TRUE);
}
static void evidence_metadata_dialog_ocr_selected(
GObject *object, GParamSpec *spec, gpointer user_data)
{
(void) object;
(void) spec;
evidence_metadata_dialog_render_ocr(user_data);
}
static GtkWidget *evidence_metadata_dialog_build_ocr(
EvidenceMetadataDialogContext *context, Database *database,
const char *root, const EvidenceRecord *record)
{
if (database == NULL || root == NULL) return NULL;
GError *error = NULL;
IdentityOcrDao *dao = identity_ocr_dao_new(database);
context->ocr_runs = identity_ocr_dao_list_runs_by_evidence(
dao, evidence_record_get_identifier(record), &error);
context->ocr_documents = identity_ocr_dao_list_documents_by_evidence(
dao, evidence_record_get_identifier(record), &error);
if (context->ocr_runs == NULL || context->ocr_runs->len == 0 ||
context->ocr_documents == NULL) {
g_clear_error(&error);
identity_ocr_dao_free(dao);
return NULL;
}
g_object_set_data_full(G_OBJECT(context->window), "identity-ocr-dao",
dao, (GDestroyNotify) identity_ocr_dao_free);
context->investigation_root = g_strdup(root);
context->ocr_run_identifiers =
g_ptr_array_new_with_free_func(g_free);
context->ocr_run_labels = gtk_string_list_new(NULL);
for (guint index = 0; index < context->ocr_runs->len; index++) {
IdentityOcrRunRecord *run =
g_ptr_array_index(context->ocr_runs, index);
char *short_id = g_strndup(run->id, 8);
char *label = g_strdup_printf(
"%s — %s — %s — %s/%s — p.%" G_GINT64_FORMAT
" — %s — %s",
run->executed_at, run->engine, run->requested_languages,
run->document_type, run->document_side, run->page_number,
run->status, short_id);
gtk_string_list_append(context->ocr_run_labels, label);
g_ptr_array_add(context->ocr_run_identifiers, g_strdup(run->id));
g_free(short_id);
g_free(label);
}
GtkWidget *section = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_name(section, "evidence-ocr-readonly");
gtk_box_append(GTK_BOX(section), gtk_label_new(
"Analyse OCR d’identité"));
context->ocr_run_dropdown = GTK_DROP_DOWN(gtk_drop_down_new(
G_LIST_MODEL(g_object_ref(context->ocr_run_labels)), NULL));
gtk_widget_set_name(GTK_WIDGET(context->ocr_run_dropdown),
"evidence-ocr-run-selector");
gtk_box_append(GTK_BOX(section),
GTK_WIDGET(context->ocr_run_dropdown));
context->ocr_summary = GTK_LABEL(gtk_label_new(NULL));
gtk_widget_set_name(GTK_WIDGET(context->ocr_summary),
"evidence-ocr-active-run");
gtk_label_set_wrap(context->ocr_summary, TRUE);
gtk_label_set_xalign(context->ocr_summary, 0.0f);
gtk_box_append(GTK_BOX(section), GTK_WIDGET(context->ocr_summary));
GtkWidget *raw = gtk_text_view_new();
gtk_widget_set_name(raw, "evidence-ocr-raw");
gtk_text_view_set_editable(GTK_TEXT_VIEW(raw), FALSE);
context->ocr_raw_buffer =
gtk_text_view_get_buffer(GTK_TEXT_VIEW(raw));
gtk_box_append(GTK_BOX(section), gtk_label_new("Texte OCR brut"));
gtk_box_append(GTK_BOX(section), raw);
GtkWidget *corrected = gtk_text_view_new();
gtk_widget_set_name(corrected, "evidence-ocr-corrected");
gtk_text_view_set_editable(GTK_TEXT_VIEW(corrected), FALSE);
context->ocr_corrected_buffer =
gtk_text_view_get_buffer(GTK_TEXT_VIEW(corrected));
gtk_box_append(GTK_BOX(section),
gtk_label_new("Transcription corrigée"));
gtk_box_append(GTK_BOX(section), corrected);
context->ocr_details = GTK_BOX(gtk_box_new(
GTK_ORIENTATION_VERTICAL, 6));
gtk_widget_set_name(GTK_WIDGET(context->ocr_details),
"evidence-ocr-details");
gtk_box_append(GTK_BOX(section), GTK_WIDGET(context->ocr_details));
context->ocr_overlay = ocr_provenance_overlay_new();
g_object_set_data_full(G_OBJECT(section), "ocr-readonly-overlay",
context->ocr_overlay, (GDestroyNotify) ocr_provenance_overlay_free);
gtk_box_append(GTK_BOX(section),
ocr_provenance_overlay_get_widget(context->ocr_overlay));
context->ocr_selection_handler = g_signal_connect(
context->ocr_run_dropdown, "notify::selected",
G_CALLBACK(evidence_metadata_dialog_ocr_selected), context);
/*
* Politique d'ouverture et de rafraîchissement : le dernier run inséré
* devient actif. La révision, elle, lit toujours l'UUID de la sélection
* courante et ne déduit jamais un run de cette politique.
*/
gtk_drop_down_set_selected(context->ocr_run_dropdown,
context->ocr_runs->len - 1);
return section;
}
gboolean evidence_metadata_dialog_present_with_ocr(
GtkWindow *parent, const EvidenceRecord *record,
const GPtrArray *evidence_types, Database *database,
const char *investigation_root,
EvidenceMetadataDialogAnalyzeCallback analyze_callback,
EvidenceMetadataDialogCallback callback, gpointer user_data
)
{
EvidenceMetadataDialogContext *context = NULL;
GtkStringList *labels = NULL; GtkWidget *main_box = NULL;
GtkWidget *description_view = NULL; GtkWidget *description_scroll = NULL;
GtkWidget *buttons = NULL; GtkWidget *cancel = NULL; GtkWidget *save = NULL;
guint selected = 0U;
if (record == NULL || evidence_types == NULL || evidence_types->len == 0U ||
callback == NULL) return FALSE;
context = g_new0(EvidenceMetadataDialogContext, 1);
context->window = GTK_WINDOW(gtk_window_new());
context->callback = callback; context->user_data = user_data;
context->analyze_callback = analyze_callback;
context->evidence_identifier =
g_strdup(evidence_record_get_identifier(record));
context->type_identifiers = g_ptr_array_new_with_free_func(g_free);
labels = gtk_string_list_new(NULL);
context->type_labels = labels;
for (guint index = 0U; index < evidence_types->len; index++)
{
const EvidenceType *type = g_ptr_array_index((GPtrArray *) evidence_types, index);
gtk_string_list_append(labels, evidence_type_get_label(type));
g_ptr_array_add(context->type_identifiers,
g_strdup(evidence_type_get_code(type)));
if (g_strcmp0(evidence_type_get_code(type),
evidence_record_get_type_identifier(record)) == 0) selected = index;
}
gtk_window_set_title(context->window, "Modifier les métadonnées");
labfy_dialog_prepare(context->window, parent, TRUE, TRUE);
if (parent != NULL) {
gtk_window_set_application(context->window,
gtk_window_get_application(parent));
}
g_object_set_data_full(G_OBJECT(context->window), "evidence-metadata-context",
context, evidence_metadata_dialog_context_free);
main_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
gtk_widget_set_margin_start(main_box, 18); gtk_widget_set_margin_end(main_box, 18);
gtk_widget_set_margin_top(main_box, 18); gtk_widget_set_margin_bottom(main_box, 18);
gtk_box_append(GTK_BOX(main_box), gtk_label_new(
"Le fichier, son UUID, sa taille, sa date d'import et son SHA-256 resteront inchangés."));
context->type_dropdown = GTK_DROP_DOWN(gtk_drop_down_new(
G_LIST_MODEL(g_object_ref(labels)), NULL));
gtk_drop_down_set_selected(context->type_dropdown, selected);
gtk_box_append(GTK_BOX(main_box), gtk_label_new("Type de preuve"));
gtk_box_append(GTK_BOX(main_box), GTK_WIDGET(context->type_dropdown));
context->source_entry = GTK_EDITABLE(gtk_entry_new());
gtk_editable_set_text(context->source_entry,
evidence_record_get_source(record) != NULL
? evidence_record_get_source(record) : "");
gtk_box_append(GTK_BOX(main_box), gtk_label_new("Source"));
gtk_box_append(GTK_BOX(main_box), GTK_WIDGET(context->source_entry));
description_view = gtk_text_view_new();
context->description_buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(description_view));
gtk_text_buffer_set_text(context->description_buffer,
evidence_record_get_description(record) != NULL
? evidence_record_get_description(record) : "", -1);
gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(description_view), GTK_WRAP_WORD_CHAR);
description_scroll = gtk_scrolled_window_new();
gtk_widget_set_vexpand(description_scroll, TRUE);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(description_scroll), description_view);
gtk_box_append(GTK_BOX(main_box), gtk_label_new("Description"));
gtk_box_append(GTK_BOX(main_box), description_scroll);
GtkWidget *ocr = evidence_metadata_dialog_build_ocr(
context, database, investigation_root, record);
if (ocr != NULL) {
GtkWidget *ocr_scroll = gtk_scrolled_window_new();
gtk_widget_set_size_request(ocr_scroll, -1, 260);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(ocr_scroll), ocr);
gtk_box_append(GTK_BOX(main_box), ocr_scroll);
}
if (database != NULL) {
context->authenticity_editor = document_authenticity_editor_new(
database, evidence_record_get_identifier(record));
if (context->authenticity_editor != NULL) {
GtkWidget *auth_scroll = gtk_scrolled_window_new();
gtk_widget_set_size_request(auth_scroll, -1, 300);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(auth_scroll),
document_authenticity_editor_get_widget(context->authenticity_editor));
gtk_box_append(GTK_BOX(main_box), auth_scroll);
}
context->identity_misuse_editor = document_identity_misuse_editor_new(
database, evidence_record_get_identifier(record));
if (context->identity_misuse_editor != NULL) {
GtkWidget *misuse_scroll = gtk_scrolled_window_new();
gtk_widget_set_size_request(misuse_scroll, -1, 300);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(misuse_scroll),
document_identity_misuse_editor_get_widget(
context->identity_misuse_editor));
gtk_box_append(GTK_BOX(main_box), misuse_scroll);
}
IdentityTraceabilityDao *trace_dao = identity_traceability_dao_new(database);
if (trace_dao != NULL) {
GPtrArray *relations = identity_traceability_dao_list_factual_relations_by_evidence(
trace_dao, evidence_record_get_identifier(record), NULL);
if (relations != NULL && relations->len > 0) {
GtkWidget *rel_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_box_append(GTK_BOX(rel_box), gtk_label_new("Relations factuelles avec les personnes"));
EntityDao *entity_dao = entity_dao_new(database, NULL);
for (guint i = 0; i < relations->len; i++) {
PersonEvidenceFactualRelation *rel = g_ptr_array_index(relations, i);
const char *person_id = person_evidence_factual_relation_get_person_identifier(rel);
const char *rel_type = person_vocabulary_adapter_relation_label(
person_evidence_factual_relation_get_relation_type(rel));
const char *note = person_evidence_factual_relation_get_factual_note(rel);
char *designation = NULL;
if (entity_dao != NULL) {
EntityRecord *person = entity_dao_find_by_identifier(entity_dao, person_id, NULL);
if (person != NULL) {
const char *label = entity_record_get_label(person);
const char *value = entity_record_get_value(person);
designation = g_strdup(label && label[0] ? label : (value ? value : ""));
entity_record_free(person);
}
}
if (designation == NULL) designation = g_strdup("Personne inconnue");
const char *run = person_evidence_factual_relation_get_ocr_run_identifier(rel);
char *text = g_strdup_printf("• %s : %s\n Date : %s — Origine : %s%s%s",
designation, rel_type,
person_evidence_factual_relation_get_observed_at(rel),
person_evidence_factual_relation_get_origin(rel),
run ? " — OcrRun : " : "", run ? run : "");
if (note && note[0]) {
char *tmp = g_strdup_printf("%s\n Note : %s", text, note);
g_free(text);
text = tmp;
}
GtkWidget *label = gtk_label_new(text);
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_box_append(GTK_BOX(rel_box), label);
g_free(text);
g_free(designation);
}
if (entity_dao != NULL) entity_dao_free(entity_dao);
GtkWidget *rel_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(rel_scroll), rel_box);
gtk_widget_set_size_request(rel_scroll, -1, 100);
gtk_box_append(GTK_BOX(main_box), rel_scroll);
g_ptr_array_unref(relations);
}
identity_traceability_dao_free(trace_dao);
}
}
buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_halign(buttons, GTK_ALIGN_END);
cancel = gtk_button_new_with_label("Annuler");
save = gtk_button_new_with_label("Enregistrer");
if (analyze_callback != NULL && ocr != NULL) {
GtkWidget *revise = gtk_button_new_with_label(
"Réviser l’analyse OCR");
context->ocr_revise_button = GTK_BUTTON(revise);
gtk_widget_set_name(revise, "revise-existing-identity-ocr");
g_object_set_data(G_OBJECT(revise),"revise-existing",
GINT_TO_POINTER(TRUE));
g_signal_connect(revise, "clicked",
G_CALLBACK(evidence_metadata_dialog_on_analyze), context);
gtk_box_append(GTK_BOX(buttons), revise);
evidence_metadata_dialog_render_ocr(context);
}
if (analyze_callback != NULL &&
evidence_identity_ocr_dialog_file_is_compatible(
evidence_record_get_original_name(record))) {
GtkWidget *analyze = gtk_button_new_with_label(
"Relancer une nouvelle analyse OCR");
gtk_widget_set_name(analyze, "analyze-existing-identity");
g_signal_connect(analyze, "clicked",
G_CALLBACK(evidence_metadata_dialog_on_analyze), context);
gtk_box_append(GTK_BOX(buttons), analyze);
}
gtk_widget_add_css_class(save, "suggested-action");
g_signal_connect(cancel, "clicked", G_CALLBACK(evidence_metadata_dialog_on_cancel), context);
g_signal_connect(save, "clicked", G_CALLBACK(evidence_metadata_dialog_on_save), context);
g_signal_connect(context->window, "close-request",
G_CALLBACK(evidence_metadata_dialog_on_close_request), context);
{
GtkEventController *keys = gtk_event_controller_key_new();
g_signal_connect(keys, "key-pressed",
G_CALLBACK(evidence_metadata_dialog_on_key_pressed), context);
gtk_widget_add_controller(GTK_WIDGET(context->window), keys);
}
gtk_box_append(GTK_BOX(buttons), cancel); gtk_box_append(GTK_BOX(buttons), save);
gtk_box_append(GTK_BOX(main_box), buttons);
gtk_window_set_child(context->window, main_box);
labfy_dialog_present(context->window);
return TRUE;
}
gboolean evidence_metadata_dialog_present(
GtkWindow *parent, const EvidenceRecord *record,
const GPtrArray *evidence_types, EvidenceMetadataDialogCallback callback,
gpointer user_data)
{
return evidence_metadata_dialog_present_with_ocr(
parent, record, evidence_types, NULL, NULL, NULL,
callback, user_data);
}
void evidence_metadata_dialog_result_free(EvidenceMetadataDialogResult *result)
{
if (result == NULL) return;
g_free(result->type_identifier); g_free(result->source);
g_free(result->description); g_free(result);
}
const char *evidence_metadata_dialog_result_get_type_identifier(
const EvidenceMetadataDialogResult *result)
{ return result != NULL ? result->type_identifier : NULL; }
const char *evidence_metadata_dialog_result_get_source(
const EvidenceMetadataDialogResult *result)
{ return result != NULL ? result->source : NULL; }
const char *evidence_metadata_dialog_result_get_description(
const EvidenceMetadataDialogResult *result)
{ return result != NULL ? result->description : NULL; }

View file

@ -1,298 +0,0 @@
/******************************************************************************
* @file file_dialog.c
* @brief Implémentation du dialogue de sélection d’un fichier de preuve.
******************************************************************************/
#include "views/file_dialog.h"
#include <gio/gio.h>
/**
* @brief Contexte conservé pendant l’opération asynchrone.
*/
typedef struct
{
FileDialogCallback callback;
gpointer user_data;
} FileDialogContext;
/** @brief Contexte conservé pendant une sélection multiple. */
typedef struct
{
FileDialogMultipleCallback callback;
gpointer user_data;
} FileDialogMultipleContext;
/**
* @brief Libère le contexte du dialogue.
*/
static void file_dialog_context_free(
FileDialogContext *context
)
{
g_free(
context
);
}
/**
* @brief Transmet le résultat au code appelant.
*/
static void file_dialog_emit_result(
FileDialogContext *context,
const char *file_path,
const GError *error
)
{
if (context == NULL ||
context->callback == NULL)
{
return;
}
context->callback(
file_path,
error,
context->user_data
);
}
/**
* @brief Traite le résultat du sélecteur de fichier.
*/
static void file_dialog_on_file_selected(
GObject *source_object,
GAsyncResult *result,
gpointer user_data
)
{
GtkFileDialog *dialog =
GTK_FILE_DIALOG(
source_object
);
FileDialogContext *context =
user_data;
GFile *file = NULL;
char *file_path = NULL;
GError *error = NULL;
GError *path_error = NULL;
if (context == NULL)
{
return;
}
file =
gtk_file_dialog_open_finish(
dialog,
result,
&error
);
if (file == NULL)
{
/*
* Fermer volontairement le dialogue n’est pas une erreur.
*/
if (error != NULL &&
g_error_matches(
error,
GTK_DIALOG_ERROR,
GTK_DIALOG_ERROR_DISMISSED
))
{
file_dialog_emit_result(
context,
NULL,
NULL
);
}
else
{
file_dialog_emit_result(
context,
NULL,
error
);
}
g_clear_error(
&error
);
file_dialog_context_free(
context
);
return;
}
file_path =
g_file_get_path(
file
);
/*
* g_file_get_path() retourne NULL pour une ressource qui ne
* correspond pas à un fichier local.
*/
if (file_path == NULL)
{
g_set_error_literal(
&path_error,
G_IO_ERROR,
G_IO_ERROR_NOT_SUPPORTED,
"Le fichier sélectionné ne possède pas de chemin local."
);
file_dialog_emit_result(
context,
NULL,
path_error
);
g_clear_error(
&path_error
);
g_object_unref(
file
);
file_dialog_context_free(
context
);
return;
}
file_dialog_emit_result(
context,
file_path,
NULL
);
g_free(
file_path
);
g_object_unref(
file
);
file_dialog_context_free(
context
);
}
void file_dialog_select_file(
GtkWindow *parent,
FileDialogCallback callback,
gpointer user_data
)
{
GtkFileDialog *dialog = NULL;
FileDialogContext *context = NULL;
if (callback == NULL)
{
return;
}
context =
g_new0(
FileDialogContext,
1
);
context->callback =
callback;
context->user_data =
user_data;
dialog =
gtk_file_dialog_new();
gtk_file_dialog_set_title(
dialog,
"Sélectionner un fichier de preuve"
);
gtk_file_dialog_set_modal(
dialog,
TRUE
);
gtk_file_dialog_open(
dialog,
parent,
NULL,
file_dialog_on_file_selected,
context
);
g_object_unref(
dialog
);
}
/** @brief Traite le résultat du sélecteur multiple. */
static void file_dialog_on_files_selected(
GObject *source_object, GAsyncResult *result, gpointer user_data
)
{
FileDialogMultipleContext *context = user_data;
GListModel *files = NULL;
GPtrArray *paths = NULL;
GError *error = NULL;
files = gtk_file_dialog_open_multiple_finish(
GTK_FILE_DIALOG(source_object), result, &error);
if (files == NULL)
{
if (error != NULL && g_error_matches(
error, GTK_DIALOG_ERROR, GTK_DIALOG_ERROR_DISMISSED))
context->callback(NULL, NULL, context->user_data);
else context->callback(NULL, error, context->user_data);
g_clear_error(&error); g_free(context); return;
}
paths = g_ptr_array_new_with_free_func(g_free);
for (guint index = 0U; index < g_list_model_get_n_items(files); index++)
{
GFile *file = g_list_model_get_item(files, index);
char *path = g_file_get_path(file);
if (path == NULL)
{
g_set_error_literal(&error, G_IO_ERROR, G_IO_ERROR_NOT_SUPPORTED,
"Un fichier sélectionné ne possède pas de chemin local.");
g_object_unref(file); break;
}
g_ptr_array_add(paths, path); g_object_unref(file);
}
if (error != NULL) context->callback(NULL, error, context->user_data);
else context->callback(paths, NULL, context->user_data);
g_clear_error(&error); g_ptr_array_unref(paths); g_object_unref(files);
g_free(context);
}
void file_dialog_select_files(
GtkWindow *parent, FileDialogMultipleCallback callback, gpointer user_data
)
{
GtkFileDialog *dialog = NULL;
FileDialogMultipleContext *context = NULL;
if (callback == NULL) return;
context = g_new0(FileDialogMultipleContext, 1);
context->callback = callback; context->user_data = user_data;
dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Sélectionner des fichiers de preuve");
gtk_file_dialog_set_modal(dialog, TRUE);
gtk_file_dialog_open_multiple(dialog, parent, NULL,
file_dialog_on_files_selected, context);
g_object_unref(dialog);
}

View file

@ -1,122 +0,0 @@
/******************************************************************************
* @file folder_dialog.c
* @brief Implémentation du dialogue de sélection d'un dossier d'enquête.
******************************************************************************/
#include "views/folder_dialog.h"
#include <gio/gio.h>
/**
* @struct FolderDialogContext
* @brief Conserve les données nécessaires pendant l'opération asynchrone.
*
* Le dialogue GTK ne se termine pas immédiatement. Ce contexte permet donc
* de conserver le callback et ses données jusqu'à la fermeture du dialogue.
*/
typedef struct
{
FolderDialogCallback callback;
gpointer user_data;
} FolderDialogContext;
/**
* @brief Libère le contexte associé au dialogue.
*
* @param context Contexte à libérer.
*/
static void folder_dialog_context_free(FolderDialogContext *context)
{
g_free(context);
}
/**
* @brief Traite le résultat de la sélection du dossier.
*
* @param source_object Objet ayant lancé l'opération asynchrone.
* @param result Résultat transmis par GTK.
* @param user_data Contexte du dialogue.
*/
static void folder_dialog_on_folder_selected(
GObject *source_object,
GAsyncResult *result,
gpointer user_data
)
{
GtkFileDialog *dialog = GTK_FILE_DIALOG(source_object);
FolderDialogContext *context = user_data;
GError *error = NULL;
GFile *folder = NULL;
char *folder_path = NULL;
folder = gtk_file_dialog_select_folder_finish(dialog, result, &error);
if (folder == NULL)
{
/*
* Une annulation est un comportement normal de l'utilisateur.
* Elle est transmise au code appelant sous la forme d'un chemin NULL.
*/
if (error != NULL &&
!g_error_matches(error, GTK_DIALOG_ERROR, GTK_DIALOG_ERROR_DISMISSED))
{
g_warning(
"Impossible de sélectionner le dossier : %s",
error->message
);
}
if (context->callback != NULL)
{
context->callback(NULL, context->user_data);
}
g_clear_error(&error);
folder_dialog_context_free(context);
return;
}
folder_path = g_file_get_path(folder);
if (context->callback != NULL)
{
context->callback(folder_path, context->user_data);
}
g_free(folder_path);
g_object_unref(folder);
folder_dialog_context_free(context);
}
void folder_dialog_select_folder(
GtkWindow *parent,
FolderDialogCallback callback,
gpointer user_data
)
{
GtkFileDialog *dialog = NULL;
FolderDialogContext *context = NULL;
context = g_new0(FolderDialogContext, 1);
context->callback = callback;
context->user_data = user_data;
dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(
dialog,
"Sélectionner un dossier d'enquête"
);
gtk_file_dialog_set_modal(dialog, TRUE);
gtk_file_dialog_select_folder(
dialog,
parent,
NULL,
folder_dialog_on_folder_selected,
context
);
g_object_unref(dialog);
}

View file

@ -1,84 +0,0 @@
#include "views/identity_ocr_language_loader.h"
#include <gio/gio.h>
typedef struct {
char *executable;
GWeakRef window;
GWeakRef dropdown;
} IdentityLanguageJob;
static void identity_language_job_free(gpointer data)
{
IdentityLanguageJob *job = data;
if (job != NULL) {
g_free(job->executable);
g_weak_ref_clear(&job->window);
g_weak_ref_clear(&job->dropdown);
g_free(job);
}
}
static void identity_languages_worker(GTask *task, gpointer source,
gpointer task_data, GCancellable *cancellable)
{
(void)source; (void)cancellable;
IdentityLanguageJob *job = task_data;
gchar *stdout_text = NULL;
gboolean success = g_spawn_command_line_sync(
g_strdup_printf("'%s' --list-langs", job->executable),
&stdout_text, NULL, NULL, NULL);
if (success && stdout_text != NULL) {
GPtrArray *langs = g_ptr_array_new_with_free_func(g_free);
gchar **lines = g_strsplit(stdout_text, "\n", -1);
for (gsize i = 1; lines[i] != NULL; i++) {
gchar *lang = g_strstrip(g_strdup(lines[i]));
if (lang[0] != '\0') g_ptr_array_add(langs, lang);
else g_free(lang);
}
g_strfreev(lines);
g_task_return_pointer(task, langs, (GDestroyNotify)g_ptr_array_unref);
} else {
g_task_return_pointer(task, NULL, NULL);
}
g_free(stdout_text);
}
static void identity_languages_completed(GObject *source,
GAsyncResult *result, gpointer data)
{
(void)source;
GTask *task = G_TASK(result);
IdentityLanguageJob *job = data;
GPtrArray *langs = g_task_propagate_pointer(task, NULL);
if (langs != NULL) {
GtkWindow *window = GTK_WINDOW(g_weak_ref_get(&job->window));
GtkDropDown *dropdown = GTK_DROP_DOWN(g_weak_ref_get(&job->dropdown));
if (window != NULL && dropdown != NULL) {
GtkStringList *list = gtk_string_list_new(NULL);
gtk_string_list_append(list, "Fra (français)");
for (guint i = 0; i < langs->len; i++) {
const char *lang = g_ptr_array_index(langs, i);
if (g_strcmp0(lang, "fra") != 0) {
gtk_string_list_append(list, lang);
}
}
gtk_drop_down_set_model(dropdown, G_LIST_MODEL(list));
}
if (window != NULL) g_object_unref(window);
if (dropdown != NULL) g_object_unref(dropdown);
g_ptr_array_unref(langs);
}
}
void identity_ocr_language_loader_start(GtkWindow *window, const char *tesseract_path, GtkDropDown *language_dropdown)
{
if (tesseract_path == NULL || window == NULL || language_dropdown == NULL) return;
IdentityLanguageJob *job = g_new0(IdentityLanguageJob, 1);
job->executable = g_strdup(tesseract_path);
g_weak_ref_init(&job->window, G_OBJECT(window));
g_weak_ref_init(&job->dropdown, G_OBJECT(language_dropdown));
GTask *task = g_task_new(NULL, NULL, identity_languages_completed, job);
g_task_set_task_data(task, job, identity_language_job_free);
g_task_run_in_thread(task, identity_languages_worker);
g_object_unref(task);
}

File diff suppressed because it is too large Load diff

View file

@ -1,198 +0,0 @@
/******************************************************************************
* @file manage_entity_evidence_dialog.c
* @brief Dialogue de sélection des preuves associées à une entité.
******************************************************************************/
#include "views/manage_entity_evidence_dialog.h"
#include "views/dialog_geometry.h"
#include "widgets/evidence_preview_widget.h"
#include <gio/gio.h>
typedef struct
{
char *identifier;
char *path;
char *relative_path;
char *sha256;
char *mime_type;
GtkCheckButton *check;
} EvidenceChoice;
typedef struct
{
GtkWindow *window;
EvidencePreviewWidget *preview;
char *root_path;
GPtrArray *choices;
ManageEntityEvidenceDialogCallback callback;
ManageEntityEvidenceImportCallback import_callback;
gpointer callback_data;
gboolean completed;
} ManageEvidenceState;
/** @brief Ferme le gestionnaire puis lance un import pour cette personne. */
static void manage_evidence_on_import(GtkButton *button, gpointer data)
{
ManageEvidenceState *state = data;
(void) button;
if (state == NULL || state->completed) return;
state->completed = TRUE;
if (state->import_callback != NULL)
state->import_callback(state->callback_data);
gtk_window_close(state->window);
}
/** @brief Libère une option privée. */
static void evidence_choice_free(gpointer data)
{
EvidenceChoice *choice = data;
if (choice == NULL) return;
g_free(choice->identifier); g_free(choice->path);
g_free(choice->relative_path); g_free(choice->sha256);
g_free(choice->mime_type); g_free(choice);
}
/** @brief Arrête et efface l'aperçu courant. */
static void manage_evidence_clear_preview(ManageEvidenceState *state)
{
if (state == NULL || state->preview == NULL) return;
evidence_preview_widget_clear(state->preview);
}
/** @brief Libère l'état du dialogue. */
static void manage_evidence_state_free(gpointer data)
{
ManageEvidenceState *state = data;
if (state == NULL) return;
manage_evidence_clear_preview(state);
g_clear_pointer(&state->preview, evidence_preview_widget_free);
g_ptr_array_unref(state->choices); g_free(state->root_path); g_free(state);
}
/** @brief Affiche l'aperçu de l'option manipulée. */
static void manage_evidence_on_toggled(GtkCheckButton *button, gpointer data)
{
ManageEvidenceState *state = data;
EvidenceChoice *choice = g_object_get_data(G_OBJECT(button), "choice");
if (state == NULL || choice == NULL || choice->path == NULL) return;
EvidencePreviewRequest *request = evidence_preview_request_new(
state->root_path, choice->identifier, choice->relative_path,
choice->sha256, choice->mime_type, 1U);
evidence_preview_widget_show(state->preview, request,
gtk_check_button_get_label(choice->check));
evidence_preview_request_free(request);
}
/** @brief Termine le dialogue en renvoyant la sélection. */
static void manage_evidence_on_save(GtkButton *button, gpointer data)
{
ManageEvidenceState *state = data;
GPtrArray *selected = g_ptr_array_new_with_free_func(g_free);
(void) button;
for (guint index = 0; index < state->choices->len; index++)
{
EvidenceChoice *choice = g_ptr_array_index(state->choices, index);
if (gtk_check_button_get_active(choice->check))
g_ptr_array_add(selected, g_strdup(choice->identifier));
}
state->completed = TRUE; state->callback(selected, state->callback_data);
gtk_window_close(state->window);
}
/** @brief Termine le dialogue par une annulation. */
static gboolean manage_evidence_on_close(GtkWindow *window, gpointer data)
{
ManageEvidenceState *state = data; (void) window;
if (!state->completed) state->callback(NULL, state->callback_data);
return FALSE;
}
gboolean manage_entity_evidence_dialog_present(GtkWindow *parent,
const GPtrArray *records, const GPtrArray *selected_ids,
const char *root_path, TaskManager *task_manager,
ManageEntityEvidenceDialogCallback callback,
gpointer callback_data, ManageEntityEvidenceImportCallback import_callback,
GError **error)
{
ManageEvidenceState *state = NULL; GtkWidget *root = NULL;
GtkWidget *content = NULL; GtkWidget *list = NULL; GtkWidget *scroll = NULL;
GtkWidget *save = NULL; GtkWidget *import = NULL; GtkWidget *actions = NULL;
g_return_val_if_fail(error == NULL || *error == NULL, FALSE);
if (!GTK_IS_WINDOW(parent) || records == NULL || root_path == NULL ||
task_manager == NULL ||
callback == NULL) return FALSE;
state = g_new0(ManageEvidenceState, 1);
state->choices = g_ptr_array_new_with_free_func(evidence_choice_free);
state->callback = callback; state->callback_data = callback_data;
state->root_path = g_strdup(root_path);
state->import_callback = import_callback;
state->window = GTK_WINDOW(gtk_window_new());
gtk_window_set_title(state->window, "Pièces jointes de la personne");
labfy_dialog_prepare(state->window, parent, TRUE, TRUE);
root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_margin_top(root, 16); gtk_widget_set_margin_bottom(root, 16);
gtk_widget_set_margin_start(root, 16); gtk_widget_set_margin_end(root, 16);
list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
scroll = gtk_scrolled_window_new(); gtk_widget_set_size_request(scroll, 380, 480);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), list);
for (guint index = 0; index < records->len; index++)
{
const EvidenceRecord *record = g_ptr_array_index(records, index);
EvidenceChoice *choice = g_new0(EvidenceChoice, 1); gboolean active = FALSE;
char *label = g_strdup_printf("%s — %s",
evidence_record_get_original_name(record),
evidence_record_get_type_identifier(record));
char *candidate = g_build_filename(root_path,
evidence_record_get_relative_path(record), NULL);
char *canonical_root = g_canonicalize_filename(root_path, NULL);
char *canonical_path = g_canonicalize_filename(candidate, NULL);
choice->identifier = g_strdup(evidence_record_get_identifier(record));
choice->relative_path = g_strdup(
evidence_record_get_relative_path(record));
choice->sha256 = g_strdup(evidence_record_get_sha256(record));
{
gboolean uncertain = FALSE;
char *content_type = g_content_type_guess(
choice->path != NULL ? choice->path : candidate,
NULL, 0U, &uncertain);
choice->mime_type = content_type != NULL
? g_content_type_get_mime_type(content_type)
: g_strdup("application/octet-stream");
g_free(content_type);
(void) uncertain;
}
if (canonical_root != NULL && canonical_path != NULL &&
g_str_has_prefix(canonical_path, canonical_root) &&
(canonical_path[strlen(canonical_root)] == G_DIR_SEPARATOR ||
canonical_path[strlen(canonical_root)] == '\0'))
choice->path = g_strdup(canonical_path);
g_free(canonical_path); g_free(canonical_root); g_free(candidate);
choice->check = GTK_CHECK_BUTTON(gtk_check_button_new_with_label(label));
g_free(label);
for (guint selected = 0; selected_ids != NULL && selected < selected_ids->len; selected++)
if (g_strcmp0(choice->identifier, g_ptr_array_index(selected_ids, selected)) == 0) active = TRUE;
gtk_check_button_set_active(choice->check, active);
g_object_set_data(G_OBJECT(choice->check), "choice", choice);
g_signal_connect(choice->check, "toggled", G_CALLBACK(manage_evidence_on_toggled), state);
gtk_box_append(GTK_BOX(list), GTK_WIDGET(choice->check));
g_ptr_array_add(state->choices, choice);
}
state->preview = evidence_preview_widget_new(task_manager, NULL, NULL);
if (state->preview == NULL) {
gtk_window_destroy(state->window);
manage_evidence_state_free(state);
return FALSE;
}
content = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_widget_set_name(content, "manage-evidence-paned");
gtk_paned_set_start_child(GTK_PANED(content), scroll);
gtk_paned_set_end_child(GTK_PANED(content),
evidence_preview_widget_get_widget(state->preview));
labfy_paned_apply_initial_ratio(
GTK_PANED(content), 2.0 / 3.0, 480, 240);
gtk_paned_set_resize_start_child(GTK_PANED(content), FALSE);
gtk_paned_set_resize_end_child(GTK_PANED(content), TRUE);
save = gtk_button_new_with_label("Enregistrer les pièces jointes");
import = gtk_button_new_with_label("Importer une nouvelle preuve");
actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_add_css_class(save, "suggested-action"); gtk_widget_set_halign(save, GTK_ALIGN_END);
gtk_box_append(GTK_BOX(actions), import);
gtk_box_append(GTK_BOX(actions), save);
gtk_widget_set_halign(actions, GTK_ALIGN_END);
gtk_box_append(GTK_BOX(root), content); gtk_box_append(GTK_BOX(root), actions);
gtk_window_set_child(state->window, root);
g_object_set_data_full(G_OBJECT(state->window), "state", state, manage_evidence_state_free);
g_signal_connect(save, "clicked", G_CALLBACK(manage_evidence_on_save), state);
g_signal_connect(import, "clicked", G_CALLBACK(manage_evidence_on_import), state);
g_signal_connect(state->window, "close-request", G_CALLBACK(manage_evidence_on_close), state);
labfy_dialog_present(state->window); return TRUE;
}

View file

@ -1,86 +0,0 @@
/******************************************************************************
* @file metadata_analysis_dialog.c
* @brief Fenêtre de consultation des métadonnées extraites.
******************************************************************************/
#include "views/metadata_analysis_dialog.h"
#include "views/dialog_geometry.h"
static gboolean metadata_analysis_dialog_on_escape(
GtkWidget *widget, GVariant *arguments, gpointer user_data)
{
(void) widget;
(void) arguments;
gtk_window_destroy(GTK_WINDOW(user_data));
return TRUE;
}
void metadata_analysis_dialog_present(GtkWindow *parent, const char *summary,
const char *json_path, const char *version)
{
GtkWidget *window = NULL;
GtkWidget *content = NULL;
GtkWidget *heading = NULL;
GtkWidget *scrolled = NULL;
GtkWidget *text_view = NULL;
GtkTextBuffer *buffer = NULL;
GtkWidget *path_label = NULL;
GtkWidget *close_button = NULL;
char *heading_text = NULL;
char *path_text = NULL;
window = gtk_window_new();
gtk_window_set_title(GTK_WINDOW(window), "Métadonnées de la preuve");
labfy_dialog_prepare(GTK_WINDOW(window), parent, TRUE, TRUE);
content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_margin_top(content, 16);
gtk_widget_set_margin_bottom(content, 16);
gtk_widget_set_margin_start(content, 16);
gtk_widget_set_margin_end(content, 16);
heading_text = g_strdup_printf("Extraction locale — ExifTool %s",
version != NULL ? version : "version inconnue");
heading = gtk_label_new(heading_text);
gtk_widget_set_halign(heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(content), heading);
scrolled = gtk_scrolled_window_new();
gtk_widget_set_vexpand(scrolled, TRUE);
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled),
GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC);
text_view = gtk_text_view_new();
gtk_text_view_set_editable(GTK_TEXT_VIEW(text_view), FALSE);
gtk_text_view_set_monospace(GTK_TEXT_VIEW(text_view), TRUE);
gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(text_view), GTK_WRAP_WORD_CHAR);
buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(text_view));
gtk_text_buffer_set_text(buffer, summary != NULL ? summary :
"Aucune métadonnée exploitable.", -1);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scrolled), text_view);
gtk_box_append(GTK_BOX(content), scrolled);
path_text = g_strdup_printf("Sortie JSON conservée : %s",
json_path != NULL ? json_path : "indisponible");
path_label = gtk_label_new(path_text);
gtk_label_set_wrap(GTK_LABEL(path_label), TRUE);
gtk_label_set_selectable(GTK_LABEL(path_label), TRUE);
gtk_widget_set_halign(path_label, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(content), path_label);
close_button = gtk_button_new_with_label("Fermer");
gtk_widget_set_halign(close_button, GTK_ALIGN_END);
g_signal_connect_swapped(close_button, "clicked",
G_CALLBACK(gtk_window_destroy), window);
{
GtkShortcutController *shortcuts = GTK_SHORTCUT_CONTROLLER(
gtk_shortcut_controller_new());
gtk_shortcut_controller_set_scope(
shortcuts, GTK_SHORTCUT_SCOPE_LOCAL);
gtk_shortcut_controller_add_shortcut(shortcuts,
gtk_shortcut_new(
gtk_keyval_trigger_new(GDK_KEY_Escape, 0),
gtk_callback_action_new(
metadata_analysis_dialog_on_escape, window, NULL)));
gtk_widget_add_controller(window, GTK_EVENT_CONTROLLER(shortcuts));
}
gtk_box_append(GTK_BOX(content), close_button);
gtk_window_set_child(GTK_WINDOW(window), content);
labfy_dialog_present(GTK_WINDOW(window));
g_free(heading_text);
g_free(path_text);
}

View file

@ -1,280 +0,0 @@
/******************************************************************************
* @file osint_dns_review_dialog.c
* @brief Dialogue de sélection des propositions issues d'une réponse DNS.
******************************************************************************/
#include "views/osint_dns_review_dialog.h"
#include "views/dialog_geometry.h"
#include "models/osint_dns_proposal.h"
typedef struct
{
GtkWindow *window;
GtkWidget *select_all_button;
GtkWidget *confirm_button;
GPtrArray *proposals;
GPtrArray *selection_buttons;
OsintDnsReviewDialogConfirmCallback callback;
gpointer user_data;
} OsintDnsReviewDialogContext;
/** @brief Libère le contexte possédé par la fenêtre. */
static void osint_dns_review_dialog_context_free(gpointer data)
{
OsintDnsReviewDialogContext *context = data;
if (context == NULL) return;
g_clear_pointer(&context->selection_buttons, g_ptr_array_unref);
g_clear_pointer(&context->proposals, g_ptr_array_unref);
g_free(context);
}
/** @brief Ferme le dialogue sans intégrer de proposition. */
static void osint_dns_review_dialog_on_cancel_clicked(
GtkButton *button,
gpointer user_data
)
{
OsintDnsReviewDialogContext *context = user_data;
(void) button;
if (context != NULL) gtk_window_destroy(context->window);
}
/** @brief Actualise l'état du bouton de confirmation. */
static void osint_dns_review_dialog_update_confirmation(
OsintDnsReviewDialogContext *context
)
{
gboolean has_selection = FALSE;
for (guint index = 0;
context != NULL && index < context->selection_buttons->len;
index++)
{
GtkCheckButton *button = g_ptr_array_index(
context->selection_buttons, index
);
if (gtk_widget_get_sensitive(GTK_WIDGET(button)) &&
gtk_check_button_get_active(button))
{
has_selection = TRUE;
break;
}
}
if (context != NULL)
gtk_widget_set_sensitive(context->confirm_button, has_selection);
}
/** @brief Réagit à la sélection individuelle d'une proposition. */
static void osint_dns_review_dialog_on_proposal_toggled(
GtkCheckButton *button,
gpointer user_data
)
{
(void) button;
osint_dns_review_dialog_update_confirmation(user_data);
}
/** @brief Sélectionne ou désélectionne toutes les propositions compatibles. */
static void osint_dns_review_dialog_on_select_all_toggled(
GtkCheckButton *button,
gpointer user_data
)
{
OsintDnsReviewDialogContext *context = user_data;
const gboolean selected = gtk_check_button_get_active(button);
for (guint index = 0;
context != NULL && index < context->selection_buttons->len;
index++)
{
GtkWidget *selection_button = g_ptr_array_index(
context->selection_buttons, index
);
if (gtk_widget_get_sensitive(selection_button))
gtk_check_button_set_active(
GTK_CHECK_BUTTON(selection_button), selected
);
}
osint_dns_review_dialog_update_confirmation(context);
}
/** @brief Transmet les propositions cochées puis ferme le dialogue. */
static void osint_dns_review_dialog_on_confirm_clicked(
GtkButton *button,
gpointer user_data
)
{
OsintDnsReviewDialogContext *context = user_data;
GPtrArray *selected_proposals = g_ptr_array_new();
(void) button;
for (guint index = 0;
context != NULL && index < context->selection_buttons->len;
index++)
{
GtkCheckButton *selection_button = g_ptr_array_index(
context->selection_buttons, index
);
if (gtk_widget_get_sensitive(GTK_WIDGET(selection_button)) &&
gtk_check_button_get_active(selection_button))
{
g_ptr_array_add(
selected_proposals,
g_object_get_data(
G_OBJECT(selection_button), "osint-dns-proposal"
)
);
}
}
if (context != NULL && context->callback != NULL &&
selected_proposals->len > 0U)
{
context->callback(selected_proposals, context->user_data);
}
g_ptr_array_unref(selected_proposals);
if (context != NULL) gtk_window_destroy(context->window);
}
/** @brief Crée une ligne décrivant une proposition DNS. */
static GtkWidget *osint_dns_review_dialog_create_row(
OsintDnsReviewDialogContext *context,
const OsintDnsProposal *proposal
)
{
GtkWidget *row = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
const char *entity_type = osint_dns_proposal_get_entity_type(proposal);
const char *relation_type = osint_dns_proposal_get_relation_type(proposal);
char *normalized_value = osint_dns_proposal_dup_normalized_value(proposal);
const gboolean compatible = entity_type != NULL && relation_type != NULL &&
normalized_value != NULL;
char *title = g_strdup_printf(
"%s → %s",
osint_dns_proposal_get_record_type(proposal),
osint_dns_proposal_get_value(proposal)
);
char *details = g_strdup_printf(
"Cible : %s\nPropriétaire : %s\nType Labfy : %s\n"
"Relation prévue : %s — %s → %s",
osint_dns_proposal_get_target(proposal),
osint_dns_proposal_get_owner(proposal),
compatible ? entity_type : "non pris en charge",
osint_dns_proposal_get_target(proposal),
compatible ? relation_type : "non prise en charge",
normalized_value != NULL ? normalized_value
: osint_dns_proposal_get_value(proposal)
);
GtkWidget *selection_button = gtk_check_button_new_with_label(title);
GtkWidget *details_label = gtk_label_new(details);
gtk_widget_set_sensitive(selection_button, compatible);
gtk_widget_set_margin_start(details_label, 28);
gtk_widget_set_halign(details_label, GTK_ALIGN_START);
gtk_label_set_xalign(GTK_LABEL(details_label), 0.0F);
gtk_label_set_selectable(GTK_LABEL(details_label), TRUE);
g_object_set_data(
G_OBJECT(selection_button), "osint-dns-proposal", (gpointer) proposal
);
g_signal_connect(
selection_button, "toggled",
G_CALLBACK(osint_dns_review_dialog_on_proposal_toggled), context
);
g_ptr_array_add(context->selection_buttons, selection_button);
gtk_box_append(GTK_BOX(row), selection_button);
gtk_box_append(GTK_BOX(row), details_label);
g_free(normalized_value);
g_free(title);
g_free(details);
return row;
}
void osint_dns_review_dialog_present(
GtkWindow *parent_window,
GPtrArray *proposals,
OsintDnsReviewDialogConfirmCallback callback,
gpointer user_data
)
{
OsintDnsReviewDialogContext *context = NULL;
GtkWidget *main_box = NULL;
GtkWidget *title_label = NULL;
GtkWidget *notice_label = NULL;
GtkWidget *scrolled_window = NULL;
GtkWidget *proposal_box = NULL;
GtkWidget *button_box = NULL;
GtkWidget *cancel_button = NULL;
if (proposals == NULL || proposals->len == 0U) return;
context = g_new0(OsintDnsReviewDialogContext, 1);
context->window = GTK_WINDOW(gtk_window_new());
context->proposals = g_ptr_array_ref(proposals);
context->selection_buttons = g_ptr_array_new();
context->callback = callback;
context->user_data = user_data;
gtk_window_set_title(context->window, "Intégrer les propositions DNS");
labfy_dialog_prepare(context->window, parent_window, TRUE, TRUE);
g_object_set_data_full(
G_OBJECT(context->window), "osint-dns-review-context", context,
osint_dns_review_dialog_context_free
);
main_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_margin_start(main_box, 20);
gtk_widget_set_margin_end(main_box, 20);
gtk_widget_set_margin_top(main_box, 20);
gtk_widget_set_margin_bottom(main_box, 20);
title_label = gtk_label_new("Sélectionner les propositions à intégrer");
gtk_widget_set_halign(title_label, GTK_ALIGN_START);
gtk_widget_add_css_class(title_label, "title-2");
notice_label = gtk_label_new(
"Aucune écriture ne sera effectuée avant confirmation. "
"Les types non pris en charge restent visibles mais désactivés."
);
gtk_label_set_wrap(GTK_LABEL(notice_label), TRUE);
gtk_label_set_xalign(GTK_LABEL(notice_label), 0.0F);
context->select_all_button = gtk_check_button_new_with_label(
"Sélectionner toutes les propositions compatibles"
);
g_signal_connect(
context->select_all_button, "toggled",
G_CALLBACK(osint_dns_review_dialog_on_select_all_toggled), context
);
proposal_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 14);
for (guint index = 0; index < proposals->len; index++)
gtk_box_append(
GTK_BOX(proposal_box),
osint_dns_review_dialog_create_row(
context, g_ptr_array_index(proposals, index)
)
);
scrolled_window = gtk_scrolled_window_new();
gtk_widget_set_vexpand(scrolled_window, TRUE);
gtk_scrolled_window_set_child(
GTK_SCROLLED_WINDOW(scrolled_window), proposal_box
);
button_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_halign(button_box, GTK_ALIGN_END);
cancel_button = gtk_button_new_with_label("Annuler");
context->confirm_button = gtk_button_new_with_label("Intégrer la sélection");
gtk_widget_add_css_class(context->confirm_button, "suggested-action");
gtk_widget_set_sensitive(context->confirm_button, FALSE);
g_signal_connect(
cancel_button, "clicked",
G_CALLBACK(osint_dns_review_dialog_on_cancel_clicked), context
);
g_signal_connect(
context->confirm_button, "clicked",
G_CALLBACK(osint_dns_review_dialog_on_confirm_clicked), context
);
gtk_box_append(GTK_BOX(button_box), cancel_button);
gtk_box_append(GTK_BOX(button_box), context->confirm_button);
gtk_box_append(GTK_BOX(main_box), title_label);
gtk_box_append(GTK_BOX(main_box), notice_label);
gtk_box_append(GTK_BOX(main_box), context->select_all_button);
gtk_box_append(GTK_BOX(main_box), scrolled_window);
gtk_box_append(GTK_BOX(main_box), button_box);
gtk_window_set_child(context->window, main_box);
labfy_dialog_present(context->window);
}

View file

@ -1,216 +0,0 @@
/******************************************************************************
* @file osint_execution_history_dialog.c
* @brief Consultation en lecture seule de l'historique OSINT.
******************************************************************************/
#include "views/osint_execution_history_dialog.h"
#include "views/dialog_geometry.h"
#include "core/osint_execution_integrity.h"
#include "models/osint_execution_record.h"
typedef struct
{
GtkWindow *window;
GtkTextBuffer *details_buffer;
GtkWidget *integrity_label;
const OsintExecutionRecord *current_record;
GPtrArray *records;
GHashTable *linked_objects;
} OsintExecutionHistoryDialogContext;
/** @brief Libère les données conservées par la fenêtre. */
static void osint_execution_history_dialog_context_free(gpointer data)
{
OsintExecutionHistoryDialogContext *context = data;
if (context == NULL) return;
g_clear_pointer(&context->records, g_ptr_array_unref);
g_clear_pointer(&context->linked_objects, g_hash_table_unref);
g_free(context);
}
/** @brief Convertit une sortie brute en texte UTF-8 affichable. */
static char *osint_execution_history_dialog_output_to_text(GBytes *bytes)
{
gconstpointer data = NULL;
gsize size = 0U;
if (bytes == NULL) return g_strdup("");
data = g_bytes_get_data(bytes, &size);
return data != NULL && size > 0U
? g_utf8_make_valid(data, (gssize) size) : g_strdup("");
}
/** @brief Construit le détail textuel complet d'une exécution. */
static char *osint_execution_history_dialog_build_details(
const OsintExecutionRecord *record, GPtrArray *linked_objects
)
{
GBytes *stdout_raw = osint_execution_record_ref_stdout(record);
GBytes *stderr_raw = osint_execution_record_ref_stderr(record);
char *stdout_text = osint_execution_history_dialog_output_to_text(stdout_raw);
char *stderr_text = osint_execution_history_dialog_output_to_text(stderr_raw);
GString *details = g_string_new(NULL);
g_string_append_printf(details,
"Exécution : %s\nOutil : %s%s%s\nAction : %s\nÉtat : %s\n"
"Sélection : %s %s\nCible : %s\nDébut : %s\nFin : %s\n"
"Code de sortie : ",
osint_execution_record_get_identifier(record),
osint_execution_record_get_tool_identifier(record),
osint_execution_record_get_tool_version(record) != NULL ? " — " : "",
osint_execution_record_get_tool_version(record) != NULL
? osint_execution_record_get_tool_version(record) : "",
osint_execution_record_get_action_identifier(record),
osint_execution_record_get_final_state(record),
osint_execution_record_get_selection_kind(record),
osint_execution_record_get_selection_identifier(record),
osint_execution_record_get_target_value(record),
osint_execution_record_get_started_at(record),
osint_execution_record_get_finished_at(record));
if (osint_execution_record_has_exit_code(record))
g_string_append_printf(details, "%d", osint_execution_record_get_exit_code(record));
else g_string_append(details, "indisponible");
g_string_append_printf(details,
"\nArguments : %s\nSHA-256 : %s\n\nObjets liés :\n",
osint_execution_record_get_arguments(record),
osint_execution_record_get_output_sha256(record));
if (linked_objects == NULL || linked_objects->len == 0U)
g_string_append(details, "Aucun objet intégré.\n");
else for (guint index = 0U; index < linked_objects->len; index++)
g_string_append_printf(details, "- %s\n",
(const char *) g_ptr_array_index(linked_objects, index));
g_string_append_printf(details, "\nSortie standard :\n%s\n\nSortie d'erreur :\n%s",
stdout_text[0] != '\0' ? stdout_text : "(vide)",
stderr_text[0] != '\0' ? stderr_text : "(vide)");
g_bytes_unref(stdout_raw); g_bytes_unref(stderr_raw);
g_free(stdout_text); g_free(stderr_text);
return g_string_free(details, FALSE);
}
/** @brief Affiche le détail correspondant au bouton d'historique activé. */
static void osint_execution_history_dialog_on_record_clicked(
GtkButton *button, gpointer user_data
)
{
OsintExecutionHistoryDialogContext *context = user_data;
guint encoded_index = GPOINTER_TO_UINT(g_object_get_data(
G_OBJECT(button), "osint-execution-index"));
OsintExecutionRecord *record = NULL;
GPtrArray *linked_objects = NULL;
char *details = NULL;
if (context == NULL || encoded_index == 0U ||
encoded_index > context->records->len) return;
record = g_ptr_array_index(context->records, encoded_index - 1U);
context->current_record = record;
gtk_label_set_text(GTK_LABEL(context->integrity_label),
"Intégrité : non vérifiée");
gtk_widget_remove_css_class(context->integrity_label, "error");
linked_objects = g_hash_table_lookup(context->linked_objects,
osint_execution_record_get_identifier(record));
details = osint_execution_history_dialog_build_details(record, linked_objects);
gtk_text_buffer_set_text(context->details_buffer, details, -1);
g_free(details);
}
/** @brief Recalcule et affiche l'intégrité de l'exécution sélectionnée. */
static void osint_execution_history_dialog_on_verify_clicked(
GtkButton *button, gpointer user_data
)
{
OsintExecutionHistoryDialogContext *context = user_data;
GBytes *stdout_raw = NULL; GBytes *stderr_raw = NULL;
OsintExecutionIntegrityStatus status;
const char *status_text = "Intégrité : vérification impossible";
(void) button;
if (context == NULL || context->current_record == NULL) return;
stdout_raw = osint_execution_record_ref_stdout(context->current_record);
stderr_raw = osint_execution_record_ref_stderr(context->current_record);
status = osint_execution_integrity_verify(
stdout_raw, stderr_raw,
osint_execution_record_get_output_sha256(context->current_record));
if (status == OSINT_EXECUTION_INTEGRITY_INTACT)
status_text = "Intégrité : intacte";
else if (status == OSINT_EXECUTION_INTEGRITY_ALTERED)
status_text = "Intégrité : altérée";
gtk_label_set_text(GTK_LABEL(context->integrity_label), status_text);
if (status == OSINT_EXECUTION_INTEGRITY_INTACT)
gtk_widget_remove_css_class(context->integrity_label, "error");
else gtk_widget_add_css_class(context->integrity_label, "error");
g_bytes_unref(stdout_raw); g_bytes_unref(stderr_raw);
}
void osint_execution_history_dialog_present(
GtkWindow *parent_window, GPtrArray *records, GHashTable *linked_objects
)
{
OsintExecutionHistoryDialogContext *context = NULL;
GtkWidget *main_box = NULL; GtkWidget *content_box = NULL;
GtkWidget *history_scroll = NULL; GtkWidget *history_box = NULL;
GtkWidget *details_scroll = NULL; GtkWidget *details_view = NULL;
GtkWidget *button_box = NULL; GtkWidget *verify_button = NULL;
GtkWidget *close_button = NULL;
if (records == NULL || records->len == 0U || linked_objects == NULL) return;
context = g_new0(OsintExecutionHistoryDialogContext, 1);
context->window = GTK_WINDOW(gtk_window_new());
context->records = g_ptr_array_ref(records);
context->linked_objects = g_hash_table_ref(linked_objects);
gtk_window_set_title(context->window, "Historique OSINT");
labfy_dialog_prepare(context->window, parent_window, TRUE, TRUE);
g_object_set_data_full(G_OBJECT(context->window), "osint-history-context",
context, osint_execution_history_dialog_context_free);
main_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_margin_start(main_box, 16); gtk_widget_set_margin_end(main_box, 16);
gtk_widget_set_margin_top(main_box, 16); gtk_widget_set_margin_bottom(main_box, 16);
content_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
gtk_widget_set_vexpand(content_box, TRUE);
history_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
history_scroll = gtk_scrolled_window_new();
gtk_widget_set_size_request(history_scroll, 300, -1);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(history_scroll), history_box);
for (guint index = 0U; index < records->len; index++)
{
OsintExecutionRecord *record = g_ptr_array_index(records, index);
char *label = g_strdup_printf("%s\n%s — %s",
osint_execution_record_get_finished_at(record),
osint_execution_record_get_tool_identifier(record),
osint_execution_record_get_final_state(record));
GtkWidget *button = gtk_button_new_with_label(label);
gtk_widget_set_halign(button, GTK_ALIGN_FILL);
g_object_set_data(G_OBJECT(button), "osint-execution-index",
GUINT_TO_POINTER(index + 1U));
g_signal_connect(button, "clicked",
G_CALLBACK(osint_execution_history_dialog_on_record_clicked), context);
gtk_box_append(GTK_BOX(history_box), button);
g_free(label);
}
details_view = gtk_text_view_new();
context->details_buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(details_view));
gtk_text_view_set_editable(GTK_TEXT_VIEW(details_view), FALSE);
gtk_text_view_set_cursor_visible(GTK_TEXT_VIEW(details_view), TRUE);
gtk_text_view_set_monospace(GTK_TEXT_VIEW(details_view), TRUE);
gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(details_view), GTK_WRAP_WORD_CHAR);
details_scroll = gtk_scrolled_window_new();
gtk_widget_set_hexpand(details_scroll, TRUE);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(details_scroll), details_view);
gtk_box_append(GTK_BOX(content_box), history_scroll);
gtk_box_append(GTK_BOX(content_box), details_scroll);
button_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_halign(button_box, GTK_ALIGN_END);
context->integrity_label = gtk_label_new("Intégrité : non vérifiée");
gtk_widget_set_hexpand(context->integrity_label, TRUE);
gtk_widget_set_halign(context->integrity_label, GTK_ALIGN_START);
verify_button = gtk_button_new_with_label("Vérifier l'intégrité");
g_signal_connect(verify_button, "clicked",
G_CALLBACK(osint_execution_history_dialog_on_verify_clicked), context);
close_button = gtk_button_new_with_label("Fermer");
g_signal_connect_swapped(close_button, "clicked",
G_CALLBACK(gtk_window_destroy), context->window);
gtk_box_append(GTK_BOX(button_box), verify_button);
gtk_box_append(GTK_BOX(button_box), close_button);
gtk_box_append(GTK_BOX(main_box), content_box);
gtk_box_append(GTK_BOX(main_box), context->integrity_label);
gtk_box_append(GTK_BOX(main_box), button_box);
gtk_window_set_child(context->window, main_box);
osint_execution_history_dialog_on_record_clicked(
GTK_BUTTON(gtk_widget_get_first_child(history_box)), context);
labfy_dialog_present(context->window);
}

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