feat(entities): add social media accounts

This commit is contained in:
grayTerminal-sh 2026-07-22 15:03:13 +02:00
parent d876590529
commit e91c5f5ee0
18 changed files with 934 additions and 9 deletions

View file

@ -121,6 +121,7 @@ TEST_OSINT_DNS_INTEGRATION := tests/test_osint_dns_integration
TEST_OSINT_EXECUTION_DAO := tests/test_osint_execution_dao
TEST_OSINT_EXECUTION_INTEGRITY := tests/test_osint_execution_integrity
TEST_EVIDENCE_RECLASSIFICATION := tests/test_evidence_reclassification
TEST_SOCIAL_ACCOUNT_SERVICE := tests/test_social_account_service
all: $(TARGET)
@ -609,6 +610,19 @@ $(TEST_EVIDENCE_RECLASSIFICATION): \
src/database/error.c
$(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) -lsqlite3
$(TEST_SOCIAL_ACCOUNT_SERVICE): \
tests/test_social_account_service.c \
src/core/social_account_service.c \
src/dao/entity_dao.c \
src/dao/evidence_entity_dao.c \
src/models/entity_record.c \
src/database/database.c \
src/database/schema.c \
src/database/statement.c \
src/database/transaction.c \
src/database/error.c
$(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) -lsqlite3
$(TEST_INVESTIGATION_GRAPH_LOAD_TASK): \
tests/test_investigation_graph_load_task.c \
src/core/investigation_graph_load_task.c \
@ -684,7 +698,8 @@ test: \
$(TEST_OSINT_DNS_INTEGRATION) \
$(TEST_OSINT_EXECUTION_DAO) \
$(TEST_OSINT_EXECUTION_INTEGRITY) \
$(TEST_EVIDENCE_RECLASSIFICATION)
$(TEST_EVIDENCE_RECLASSIFICATION) \
$(TEST_SOCIAL_ACCOUNT_SERVICE)
@echo "Exécution des tests..."
@./$(TEST_NODE)
@./$(TEST_TREE_MODEL)
@ -740,6 +755,7 @@ test: \
@$(TEST_OSINT_EXECUTION_DAO)
@$(TEST_OSINT_EXECUTION_INTEGRITY)
@$(TEST_EVIDENCE_RECLASSIFICATION)
@$(TEST_SOCIAL_ACCOUNT_SERVICE)
@echo "Tous les tests sont valides."
%.o: %.c
@ -801,7 +817,8 @@ clean:
$(TEST_OSINT_DNS_INTEGRATION) \
$(TEST_OSINT_EXECUTION_DAO) \
$(TEST_OSINT_EXECUTION_INTEGRITY) \
$(TEST_EVIDENCE_RECLASSIFICATION)
$(TEST_EVIDENCE_RECLASSIFICATION) \
$(TEST_SOCIAL_ACCOUNT_SERVICE)
-include $(DEP)

View file

@ -158,6 +158,9 @@ Le socle actuel comprend notamment :
`uses_name_server` depuis l'entité interrogée ;
- provenance OSINT SQLite V3 conservant les arguments, sorties brutes,
empreinte SHA-256 et liaisons vers les entités et relations intégrées ;
- comptes sociaux structurés en SQLite V4 (TikTok, Instagram, Facebook, X,
Telegram ou autre), avec URL, pseudonyme, identifiant stable facultatif,
première observation, état, notes et rattachement à une preuve ;
- historique OSINT contextuel en lecture seule avec détail des exécutions,
sorties standard et d'erreur, et objets créés ou réutilisés ;
- vérification manuelle de l'intégrité des sorties OSINT enregistrées, sans

View file

