Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/surveillance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Surveillance de l'application

# Toutes les 30 minutes, lit l'état de santé de l'application (public/sante.php : base, tâche quotidienne,
# sauvegarde, registre horodaté, horodatage, autorité d'horodatage). En cas d'alerte ou d'absence de réponse
# après trois essais, le job échoue : GitHub prévient par e-mail la personne qui a activé le workflow planifié.
on:
schedule:
- cron: "7,37 * * * *"
workflow_dispatch:

permissions:
contents: read

jobs:
sante:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: État de santé
run: |
set -u
url="https://manifesto-sign.otspi.org/sante.php"
for essai in 1 2 3; do
code=$(curl -sS -m 25 -o sante.json -w '%{http_code}' "$url" || echo 000)
if [ "$code" = 200 ]; then
cat sante.json
exit 0
fi
echo "Essai $essai : HTTP $code"
[ -s sante.json ] && cat sante.json
sleep 20
done
echo "::error title=Application de signature en alerte::$url ne répond pas ou signale un problème (voir le journal)"
exit 1
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ Protections : jeton de formulaire signé avec délai minimal, à usage unique (a

Mesure d'audience : seuls le formulaire (`index.php`) et la page « signature confirmée » chargent `assets/analytics.js` (Matomo Tag Manager de `stats.otspi.org`, sans cookie) et ouvrent leur CSP à ce domaine. Le formulaire charge aussi `assets/pow.js` (preuve de travail, sans appel extérieur). Les pages dont l'adresse porte un jeton personnel (lien de confirmation, retrait, modération) ne sont pas mesurées. Le formulaire déclare aussi des événements anonymes (attributs `data-track` et `data-track-load`, voir `analytics.js`) : clic sur « Envoyer », code d'erreur éventuel (`err_…`, jamais les valeurs saisies), page « demande envoyée » et page « signature confirmée » (réponse au POST de confirmation, dont l'adresse `confirm.php?lang=…` ne porte pas le jeton). `analytics.js` est commun aux sites d'OTSPI : sa source est dans le dépôt `vitrine` ; après une copie, incrémenter `?v=` dans `page()` (`src/lib.php`).

## Surveillance et données personnelles dans la durée

- **État de santé** : `public/sante.php` renvoie en JSON, sans donnée personnelle, l'état de la base, de la tâche quotidienne, de la dernière sauvegarde, du registre horodaté, de l'horodatage des signatures et de l'autorité d'horodatage (interrogée au plus toutes les 5 minutes) ; HTTP 200 si tout va bien, 503 sinon. Un contrôle jamais encore exécuté est « en attente ».
- **Surveillance externe** : `.github/workflows/surveillance.yml` lit cette page toutes les 30 minutes (trois essais) ; en cas d'alerte, le job échoue et GitHub prévient par e-mail la personne qui a activé le workflow planifié.
- **Durée de conservation** : `campagne_fin` (`config.php`, AAAA-MM-JJ) déclenche la suppression de toutes les signatures deux ans après, comme l'annonce le texte d'information, avec un avis à l'adresse `contact` 30 et 7 jours avant.
- **Droit d'accès** : la page de retrait propose de télécharger, en JSON, toutes les données enregistrées sur la personne avant de décider.

## Installation

1. Copier le dépôt hors du dossier web, le dossier `public/` étant la racine du site. Les dossiers `src/`, `data/`, `bin/` et `cron/` ne doivent pas être servis.
Expand Down
3 changes: 3 additions & 0 deletions config.example.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
// 'backup_certificate' => __DIR__ . '/src/sauvegarde-certificat.pem',
// Signatures à modérer : 'quotidien' (défaut, un récapitulatif par nuit) ou 'immediat' (un e-mail par signature).
// 'moderation_notify' => 'quotidien',
// Fin de la campagne (AAAA-MM-JJ) : toutes les signatures sont supprimées deux ans après, comme l'annonce le
// texte d'information ; avis à l'adresse de contact 30 et 7 jours avant. Vide : campagne en cours.
// 'campagne_fin' => '2027-12-31',
// Pour les essais : ne pas envoyer, journaliser dans mail_log.
'mail_dry_run' => false,
'mail_log' => __DIR__ . '/data/mail.log',
Expand Down
8 changes: 7 additions & 1 deletion cron/purge.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// de limitation de débit, invitations, défis et sessions d'administration périmés ; horodate les signatures qui
// ne le sont pas encore et envoie leur preuve (src/horodatage.php) ; inscrit l'entrée du jour au registre
// horodaté (src/registre.php) ; envoie le récapitulatif des signatures à modérer et la sauvegarde chiffrée de la
// base (src/sauvegarde.php). Au moindre
// base (src/sauvegarde.php) ; applique la durée de conservation (campagne_fin). Au moindre
// problème, une alerte part à l'adresse de contact (au plus une par passage).

