diff --git a/Makefile b/Makefile index b4c02e6..a274144 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index 8dd2c00..37a18e8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/database/schema_current.sql b/database/schema_current.sql index ed76ff1..630d153 100644 --- a/database/schema_current.sql +++ b/database/schema_current.sql @@ -1,7 +1,7 @@ /****************************************************************************** * Labfy Investigation * - * Extensions idempotentes du schéma SQLite courant V3 + * Extensions idempotentes du schéma SQLite courant V4 ******************************************************************************/ /* diff --git a/database/schema_v4.sql b/database/schema_v4.sql new file mode 100644 index 0000000..4afa899 --- /dev/null +++ b/database/schema_v4.sql @@ -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); diff --git a/docs/database/DATABASE_ARCHITECTURE.md b/docs/database/DATABASE_ARCHITECTURE.md index 93e7529..aa0d9aa 100644 --- a/docs/database/DATABASE_ARCHITECTURE.md +++ b/docs/database/DATABASE_ARCHITECTURE.md @@ -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 diff --git a/include/core/social_account_service.h b/include/core/social_account_service.h new file mode 100644 index 0000000..736bafd --- /dev/null +++ b/include/core/social_account_service.h @@ -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 + +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 diff --git a/include/database/schema.h b/include/database/schema.h index 46bab7d..39d45d4 100644 --- a/include/database/schema.h +++ b/include/database/schema.h @@ -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 été appliquée, sinon false. + */ +bool schema_install_v4( + Database *database +); + /** * @brief Garantit la présence des extensions du schéma courant V2. * diff --git a/include/views/create_social_account_dialog.h b/include/views/create_social_account_dialog.h new file mode 100644 index 0000000..51a487d --- /dev/null +++ b/include/views/create_social_account_dialog.h @@ -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 + +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 diff --git a/include/views/main_window.h b/include/views/main_window.h index feefb4d..15d817a 100644 --- a/include/views/main_window.h +++ b/include/views/main_window.h @@ -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. * diff --git a/src/core/application.c b/src/core/application.c index 4e5c944..3e422c5 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -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 #include @@ -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, diff --git a/src/core/social_account_service.c b/src/core/social_account_service.c new file mode 100644 index 0000000..0debe5a --- /dev/null +++ b/src/core/social_account_service.c @@ -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 + +/** @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; +} diff --git a/src/database/database.c b/src/database/database.c index 61536e8..a0c634e 100644 --- a/src/database/database.c +++ b/src/database/database.c @@ -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 d’annuler 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 )) diff --git a/src/database/schema.c b/src/database/schema.c index 874e707..a08768d 100644 --- a/src/database/schema.c +++ b/src/database/schema.c @@ -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 ) diff --git a/src/views/create_social_account_dialog.c b/src/views/create_social_account_dialog.c new file mode 100644 index 0000000..ba05035 --- /dev/null +++ b/src/views/create_social_account_dialog.c @@ -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) diff --git a/src/views/main_window.c b/src/views/main_window.c index 9fbd5ab..e8e8845 100644 --- a/src/views/main_window.c +++ b/src/views/main_window.c @@ -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, diff --git a/tests/test_database.c b/tests/test_database.c index a4e2373..c96802f 100644 --- a/tests/test_database.c +++ b/tests/test_database.c @@ -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 ); diff --git a/tests/test_entity_type_dao.c b/tests/test_entity_type_dao.c index 781e889..ec60111 100644 --- a/tests/test_entity_type_dao.c +++ b/tests/test_entity_type_dao.c @@ -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 = diff --git a/tests/test_social_account_service.c b/tests/test_social_account_service.c new file mode 100644 index 0000000..887b9c1 --- /dev/null +++ b/tests/test_social_account_service.c @@ -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 +#include + +#include +#include + +/** @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; +}