@ -1,7 +1,7 @@
/******************************************************************************
* Labfy Investigation
*
* Extensions idempotentes du schéma SQLite courant V3
* Extensions idempotentes du schéma SQLite courant V4
******************************************************************************/
/*

39
database/schema_v4.sql Normal file
View file

@ -0,0 +1,39 @@
/******************************************************************************
* Labfy Investigation
*
* Migration du schéma SQLite V3 vers V4 : comptes de réseaux sociaux
******************************************************************************/
INSERT INTO types_entite (code, label, description) VALUES
('tiktok_account', 'Compte TikTok', NULL),
('x_account', 'Compte X', NULL),
('telegram_account', 'Compte Telegram', NULL),
('social_account', 'Autre compte social', NULL);
CREATE TABLE comptes_sociaux
(
entite_id TEXT PRIMARY KEY,
plateforme TEXT NOT NULL,
url_profil TEXT NOT NULL,
pseudonyme TEXT NOT NULL,
identifiant_plateforme TEXT,
premiere_observation TEXT NOT NULL,
etat_compte TEXT NOT NULL DEFAULT 'unknown',
notes TEXT,
FOREIGN KEY (entite_id)
REFERENCES entites(id)
ON UPDATE CASCADE
ON DELETE CASCADE,
UNIQUE (plateforme, url_profil),
CHECK (plateforme IN ('tiktok', 'instagram', 'facebook', 'x', 'telegram', 'other')),
CHECK (length(trim(url_profil)) > 0),
CHECK (length(trim(pseudonyme)) > 0),
CHECK (length(premiere_observation) = 20),
CHECK (etat_compte IN ('active', 'private', 'suspended', 'deleted', 'unknown'))
);
CREATE INDEX idx_comptes_sociaux_pseudonyme
ON comptes_sociaux(plateforme, pseudonyme);

View file

@ -1148,6 +1148,19 @@ lors de l'enregistrement. Le résultat indique si les sorties sont intactes,
altérées ou impossibles à vérifier. Ce contrôle est strictement en lecture
seule : aucune sortie ni empreinte persistée n'est corrigée automatiquement.
### Comptes sociaux structurés — schéma V4
La table `comptes_sociaux` complète une ligne de `entites` sans dupliquer le
nœud affiché dans le graphe. Elle conserve la plateforme, l'URL de profil, le
pseudonyme affiché, l'identifiant stable facultatif, la première observation,
l'état observé et les notes factuelles. La contrainte unique sur
`(plateforme, url_profil)` évite les doublons.
Une capture, une vidéo ou un courriel déjà importé peut être rattaché au
compte via `preuve_entites`. La création des deux lignes et de cette liaison
est transactionnelle : aucun nœud incomplet n'est conservé si une étape
échoue.
---
# 6. Tables de liaison

View file

@ -0,0 +1,50 @@
/******************************************************************************
* @file social_account_service.h
* @brief Création transactionnelle d'entités de comptes sociaux.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_SOCIAL_ACCOUNT_SERVICE_H
#define LABFY_INVESTIGATION_SOCIAL_ACCOUNT_SERVICE_H
#include "database/database.h"
#include <glib.h>
G_BEGIN_DECLS
/** @brief Données nécessaires à l'enregistrement d'un compte social. */
typedef struct
{
const char *platform;
const char *profile_url;
const char *username;
const char *platform_identifier;
const char *first_observed_at;
const char *account_state;
const char *notes;
const char *evidence_identifier;
} SocialAccountInput;
/**
* @brief Valide et enregistre un compte social et sa preuve facultative.
*
* L'entité, sa fiche spécialisée et son éventuelle association à une preuve
* sont créées dans une même transaction. L'identifiant retourné appartient à
* l'appelant et doit être libéré avec g_free().
*
* @param database Connexion ouverte empruntée.
* @param input Données du compte à créer.
* @param out_entity_identifier Destination facultative de l'UUID créé.
* @param error Emplacement facultatif recevant une erreur.
* @return TRUE lorsque toutes les écritures sont validées.
*/
gboolean social_account_service_create(
Database *database,
const SocialAccountInput *input,
char **out_entity_identifier,
GError **error
);
G_END_DECLS
#endif

View file