declare(strict_types=1);
Expand Down Expand Up @@ -67,6 +67,12 @@
$problems[] = 'Sauvegarde : la sauvegarde chiffrée de la base n\'a pas pu être envoyée.';
}

$retention = $step('durée de conservation', 'retention_check');
if (is_string($retention)) {
echo date('c') . " conservation : $retention\n";
}

etat_set('tache', $problems === [] ? 'ok' : count($problems) . ' problème(s)');
if ($problems !== []) {
send_mail(config()['contact'], 'Alerte : tâche quotidienne des signatures',
"La tâche quotidienne de l'application de signature a rencontré un problème :\n\n"
Expand Down
69 changes: 69 additions & 0 deletions public/sante.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
// État de santé de l'application, pour la surveillance externe (.github/workflows/surveillance.yml) : base,
// tâche quotidienne, sauvegarde, registre horodaté, horodatage des signatures, autorité d'horodatage.
// JSON sans aucune donnée personnelle ; HTTP 200 si tout va bien, 503 sinon. Un contrôle jamais encore exécuté
// (juste après une mise en service) est « en attente », pas en échec.

declare(strict_types=1);
require __DIR__ . '/../src/lib.php';
require __DIR__ . '/../src/horodatage.php';

const MAX_AGE = 93600; // 26 h : la tâche quotidienne a au plus deux heures de retard
const STAGING_CHECK_TTL = 300; // l'autorité d'horodatage n'est interrogée qu'une fois toutes les 5 minutes

$iso = static fn (?int $t): ?string => $t === null ? null : gmdate('Y-m-d\TH:i:s\Z', $t);
$recent = static function (?array $state) use ($iso): array {
return $state === null
? ['ok' => true, 'etat' => 'en attente du premier passage', 'derniere' => null]
: ['ok' => time() - (int) $state['at'] <= MAX_AGE, 'etat' => $state['valeur'], 'derniere' => $iso((int) $state['at'])];
};

$checks = [];
try {
db()->query('SELECT 1 FROM signatures LIMIT 1')->fetchColumn();
$checks['base'] = ['ok' => true];
} catch (Throwable $e) {
error_log('otspi-signatures : santé, base inaccessible : ' . $e->getMessage());
http_response_code(503);
header('Content-Type: application/json; charset=UTF-8');
header('Cache-Control: no-store');
exit(json_encode(['statut' => 'alerte', 'controles' => ['base' => ['ok' => false]]]));
}

$task = etat_get('tache');
$checks['tache_quotidienne'] = $recent($task);
if ($task !== null && $task['valeur'] !== 'ok') {
$checks['tache_quotidienne']['ok'] = false;
}
$checks['sauvegarde'] = $recent(etat_get('sauvegarde'));

$last = db()->query('SELECT jour, jeton IS NOT NULL AS horodatee FROM registre ORDER BY numero DESC LIMIT 1')->fetch();
$checks['registre'] = $last === false
? ['ok' => true, 'etat' => 'en attente de la première entrée']
: ['ok' => $last['jour'] >= date('Y-m-d', time() - 86400) && (int) $last['horodatee'] === 1, 'derniere_entree' => $last['jour']];

$late = db()->prepare('SELECT COUNT(*) FROM signatures WHERE confirmed_at IS NOT NULL AND confirmed_at < ? AND proof_token IS NULL');
$late->execute([time() - 86400]);
$lateCount = (int) $late->fetchColumn();
// Le rattrapage passe par la tâche quotidienne : tant qu'elle n'a jamais tourné, un retard n'est pas une panne.
$checks['horodatage_des_signatures'] = ['ok' => $lateCount === 0 || $task === null, 'en_attente_depuis_24h' => $lateCount];

// Autorité d'horodatage : résultat mis en cache, pour qu'appeler cette page ne revienne pas à solliciter le staging.
$cached = etat_get('autorite');
if ($cached === null || time() - (int) $cached['at'] > STAGING_CHECK_TTL) {
$context = stream_context_create(['http' => ['method' => 'GET', 'timeout' => 4, 'ignore_errors' => true]]);
$body = @file_get_contents(tsa_url() . '/api/v1/policy', false, $context);
$up = is_string($body) && is_array(json_decode($body, true));
etat_set('autorite', $up ? 'joignable' : 'injoignable');
$cached = etat_get('autorite');
}
$checks['autorite_horodatage'] = ['ok' => $cached['valeur'] === 'joignable', 'etat' => $cached['valeur'], 'verifiee' => $iso((int) $cached['at'])];

$ok = !in_array(false, array_column($checks, 'ok'), true);
http_response_code($ok ? 200 : 503);
header('Content-Type: application/json; charset=UTF-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
echo json_encode(['statut' => $ok ? 'ok' : 'alerte', 'verifie_le' => $iso(time()), 'controles' => $checks],
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
26 changes: 23 additions & 3 deletions public/withdraw.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,31 @@
exit;
}
} elseif ($valid) {
$exists = $pdo->prepare('SELECT 1 FROM signatures WHERE withdraw_hash = ?');
$exists->execute([$hash]);
if ($exists->fetchColumn()) {
$row = $pdo->prepare('SELECT email, prenom, nom, fonction, organisation, publier, lang, created_at, confirmed_at, approved_at, proof_at, proof_json FROM signatures WHERE withdraw_hash = ?');
$row->execute([$hash]);
$signature = $row->fetch();
if ($signature !== false && ($_GET['f'] ?? '') === 'json') {
// Droit d'accès et de portabilité : toutes les données enregistrées, sans les empreintes de jetons.
$iso = static fn ($t): ?string => $t === null ? null : gmdate('Y-m-d\TH:i:s\Z', (int) $t);
header('Content-Type: application/json; charset=UTF-8');
header('Content-Disposition: attachment; filename="mes-donnees-signature-otspi.json"');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
echo json_encode([
'responsable' => 'Initiative OTSPI — contact@otspi.org',
'email' => $signature['email'], 'prenom' => $signature['prenom'], 'nom' => $signature['nom'],
'fonction' => $signature['fonction'], 'organisation' => $signature['organisation'],
'publication_acceptee' => (int) $signature['publier'] === 1, 'langue' => $signature['lang'],
'demande_le' => $iso($signature['created_at']), 'confirmee_le' => $iso($signature['confirmed_at']),
'moderee_le' => $iso($signature['approved_at']), 'horodatee_le' => $iso($signature['proof_at']),
'attestation_horodatee' => $signature['proof_json'] === null ? null : json_decode($signature['proof_json'], true),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if ($signature !== false) {
page(t('withdraw_title', $lang),
'<h1>' . h(t('withdraw_title', $lang)) . '</h1><p>' . h(t('withdraw_ask', $lang)) . '</p>'
. '<p>' . h(t('withdraw_export', $lang)) . ' <a href="?' . h(http_build_query(['t' => $token, 'lang' => $lang, 'f' => 'json'])) . '">' . h(t('withdraw_export_link', $lang)) . '</a></p>'
. '<form method="post" action="?lang=' . $lang . '"><input type="hidden" name="t" value="' . h($token) . '">'
. '<button type="submit">' . h(t('withdraw_button', $lang)) . '</button></form>', $lang);
exit;
Expand Down
49 changes: 49 additions & 0 deletions src/lib.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ function db(): PDO
jeton TEXT,
horodate_le INTEGER
);
CREATE TABLE IF NOT EXISTS etat (
cle TEXT PRIMARY KEY,
valeur TEXT NOT NULL,
at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS form_tokens (
hash TEXT PRIMARY KEY,
at INTEGER NOT NULL
Expand Down Expand Up @@ -326,6 +331,50 @@ function moderate(int $id, string $action): bool
return $statement->rowCount() === 1;
}

// ---- État de fonctionnement et conservation --------------------------------------------------

/** Dernier état connu d'une tâche (dernière sauvegarde, dernier passage de la tâche quotidienne…). */
function etat_set(string $cle, string $valeur): void
{
db()->prepare('INSERT INTO etat (cle, valeur, at) VALUES (?, ?, ?) ON CONFLICT(cle) DO UPDATE SET valeur = excluded.valeur, at = excluded.at')
->execute([$cle, $valeur, time()]);
}

function etat_get(string $cle): ?array
{
$row = db()->prepare('SELECT valeur, at FROM etat WHERE cle = ?');
$row->execute([$cle]);
return $row->fetch() ?: null;
}

/**
* Durée de conservation annoncée dans le texte d'information : les signatures sont gardées pendant la campagne
* (fin : campagne_fin, AAAA-MM-JJ) puis deux ans au plus. Le jour venu, toutes les signatures et demandes sont
* supprimées ; 30 et 7 jours avant, un avis part à l'adresse de contact. Renvoie le message à journaliser, ou null.
*/
function retention_check(): ?string
{
$end = (string) (config()['campagne_fin'] ?? '');
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $end) !== 1) {
return null;
}
$deadline = (new DateTimeImmutable($end))->modify('+2 years');
$days = (int) (new DateTimeImmutable('today'))->diff($deadline)->format('%r%a');
if ($days <= 0) {
$count = db()->exec('DELETE FROM signatures');
$message = "Fin de la durée de conservation (campagne close le $end, plus deux ans) : $count signature(s) et demande(s) supprimée(s).";
send_mail(config()['contact'], 'Signatures supprimées : fin de la durée de conservation', $message . "\n\nOTSPI — contact@otspi.org");
return $message;
}
if (in_array($days, [30, 7], true)) {
$message = "Les signatures seront supprimées dans $days jours ({$deadline->format('d/m/Y')}), fin de la durée de conservation annoncée "
. "(campagne close le $end, plus deux ans). Pour prolonger, il faudrait recueillir à nouveau le consentement des signataires.";
send_mail(config()['contact'], "Suppression des signatures dans $days jours", $message . "\n\nOTSPI — contact@otspi.org");
return $message;
}
return null;
}

// ---- Adresse et organisation -----------------------------------------------------------------

const FREE_MAIL = ['gmail', 'googlemail', 'outlook', 'hotmail', 'live', 'msn', 'yahoo', 'ymail', 'icloud', 'me', 'mac', 'aol',
Expand Down
2 changes: 2 additions & 0 deletions src/sauvegarde.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ function backup_send(): ?string
. "OTSPI — contact@otspi.org", 'fr', [$name => $data]);
if (!$sent) {
error_log('otspi-signatures : sauvegarde non envoyée');
} else {
etat_set('sauvegarde', $name);
}
return $sent ? $name : null;
} finally {
Expand Down
4 changes: 3 additions & 1 deletion src/texts.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,11 @@
'confirm_invalid' => ['fr' => 'Ce lien n’est plus valide. Vous pouvez signer à nouveau depuis le formulaire.', 'en' => 'This link is no longer valid. You can sign again using the form.'],
'withdraw_title' => ['fr' => 'Retirer ma signature', 'en' => 'Withdraw my signature'],
'withdraw_ask' => ['fr' => 'Confirmez-vous le retrait de votre signature et la suppression de vos données ?', 'en' => 'Do you confirm withdrawing your signature and deleting your data?'],
'withdraw_export' => ['fr' => 'Avant de décider, vous pouvez télécharger les données enregistrées à votre sujet :', 'en' => 'Before deciding, you can download the data stored about you:'],
'withdraw_export_link' => ['fr' => 'Télécharger mes données (JSON)', 'en' => 'Download my data (JSON)'],
'withdraw_button' => ['fr' => 'Retirer et supprimer mes données', 'en' => 'Withdraw and delete my data'],
'withdrawn_title' => ['fr' => 'Signature retirée', 'en' => 'Signature withdrawn'],
'withdrawn' => ['fr' => 'Votre signature et vos données ont été supprimées. Le retrait de la liste publique est effectif au prochain rafraîchissement du site (sous 24 heures).', 'en' => 'Your signature and your data have been deleted. Removal from the public list takes effect at the next refresh of the website (within 24 hours).'],
'withdrawn' => ['fr' => 'Votre signature et vos données ont été supprimées. Votre nom disparaît de la liste publique d’ici quelques minutes.', 'en' => 'Your signature and your data have been deleted. Your name will disappear from the public list within a few minutes.'],
'mail_confirm_subject' => ['fr' => 'Confirmez votre signature du manifeste OTSPI', 'en' => 'Confirm your signature of the OTSPI manifesto'],
// Pas de nom en tête de message : les champs saisis ne figurent que dans le récapitulatif, présenté
// comme tel, pour que le site ne serve pas à faire parvenir un texte libre à une adresse quelconque.
Expand Down
Loading
Loading