Globinours/app/Services/PrivacyService.php

188 lines
8.6 KiB
PHP

<?php
declare(strict_types=1);
final class PrivacyService
{
public static function logAccess(
string $type,
?int $id,
?int $animalId,
string $name,
string $action = 'view',
): void {
DB::pdo()
->prepare(
'INSERT INTO private_file_access_log(user_id,resource_type,resource_id,animal_id,action,original_name,ip_address,user_agent) VALUES(?,?,?,?,?,?,?,?)',
)
->execute([
Auth::id(),
$type,
$id,
$animalId,
$action,
mb_substr($name, 0, 240),
$_SERVER['REMOTE_ADDR'] ?? null,
mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500) ?: null,
]);
}
public static function accessLog(int $limit = 500): array
{
return DB::pdo()
->query(
'SELECT l.*,COALESCE(u.display_name,u.username) actor,a.name animal_name FROM private_file_access_log l LEFT JOIN users u ON u.id=l.user_id LEFT JOIN animals a ON a.id=l.animal_id ORDER BY l.id DESC LIMIT ' .
max(1, min(2000, $limit)),
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function retentionReview(int $years): array
{
$rows = DB::pdo()
->query(
"SELECT dc.id,dc.name,dc.email,dc.phone,dc.updated_at,group_concat(DISTINCT r.role) roles,MAX(COALESCE(ad.adoption_date,ap.event_date,dc.updated_at)) last_activity,(SELECT COUNT(*) FROM animal_placements active JOIN animals a ON a.id=active.animal_id WHERE active.contact_id=dc.id AND active.event_type='foster' AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status)='fa') active_links FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id LEFT JOIN adoptions ad ON ad.adopter_contact_id=dc.id LEFT JOIN animal_placements ap ON ap.contact_id=dc.id WHERE dc.deleted_at IS NULL AND dc.kind='person' AND dc.anonymized_at IS NULL GROUP BY dc.id ORDER BY date(last_activity),dc.name COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
$out = [];
foreach ($rows as $row) {
$roles = array_filter(explode(',', (string) $row['roles']));
$limits = [];
foreach ($roles as $role) {
$limits[] = match ($role) {
'adoptant' => AppSettings::int('privacy_retention_adopter_years', 1, 30),
'fa' => AppSettings::int('privacy_retention_foster_years', 1, 30),
'benevole' => AppSettings::int('privacy_retention_volunteer_years', 1, 30),
default => $years,
};
}
$limit = $limits ? max($limits) : $years;
$last = strtotime((string) $row['last_activity']) ?: time();
if ($last > strtotime('-' . $limit . ' years')) {
continue;
}
$row['policy_years'] = $limit;
$row['blocked'] = (int) $row['active_links'] > 0;
$out[] = $row;
}
return $out;
}
public static function exportContact(int $id): string
{
$db = DB::pdo();
$s = $db->prepare(
"SELECT dc.*,group_concat(r.role,',') roles,org.name organization_name FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id LEFT JOIN directory_contacts org ON org.id=dc.organization_id WHERE dc.id=? AND dc.deleted_at IS NULL GROUP BY dc.id",
);
$s->execute([$id]);
$contact = $s->fetch(PDO::FETCH_ASSOC);
if (!$contact) {
throw new RuntimeException(t('privacy.contact_not_found'));
}
$queries = [
'adoptions' =>
'SELECT a.internal_code,a.name animal,ad.adoption_date,ad.notes FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE ad.adopter_contact_id=? ORDER BY ad.adoption_date',
'placements' =>
'SELECT a.internal_code,a.name animal,ap.event_type,ap.event_date,ap.reason,ap.notes FROM animal_placements ap JOIN animals a ON a.id=ap.animal_id WHERE ap.contact_id=? ORDER BY ap.event_date,ap.id',
'deposited_animals' =>
'SELECT internal_code,name,intake_date,intake_type,intake_reason,intake_circumstances FROM animals WHERE depositor_contact_id=? ORDER BY intake_date',
];
$payload = ['generated_at' => new DateTimeImmutable()->format(DATE_ATOM), 'contact' => $contact];
foreach ($queries as $key => $sql) {
$q = $db->prepare($sql);
$q->execute([$id]);
$payload[$key] = $q->fetchAll(PDO::FETCH_ASSOC);
}
$tmp = tempnam(sys_get_temp_dir(), 'globinours-contact-');
if ($tmp === false) {
throw new RuntimeException(t('privacy.export_failed'));
}
@unlink($tmp);
$path = $tmp . '.zip';
$zip = new ZipArchive();
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException(t('privacy.export_failed'));
}
$zip->addFromString(
'contact.json',
json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
);
foreach (['adoptions', 'placements', 'deposited_animals'] as $key) {
$zip->addFromString($key . '.csv', self::csv($payload[$key]));
}
$zip->addFromString(
'README.txt',
"Export de portabilité Globinours\nGénéré le " .
date('d/m/Y H:i') .
"\nDonnées à conserver dans un emplacement sécurisé.\n",
);
$zip->close();
return $path;
}
public static function anonymize(int $id, string $reason): void
{
$reason = trim($reason);
if ($reason === '') {
throw new RuntimeException(t('privacy.reason_required'));
}
$db = DB::pdo();
$s = $db->prepare(
"SELECT * FROM directory_contacts WHERE id=? AND deleted_at IS NULL AND kind='person' AND anonymized_at IS NULL",
);
$s->execute([$id]);
$contact = $s->fetch(PDO::FETCH_ASSOC);
if (!$contact) {
throw new RuntimeException(t('privacy.contact_not_found'));
}
$backup = BackupService::create('avant-anonymisation-contact-' . $id);
$label = t('privacy.anonymous_contact') . ' #' . $id;
$db->beginTransaction();
try {
$db->prepare(
"UPDATE directory_contacts SET name=?,phone=NULL,email=NULL,address=NULL,postal_code=NULL,city=NULL,country='France',organization_id=NULL,notes=NULL,anonymized_at=datetime('now'),anonymization_reason=?,updated_at=datetime('now') WHERE id=?",
)->execute([$label, $reason, $id]);
$db->prepare(
'UPDATE adoptions SET adopter_name=?,adopter_phone=NULL,adopter_email=NULL,adopter_address=NULL,adopter_postal_code=NULL,adopter_city=NULL WHERE adopter_contact_id=?',
)->execute([$label, $id]);
foreach ([(string) $contact['name']] as $old) {
$db->prepare(
'UPDATE animal_movements SET contact_name=? WHERE lower(trim(contact_name))=lower(trim(?))',
)->execute([$label, $old]);
}
$db->commit();
AuditService::log(
'contact_anonymized',
'/settings/privacy/anonymize',
'Contact anonymisé',
'contact',
$id,
['reason' => $reason, 'backup' => $backup['name'] ?? null],
);
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
public static function maintenance(): array
{
$months = AppSettings::int('privacy_access_log_months', 1, 120);
$s = DB::pdo()->prepare("DELETE FROM private_file_access_log WHERE datetime(accessed_at)<datetime('now',?)");
$s->execute(['-' . $months . ' months']);
return ['access_logs_removed' => $s->rowCount(), 'retention_months' => $months];
}
private static function csv(array $rows): string
{
if (!$rows) {
return "\xEF\xBB\xBF";
}
$stream = fopen('php://temp', 'r+');
fwrite($stream, "\xEF\xBB\xBF");
fputcsv($stream, array_keys($rows[0]), ';');
foreach ($rows as $row) {
fputcsv($stream, array_values($row), ';');
}
rewind($stream);
$out = stream_get_contents($stream);
fclose($stream);
return (string) $out;
}
}