@ -68,6 +68,19 @@ bool schema_install_v3(
Database *database
);
/**
* @brief Installe la migration des comptes sociaux du schéma V4.
*
* La connexion doit être valide et une transaction doit déjà être active.
* La fonction ne réalise ni COMMIT ni ROLLBACK.
*
* @param database Connexion Database ouverte.
* @return true si la migration V4 a é appliquée, sinon false.
*/
bool schema_install_v4(
Database *database
);
/**
* @brief Garantit la présence des extensions du schéma courant V2.
*

View file

@ -0,0 +1,67 @@
/******************************************************************************
* @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 é 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

@ -50,6 +50,11 @@ 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é lorsque l'utilisateur revient au graphe.
*
@ -531,6 +536,18 @@ void main_window_set_import_evidence_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 du bouton « Revenir au graphe ».
*
@ -557,6 +574,16 @@ void main_window_set_import_evidence_enabled(
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 Libère les ressources de la fenêtre.
*

View file

@ -47,7 +47,9 @@
#include "core/evidence_integrity_task.h"
#include "core/evidence_integrity_verifier.h"
#include "core/relation_service.h"
#include "core/social_account_service.h"
#include "database/database.h"
#include "views/create_social_account_dialog.h"
#include <gtk/gtk.h>
#include <errno.h>
@ -2267,6 +2269,11 @@ static gboolean application_install_session(
TRUE
);
main_window_set_add_social_account_enabled(
application->main_window,
TRUE
);
application_start_graph_loading(
application,
database_path
@ -4091,6 +4098,80 @@ static void application_on_import_evidence_requested(
);
}
/** @brief Persiste le résultat validé du dialogue de compte social. */
static void application_on_social_account_completed(
CreateSocialAccountDialogResult *result, gpointer user_data)
{
Application *application = user_data;
SocialAccountInput input = {0};
const InvestigationProject *project = NULL;
Database *database = NULL;
GError *error = NULL;
char *identifier = NULL;
if (result == NULL || application == NULL || application->session == NULL)
{
create_social_account_dialog_result_free(result);
return;
}
input.platform = create_social_account_dialog_result_get_platform(result);
input.profile_url = create_social_account_dialog_result_get_profile_url(result);
input.username = create_social_account_dialog_result_get_username(result);
input.platform_identifier = create_social_account_dialog_result_get_platform_identifier(result);
input.first_observed_at = create_social_account_dialog_result_get_first_observed_at(result);
input.account_state = create_social_account_dialog_result_get_account_state(result);
input.notes = create_social_account_dialog_result_get_notes(result);
input.evidence_identifier = create_social_account_dialog_result_get_evidence_identifier(result);
database = investigation_session_get_database(application->session);
if (!social_account_service_create(database, &input, &identifier, &error))
{
application_present_error(application, "Compte social non créé",
error != NULL ? error->message : "L'écriture dans l'enquête a échoué.");
}
else
{
project = investigation_session_get_project(application->session);
application_message_dialog_present(
main_window_get_window(application->main_window),
APPLICATION_MESSAGE_DIALOG_INFORMATION,
"Compte social ajouté",
"Le profil et son éventuelle preuve associée ont été ajoutés au graphe.");
application_start_graph_loading(application,
investigation_project_get_database_path(project));
}
g_clear_error(&error);
g_free(identifier);
create_social_account_dialog_result_free(result);
}
/** @brief Charge les preuves puis ouvre le formulaire de compte social. */
static void application_on_add_social_account_requested(gpointer user_data)
{
Application *application = user_data;
Database *database = NULL;
EvidenceDao *dao = NULL;
GPtrArray *evidence_records = NULL;
GError *error = NULL;
if (application == NULL || application->main_window == NULL ||
application->session == NULL) return;
database = investigation_session_get_database(application->session);
dao = evidence_dao_new(database, &error);
if (dao != NULL) evidence_records = evidence_dao_list_all(dao, &error);
if (evidence_records == NULL)
{
application_present_error(application, "Formulaire indisponible",
error != NULL ? error->message : "Impossible de charger les preuves.");
}
else
{
create_social_account_dialog_present(
main_window_get_window(application->main_window), evidence_records,
application_on_social_account_completed, application);
}
g_clear_pointer(&evidence_records, g_ptr_array_unref);
evidence_dao_free(dao);
g_clear_error(&error);
}
/**
* @brief Traite la sélection d'un nœud dans l'arborescence.
*
@ -6147,11 +6228,22 @@ static void application_on_activate(
application
);
main_window_set_add_social_account_callback(
application->main_window,
application_on_add_social_account_requested,
application
);
main_window_set_import_evidence_enabled(
application->main_window,
application->session != NULL
);
main_window_set_add_social_account_enabled(
application->main_window,
application->session != NULL
);
main_window_set_quit_callback(
application->main_window,
application_on_quit_requested,

View file

@ -0,0 +1,153 @@
/******************************************************************************
* @file social_account_service.c
* @brief Création transactionnelle d'entités de comptes sociaux.
******************************************************************************/
#include "core/social_account_service.h"
#include "dao/entity_dao.h"
#include "dao/evidence_entity_dao.h"
#include "database/statement.h"
#include "database/transaction.h"
#include "models/entity_record.h"
#include <string.h>
/** @brief Domaine d'erreur privé du service. */
#define SOCIAL_ACCOUNT_SERVICE_ERROR social_account_service_error_quark()
/** @brief Retourne le domaine d'erreur du service. */
static GQuark social_account_service_error_quark(void)
{
return g_quark_from_static_string("social-account-service-error");
}
/** @brief Associe une plateforme au type d'entité correspondant. */
static const char *social_account_service_entity_type(const char *platform)
{
if (g_strcmp0(platform, "tiktok") == 0) return "tiktok_account";
if (g_strcmp0(platform, "instagram") == 0) return "instagram_account";
if (g_strcmp0(platform, "facebook") == 0) return "facebook_account";
if (g_strcmp0(platform, "x") == 0) return "x_account";
if (g_strcmp0(platform, "telegram") == 0) return "telegram_account";
if (g_strcmp0(platform, "other") == 0) return "social_account";
return NULL;
}
/** @brief Indique si l'état du compte est pris en charge. */
static gboolean social_account_service_state_is_valid(const char *state)
{
return g_strcmp0(state, "active") == 0 ||
g_strcmp0(state, "private") == 0 ||
g_strcmp0(state, "suspended") == 0 ||
g_strcmp0(state, "deleted") == 0 ||
g_strcmp0(state, "unknown") == 0;
}
/** @brief Lie un texte ou SQL NULL à une requête. */
static gboolean social_account_service_bind_optional(
DatabaseStatement *statement, int index, const char *value)
{
return value != NULL && value[0] != '\0'
? database_statement_bind_text(statement, index, value)
: database_statement_bind_null(statement, index);
}
gboolean social_account_service_create(
Database *database,
const SocialAccountInput *input,
char **out_entity_identifier,
GError **error)
{
static const char *const insert_sql =
"INSERT INTO comptes_sociaux"
"(entite_id, plateforme, url_profil, pseudonyme, "
"identifiant_plateforme, premiere_observation, etat_compte, notes) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?);";
EntityDao *entity_dao = NULL;
EvidenceEntityDao *link_dao = NULL;
DatabaseStatement *statement = NULL;
EntityRecord *record = NULL;
GDateTime *parsed_date = NULL;
char *identifier = NULL;
char *now_text = NULL;
const char *type = NULL;
gboolean transaction_active = FALSE;
gboolean success = FALSE;
g_return_val_if_fail(error == NULL || *error == NULL, FALSE);
if (out_entity_identifier != NULL) *out_entity_identifier = NULL;
type = input != NULL
? social_account_service_entity_type(input->platform) : NULL;
parsed_date = input != NULL && input->first_observed_at != NULL
? g_date_time_new_from_iso8601(input->first_observed_at, NULL) : NULL;
if (database == NULL || input == NULL || type == NULL ||
input->profile_url == NULL ||
(!g_str_has_prefix(input->profile_url, "https://") &&
!g_str_has_prefix(input->profile_url, "http://")) ||
input->username == NULL || input->username[0] == '\0' ||
parsed_date == NULL ||
!social_account_service_state_is_valid(input->account_state))
{
g_set_error_literal(error, SOCIAL_ACCOUNT_SERVICE_ERROR, 1,
"Les informations du compte social sont invalides.");
goto cleanup;
}
identifier = g_uuid_string_random();
{
GDateTime *now = g_date_time_new_now_utc();
now_text = now != NULL ? g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ") : NULL;
g_clear_pointer(&now, g_date_time_unref);
}
if (identifier == NULL || now_text == NULL ||
!database_transaction_begin(database)) goto cleanup;
transaction_active = TRUE;
entity_dao = entity_dao_new(database, error);
record = entity_record_new(identifier, type, input->profile_url,
input->username, input->notes, 50, now_text, now_text,
ENTITY_STATUS_ACTIVE, error);
if (entity_dao == NULL || record == NULL ||
!entity_dao_insert(entity_dao, record, error)) goto cleanup;
statement = database_statement_prepare(database, insert_sql);
if (statement == NULL ||
!database_statement_bind_text(statement, 1, identifier) ||
!database_statement_bind_text(statement, 2, input->platform) ||
!database_statement_bind_text(statement, 3, input->profile_url) ||
!database_statement_bind_text(statement, 4, input->username) ||
!social_account_service_bind_optional(statement, 5, input->platform_identifier) ||
!database_statement_bind_text(statement, 6, input->first_observed_at) ||
!database_statement_bind_text(statement, 7, input->account_state) ||
!social_account_service_bind_optional(statement, 8, input->notes) ||
database_statement_step(statement) != DATABASE_STATEMENT_STEP_DONE)
{
if (error != NULL && *error == NULL)
g_set_error_literal(error, SOCIAL_ACCOUNT_SERVICE_ERROR, 2,
"Impossible d'enregistrer la fiche du compte social.");
goto cleanup;
}
if (input->evidence_identifier != NULL &&
input->evidence_identifier[0] != '\0')
{
link_dao = evidence_entity_dao_new(database, error);
if (link_dao == NULL || !evidence_entity_dao_link(
link_dao, input->evidence_identifier, identifier, error))
goto cleanup;
}
if (!database_transaction_commit(database)) goto cleanup;
transaction_active = FALSE;
success = TRUE;
if (out_entity_identifier != NULL)
*out_entity_identifier = g_strdup(identifier);
cleanup:
if (!success && transaction_active)
database_transaction_rollback(database);
database_statement_finalize(statement);
evidence_entity_dao_free(link_dao);
entity_record_free(record);
entity_dao_free(entity_dao);
g_clear_pointer(&parsed_date, g_date_time_unref);
g_free(now_text);
g_free(identifier);
return success;
}

View file

@ -16,12 +16,12 @@
/**
* @brief Version actuelle du schéma SQLite.
*/
#define DATABASE_SCHEMA_VERSION_CURRENT 3
#define DATABASE_SCHEMA_VERSION_CURRENT 4
/**
* @brief Version actuelle sous forme textuelle pour metadata.
*/
#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "3"
#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "4"
/**
* @brief Nom de l'application enregistré dans les métadonnées.
@ -692,6 +692,26 @@ rollback:
return false;
}
/** @brief Applique atomiquement la migration du schéma V3 vers V4. */
static bool database_migrate_v3_to_v4(Database *database)
{
bool transaction_started = false;
if (database == NULL || !database_transaction_begin(database))
return false;
transaction_started = true;
if (!schema_install_v4(database) ||
!database_update_schema_version(database, "4") ||
!database_transaction_commit(database))
goto rollback;
return true;
rollback:
if (transaction_started && !database_transaction_rollback(database))
g_warning("Impossible dannuler la migration SQLite V3 vers V4.");
return false;
}
/**
* @brief Garantit atomiquement la présence des extensions du schéma courant.
*/
@ -884,6 +904,12 @@ bool database_migrate_to_latest(
schema_version = 3;
break;
case 3:
if (!database_migrate_v3_to_v4(database))
return false;
schema_version = 4;
break;
default:
database_set_error(
database,
@ -1000,6 +1026,11 @@ bool database_initialize(
goto rollback;
}
if (!schema_install_v4(database))
{
goto rollback;
}
if (!schema_ensure_current(
database
))

View file

@ -204,6 +204,17 @@ bool schema_install_v3(
);
}
bool schema_install_v4(
Database *database
)
{
return schema_execute_file(
database,
"database/schema_v4.sql",
"la migration SQLite V4"
);
}
bool schema_ensure_current(
Database *database
)

View file

@ -0,0 +1,277 @@
/******************************************************************************
* @file create_social_account_dialog.c
* @brief Dialogue GTK de création d'un compte social observé.
******************************************************************************/
#include "views/create_social_account_dialog.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;
date = g_date_time_new_from_iso8601(observed_at, NULL);
if (platform >= G_N_ELEMENTS(platform_codes) ||
account_state >= G_N_ELEMENTS(state_codes) ||
url == NULL ||
(!g_str_has_prefix(url, "https://") && !g_str_has_prefix(url, "http://")) ||
username == NULL || username[0] == '\0' || date == NULL)
{
gtk_label_set_text(state->error,
"Renseignez une URL http(s), un pseudonyme et une date ISO 8601 valide.");
gtk_widget_set_visible(GTK_WIDGET(state->error), TRUE);
g_clear_pointer(&date, g_date_time_unref);
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");
gtk_window_set_transient_for(state->window, parent);
gtk_window_set_modal(state->window, TRUE);
gtk_window_set_default_size(state->window, 620, 560);
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));
g_object_unref(evidence_labels);
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);
gtk_window_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

@ -59,6 +59,7 @@ struct MainWindow
GtkWidget *new_investigation_button;
GtkWidget *open_investigation_button;
GtkWidget *import_evidence_button;
GtkWidget *add_social_account_button;
GtkWidget *show_graph_button;
GtkWidget *content_paned;
GtkWidget *main_paned;
@ -87,6 +88,9 @@ struct MainWindow
gpointer
import_evidence_user_data;
MainWindowAddSocialAccountCallback add_social_account_callback;
gpointer add_social_account_user_data;
MainWindowShowGraphCallback
show_graph_callback;
@ -294,6 +298,18 @@ static void main_window_on_import_evidence_clicked(
);
}
/** @brief Relaie la demande d'ajout d'un compte social. */
static void main_window_on_add_social_account_clicked(
GtkButton *button, gpointer user_data)
{
MainWindow *main_window = user_data;
(void) button;
if (main_window == NULL || main_window->add_social_account_callback == NULL)
return;
main_window->add_social_account_callback(
main_window->add_social_account_user_data);
}
/**
* @brief Transmet la demande de fermeture au contrôleur.
*
@ -594,6 +610,12 @@ MainWindow *main_window_new(
"Importer une preuve"
);
main_window->add_social_account_button =
main_window_create_action_button(
"contact-new-symbolic",
"Ajouter un compte social"
);
main_window->show_graph_button =
main_window_create_action_button(
"go-previous-symbolic",
@ -625,6 +647,8 @@ MainWindow *main_window_new(
FALSE
);
gtk_widget_set_sensitive(main_window->add_social_account_button, FALSE);
gtk_widget_set_sensitive(
main_window->show_graph_button,
FALSE
@ -645,6 +669,9 @@ MainWindow *main_window_new(
main_window->import_evidence_button
);
gtk_box_append(GTK_BOX(main_window->action_bar),
main_window->add_social_account_button);
gtk_box_append(
GTK_BOX(main_window->action_bar),
main_window->show_graph_button
@ -687,6 +714,9 @@ MainWindow *main_window_new(
main_window
);
g_signal_connect(main_window->add_social_account_button, "clicked",
G_CALLBACK(main_window_on_add_social_account_clicked), main_window);
g_signal_connect(
main_window->show_graph_button,
"clicked",
@ -1411,6 +1441,16 @@ void main_window_set_import_evidence_callback(
user_data;
}
void main_window_set_add_social_account_callback(
MainWindow *main_window,
MainWindowAddSocialAccountCallback callback,
gpointer user_data)
{
if (main_window == NULL) return;
main_window->add_social_account_callback = callback;
main_window->add_social_account_user_data = user_data;
}
void main_window_set_show_graph_callback(
MainWindow *main_window,
MainWindowShowGraphCallback callback,
@ -1446,6 +1486,14 @@ void main_window_set_import_evidence_enabled(
);
}
void main_window_set_add_social_account_enabled(
MainWindow *main_window, gboolean enabled)
{
if (main_window == NULL || main_window->add_social_account_button == NULL)
return;
gtk_widget_set_sensitive(main_window->add_social_account_button, enabled);
}
void main_window_set_verify_evidence_callback(
MainWindow *main_window,
MainWindowVerifyEvidenceCallback callback,

View file

@ -525,10 +525,11 @@ static void test_database_initialize_valid_database(void)
"FROM investigation;"
);
assert(strcmp(schema_version, "3") == 0);
assert(strcmp(schema_version, "4") == 0);
test_database_assert_table_exists(database, "osint_executions");
test_database_assert_table_exists(database, "osint_execution_entities");
test_database_assert_table_exists(database, "osint_execution_relations");
test_database_assert_table_exists(database, "comptes_sociaux");
assert(strcmp(application_name, "Labfy Investigation") == 0);
assert(created_at[0] != '\0');
@ -987,7 +988,7 @@ static void test_database_migrate_v1_to_v2(void)
assert(
strcmp(
schema_version,
"3"
"4"
) == 0
);

View file

@ -245,7 +245,11 @@ static void test_entity_type_dao_list_all(void)
"domain_name",
"ip_address",
"organization",
"other"
"other",
"tiktok_account",
"x_account",
"telegram_account",
"social_account"
};
static const char *const expected_labels[] =
@ -263,7 +267,11 @@ static void test_entity_type_dao_list_all(void)
"Nom de domaine",
"Adresse IP",
"Organisation",
"Autre"
"Autre",
"Compte TikTok",
"Compte X",
"Compte Telegram",
"Autre compte social"
};
TestEntityTypeDaoFixture fixture =

View file

@ -0,0 +1,75 @@
/******************************************************************************
* @file test_social_account_service.c
* @brief Tests transactionnels de création des comptes sociaux.
******************************************************************************/
#include "core/social_account_service.h"
#include "database/database.h"
#include "database/statement.h"
#include <assert.h>
#include <stdio.h>
#include <glib.h>
#include <glib/gstdio.h>
/** @brief Lit un comptage entier dans la base de test. */
static gint64 test_social_account_count(Database *database, const char *sql)
{
DatabaseStatement *statement = database_statement_prepare(database, sql);
int64_t count = -1;
assert(statement != NULL);
assert(database_statement_step(statement) == DATABASE_STATEMENT_STEP_ROW);
assert(database_statement_column_int64(statement, 0, &count));
database_statement_finalize(statement);
return count;
}
/** @brief Vérifie la création et le rollback d'un doublon. */
static void test_social_account_create_and_duplicate(void)
{
char *directory = NULL;
char *path = NULL;
char *identifier = NULL;
Database *database = NULL;
GError *error = NULL;
SocialAccountInput input = {
.platform = "tiktok",
.profile_url = "https://www.tiktok.com/@profil.test",
.username = "@profil.test",
.platform_identifier = "123456789",
.first_observed_at = "2026-07-22T10:00:00Z",
.account_state = "active",
.notes = "URL transmise par la victime.",
.evidence_identifier = NULL
};
directory = g_dir_make_tmp("labfy-social-account-test-XXXXXX", &error);
assert(directory != NULL && error == NULL);
path = g_build_filename(directory, "Enquete.sqlite", NULL);
assert(database_initialize(path, "Enquete_Social", directory));
database = database_open(path);
assert(database != NULL);
assert(database_migrate_to_latest(database));
assert(social_account_service_create(database, &input, &identifier, &error));
assert(identifier != NULL && error == NULL);
assert(test_social_account_count(database, "SELECT COUNT(*) FROM entites;") == 1);
assert(test_social_account_count(database, "SELECT COUNT(*) FROM comptes_sociaux;") == 1);
g_clear_pointer(&identifier, g_free);
assert(!social_account_service_create(database, &input, &identifier, &error));
assert(error != NULL);
assert(test_social_account_count(database, "SELECT COUNT(*) FROM entites;") == 1);
assert(test_social_account_count(database, "SELECT COUNT(*) FROM comptes_sociaux;") == 1);
g_clear_error(&error);
database_close(database);
assert(g_remove(path) == 0);
assert(g_rmdir(directory) == 0);
g_free(path);
g_free(directory);
}
int main(void)
{
test_social_account_create_and_duplicate();
puts("SocialAccountService : tous les tests sont valides.");
return 0;
}