diff --git a/TODO.md b/TODO.md index 0779163..4f8b65d 100644 --- a/TODO.md +++ b/TODO.md @@ -1,76 +1,37 @@ # TODO -- [x] Fix email addresses in about.php contacts section not using EmailObfuscator for link text -- [x] Raise rate limits: SearchController 30→300, request-access 3→30, partage 5→50 -- [x] Make Libre option toggleable in Degré d'ouverture fieldset, move to top, remove temporary note -- [x] Mots-clés required (min 3) in partage form: red count < 3, accent ≥ 3 -- [x] Language checkbox-list no longer required when language_autre pill is present -- [x] Admin contenus: auto-save checkboxes via HTMX (Restrictions, Degré d'ouverture, Types de travaux), remove Enregistrer buttons -- [x] Improve recapitulatif.php (partage): bottom margin/padding, center .thanks-success -- [x] Display ALL submitted info in recapitulatif page + email recap -- [x] Add "validate your info / contact xamxam@erg.be" note on recap page -- [x] Fix CSV import: lecteur interne/externe + promoteurice ULB not imported with correct role/is_external/is_ulb flags -- [x] Add "Terminé" button to import dialog on success (closes dialog + reloads page to clear form) +## SQLite Backup & Data Integrity (docs/backup-plan.md) -- [x] Replace HTMX+PHP file upload queues with client-side JS -- [x] Fix submit button on all forms — add JS/PHP debug logging - - [x] Fix file-upload-queue.js: redirect detection broken due to opaque redirect (switched from fetch to XHR for reliable responseURL) - - [x] Add `console.log` tracing on JS submit interception - - [x] Add `error_log` entry-point logging to all 16 PHP action files - - [x] Add double-submit guard (`_xamxamActiveSubmit`) -- [x] Fix spurious HTMX console warnings from checkbox-list default hx-include -- [x] Fix duplicate language entries (accented vs non-accented variants) -- [x] Fix checkbox click in admin index navigating to recapitulatif instead of toggling - - [x] Deduplicate getPredefinedLanguages() query - - [x] Accent-tolerant getOrCreateLanguage() to prevent future duplicates - - [x] Delete orphan non-accented language rows from DB -- [x] Migrate file upload queues to FilePond - - [x] Download filepond.min.js + filepond.min.css as local assets - - [x] Create file-upload-filepond.js (init script for FilePond instances) - - [x] Rewrite fichiers-fragment.php: replace custom picker/queue DOM with FilePond targets - - [x] Rewrite fieldset-files.php: same migration (dead code but kept consistent) - - [x] Update admin/add.php, admin/edit.php, partage/index.php: swap sortable+file-upload-queue for filepond - - [x] Remove file-upload-queue.js and sortable.min.js - - [x] Clean up CSS: remove .fq-*, .tfe-file-queue styles, add filepond.css + theme overrides - - [x] Decouple format extras from main file inputs — slot-based HTMX swaps preserve FilePond instances - - [x] Fix initFilePonds → window.XamxamInitFilePonds bug - - [x] Verify backend $_FILES['queue_file'][*] data flow unchanged - - [x] Add FilePond pools for couverture + note_intention (extracted from file-field.php inner
) - - [x] Fix video/audio pools: allowMultiple: true, not single-file - - [x] Add QUEUE_CONFIG for cover (20MB single) and note_intention (100MB PDF single) -- [x] Disable dedicated video/audio upload slots — video/audio files now go through TFE FilePond input - - [x] Comment out slot-video and slot-audio in fichiers-fragment.php (keep code, render always-hidden) - - [x] Remove HTMX swap triggers from Vidéo/Audio checkboxes - - [x] Clean up slot-video/slot-audio from file-upload-filepond.js beforeSwap handler - - [x] Fix missing endif after removing elseif chain (parse error) -- [x] Fix annexe validation error + FilePond type validation + styling - - [x] Make annexe pool always visible (remove checkbox+HTMX swap, always on, optional) - - [x] Remove mandatory annexe file validation from ThesisCreateController - - [x] Add extension-based file type validation in beforeAddFile (needed because storeAsFile: true skips FilePond MIME detection) - - [x] Fix FilePond dark theme: override item/file colors, buttons, progress indicator to match site theme - - [x] Add drag-over highlight style for drop area -- [x] FilePond production hardening - - [x] Fix beforeAddFile return format: return true/false, not {status, main, sub} (FilePond API contract) - - [x] Replace manual validation with FilePond plugins: FileValidateType, FileValidateSize - - [x] Download FilePond plugin assets: file-validate-type, file-validate-size, image-preview, image-exif-orientation - - [x] Add order serialization: hidden inputs (queue_order[type]) synced from pond.getFiles() - - [x] Fix HTMX cleanup: generic destroyFilePondsIn(target) for all beforeSwap events, not just known IDs - - [x] Fix duplicate initialization: use FilePond.find(input) instead of dataset checks - - [x] Centralize validation config in QUEUE_CONFIG (acceptedFileTypes, maxFileSize per type) - - [x] Add per-extension size limits for TFE queue (PDF=100MB, video/audio=2GB, default 500MB) - - [x] Add comprehensive French labels (labelFileProcessing, labelTapToCancel, etc.) - - [x] Register plugins on all entrypoints (admin/add, admin/edit, partage/index) - - [x] Remove duplicate init scripts from fichiers-fragment.php - - [x] Server-side MIME verification already in place (finfo-based validation in ThesisFileHandler) -- [x] Fix undefined $isExternalUrl and disable PeerTube in tfe.php -- [x] Fix migration 028: drop banner_path from theses (handle dependent view) - - [x] Create ensure-db.php to init fresh DB from schema.sql when missing - - [x] Remove broken 027_drop_banner_path.sql, move 025 to applied - - [x] Move stray 021_peertube_settings.sql to applied/ - - [x] Update deploy justfile to run ensure-db.php before migrations -- [x] Fix promoteurice array repopulation in partage form - - [x] Fix old() to return raw arrays (not json_encode) for repopulation - - [x] Handle jury_promoteur[] and jury_promoteur_ulb_name[] as arrays in partage/index.php -- [x] Make Auteur(s) and Accès columns sortable alphabetically in admin list -- [x] Merge both .recap-section sections into one + add margin-bottom: var(--space-l) -- [x] Fix Fatal error: old() type error in jury-fieldset.php — switch from global old() to $oldFn callable +### Phase 1 — WAL Mode +- [x] WAL mode already active (`PRAGMA journal_mode` → `wal`) — set in Database constructor +- [ ] Verify `-wal` and `-shm` sidecar files exist after writes +- [ ] Verify nginx/PHP write access to sidecar files on server + +### Phase 2 — Audit Log +- [x] `admin_audit_log` table already exists (migration 009), `AdminLogger` already writes to it +- [x] Create the `audit_log` table for data-level audit (before/after row snapshots) +- [x] Create `Audit.php` helper class +- [x] Instrument all DELETE, UPDATE, INSERT operations on core tables (theses, tags, languages, thesis_files) +- [ ] Verify by triggering a test delete and querying `SELECT * FROM audit_log ORDER BY id DESC LIMIT 5` + +### Phase 3 — Soft Deletes +- [x] Add `deleted_at` columns to `languages`, `tags`, `theses` +- [x] Rebuild views `v_theses_full` and `v_theses_public` with `deleted_at IS NULL` filters +- [x] Update `schema.sql` for fresh installs +- [x] Replace all hard DELETEs with soft deletes (`DELETE` → `UPDATE ... SET deleted_at = ...`) +- [x] Add `deleted_at IS NULL` to all SELECT queries touching these tables +- [x] Add admin "Corbeille" view for soft-deleted theses with Restore and Hard Delete actions +- [ ] Test each htmx-driven element (language search, tag search, repertoire filters) to confirm deleted entries don't appear +- [ ] Admin: add soft-deleted tags/languages view with restore option + +### Phase 4 — Hourly Snapshots via Cronjob +- [x] Create `scripts/backup-sqlite.sh` (hot backup via `sqlite3 .backup`, gzip, retention pruning) +- [x] Test locally — backup created, restores correctly +- [x] Add `just backup-snapshot` command for local ad-hoc backups +- [ ] Deploy backup script to server (`/usr/local/bin/backup-sqlite.sh`) +- [ ] Create `/var/backups/xamxam/` directory on server +- [ ] Add cron jobs (hourly 30d + daily 90d) +- [ ] Test restore from production backup + +### Phase 5 — Remote Sync *(for later)* +- [ ] (Deferred) diff --git a/app/migrations/applied/026_audit_log.sql b/app/migrations/applied/026_audit_log.sql new file mode 100644 index 0000000..be77c0a --- /dev/null +++ b/app/migrations/applied/026_audit_log.sql @@ -0,0 +1,16 @@ +-- Migration 026: create audit_log table for data-level audit trail +-- Records before/after snapshots of every row mutation on core tables. +-- Admin actions are already logged separately via admin_audit_log. +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + actor TEXT NOT NULL, + action TEXT NOT NULL CHECK(action IN ('INSERT','UPDATE','DELETE')), + table_name TEXT NOT NULL, + record_id INTEGER, + old_data TEXT, + new_data TEXT +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_table_record ON audit_log(table_name, record_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp); diff --git a/app/migrations/applied/027_soft_deletes.sql b/app/migrations/applied/027_soft_deletes.sql new file mode 100644 index 0000000..5ecf1ca --- /dev/null +++ b/app/migrations/applied/027_soft_deletes.sql @@ -0,0 +1,80 @@ +-- Migration 027: add soft-delete columns and update views +-- Adds deleted_at to languages, tags, theses so deletions are reversible. + +-- Add soft-delete columns (idempotent: ALTER TABLE ... ADD COLUMN fails gracefully if column exists) +ALTER TABLE languages ADD COLUMN deleted_at TEXT DEFAULT NULL; +ALTER TABLE tags ADD COLUMN deleted_at TEXT DEFAULT NULL; +ALTER TABLE theses ADD COLUMN deleted_at TEXT DEFAULT NULL; + +-- Rebuild views to filter out soft-deleted rows. + +DROP VIEW IF EXISTS v_theses_public; +DROP VIEW IF EXISTS v_theses_full; + +CREATE VIEW v_theses_full AS +SELECT + t.id, + t.identifier, + t.title, + t.subtitle, + t.year, + t.is_doctoral, + t.objet, + o.name as orientation, + ap.name as ap_program, + ft.name as finality_type, + t.synopsis, + t.context_note, + at.name as access_type, + lt.name as license_type, + t.license_id, + t.license_custom, + t.access_type_id, + t.jury_points, + t.submitted_at, + t.defense_date, + t.published_at, + t.is_published, + t.baiu_link, + t.banner_path, + t.exemplaire_baiu, + t.exemplaire_erg, + t.cc2r, + t.remarks, + t.jury_note_added, + GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors, + GROUP_CONCAT(DISTINCT s.name) as supervisors, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 0 THEN s.name END) as jury_promoteurs, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 1 THEN s.name END) as jury_promoteurs_ulb, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 0 THEN s.name END) as jury_lecteurs_internes, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 1 THEN s.name END) as jury_lecteurs_externes, + GROUP_CONCAT(DISTINCT UPPER(SUBSTR(l.name,1,1)) || SUBSTR(l.name,2)) as languages, + GROUP_CONCAT(DISTINCT fmt.name) as formats, + GROUP_CONCAT(DISTINCT tg.name) as keywords, + -- First author's email and contact-visibility flag + (SELECT a2.email FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as contact_interne, + (SELECT a2.show_contact FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as contact_public +FROM theses t +LEFT JOIN orientations o ON t.orientation_id = o.id +LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id +LEFT JOIN finality_types ft ON t.finality_id = ft.id +LEFT JOIN access_types at ON t.access_type_id = at.id +LEFT JOIN license_types lt ON t.license_id = lt.id +LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id +LEFT JOIN authors a ON ta.author_id = a.id +LEFT JOIN thesis_supervisors ts ON t.id = ts.thesis_id +LEFT JOIN supervisors s ON ts.supervisor_id = s.id +LEFT JOIN thesis_languages tl ON t.id = tl.thesis_id +LEFT JOIN languages l ON tl.language_id = l.id AND l.deleted_at IS NULL +LEFT JOIN thesis_formats tf ON t.id = tf.thesis_id +LEFT JOIN format_types fmt ON tf.format_id = fmt.id +LEFT JOIN thesis_tags tt ON t.id = tt.thesis_id +LEFT JOIN tags tg ON tt.tag_id = tg.id AND tg.deleted_at IS NULL +WHERE t.deleted_at IS NULL +GROUP BY t.id; + +-- Published theses only (for public view) +CREATE VIEW v_theses_public AS +SELECT * FROM v_theses_full +WHERE is_published = 1; diff --git a/app/migrations/pending/028_drop_banner_path.sql b/app/migrations/applied/028_drop_banner_path.sql similarity index 100% rename from app/migrations/pending/028_drop_banner_path.sql rename to app/migrations/applied/028_drop_banner_path.sql diff --git a/app/public/admin/actions/corbeille.php b/app/public/admin/actions/corbeille.php new file mode 100644 index 0000000..7a29dc6 --- /dev/null +++ b/app/public/admin/actions/corbeille.php @@ -0,0 +1,60 @@ +restoreThesis($thesisId); + $logger->logPublish(true, [$thesisId]); // log restore as an admin action + App::flash('success', "TFE restauré avec succès."); + break; + + case 'hard_delete': + $thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT); + if (!$thesisId) throw new Exception("ID invalide."); + $db->hardDeleteThesis($thesisId); + $logger->logDelete([$thesisId]); + App::flash('success', "TFE définitivement supprimé."); + break; + + case 'empty_trash': + $trashed = $db->getTrashedTheses(); + foreach ($trashed as $t) { + $db->hardDeleteThesis((int)$t['id']); + $logger->logDelete([(int)$t['id']]); + } + App::flash('success', "Corbeille vidée (" . count($trashed) . " TFE supprimés définitivement)."); + break; + + default: + throw new Exception("Action inconnue."); + } +} catch (Exception $e) { + ErrorHandler::log('corbeille', $e); + App::flash('error', ErrorHandler::userMessage($e)); +} + +$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); +header('Location: /admin/index.php?tab=trash'); +exit; diff --git a/app/public/admin/actions/settings.php b/app/public/admin/actions/settings.php index d9bdd9d..ffd7dc5 100644 --- a/app/public/admin/actions/settings.php +++ b/app/public/admin/actions/settings.php @@ -22,6 +22,8 @@ $isHxRequest = (isset($_SERVER['HTTP_HX_REQUEST']) && $_SERVER['HTTP_HX_REQUEST' $section = $_POST['section'] ?? ''; if ($section === 'formulaire') { + // hx-include targets the wrapper div, so all checkboxes in the fieldset + // are submitted together — including unchecked ones (absent from POST). $allowed = [ 'access_type_libre_enabled', 'access_type_interne_enabled', diff --git a/app/public/admin/index.php b/app/public/admin/index.php index 3cdc5fa..dd776d5 100644 --- a/app/public/admin/index.php +++ b/app/public/admin/index.php @@ -398,7 +398,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) { $langName = strtolower($langName); if ($langName === '') continue; // Lookup case-insensitively; insert if missing (stored lowercase). - $s = $importPdo->prepare("SELECT id FROM languages WHERE LOWER(name) = LOWER(?)"); + $s = $importPdo->prepare("SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND deleted_at IS NULL"); $s->execute([$langName]); $r = $s->fetch(); $langId = $r ? (int)$r['id'] : null; @@ -470,6 +470,9 @@ try { $years = $db->getAllYears(); $orientations = $db->getAllOrientations(); $apPrograms = $db->getAllAPPrograms(); + $trashCount = $db->countTrashedTheses(); + $tab = $_GET['tab'] ?? 'list'; + $trashedTheses = ($tab === 'trash') ? $db->getTrashedTheses() : []; } catch (Exception $e) { error_log("Error loading theses list: " . $e->getMessage()); die("Erreur lors du chargement de la liste."); @@ -478,11 +481,19 @@ try { $isHtmx = ($_SERVER['HTTP_HX_REQUEST'] ?? '') === 'true'; $isAdmin = true; $bodyClass = 'admin-body'; if ($isHtmx) { - include APP_ROOT . '/templates/admin/index-table.php'; + if ($tab === 'trash') { + include APP_ROOT . '/templates/admin/index-trash.php'; + } else { + include APP_ROOT . '/templates/admin/index-table.php'; + } } else { require_once APP_ROOT . '/templates/head.php'; include APP_ROOT . '/templates/header.php'; - include APP_ROOT . '/templates/admin/index.php'; + if ($tab === 'trash') { + include APP_ROOT . '/templates/admin/index-trash.php'; + } else { + include APP_ROOT . '/templates/admin/index.php'; + } require_once APP_ROOT . '/templates/admin/footer.php'; } diff --git a/app/src/Audit.php b/app/src/Audit.php new file mode 100644 index 0000000..6e77cd6 --- /dev/null +++ b/app/src/Audit.php @@ -0,0 +1,80 @@ +getConnection()->prepare( + 'INSERT INTO audit_log (actor, action, table_name, record_id, old_data, new_data) + VALUES (?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([ + $actor, + $action, + $tableName, + $recordId, + $oldData !== null ? self::safeJsonEncode($oldData) : null, + $newData !== null ? self::safeJsonEncode($newData) : null, + ]); + } catch (\Throwable $e) { + // Audit logging is best-effort — never crash the app over it. + error_log('[Audit] write failed: ' . $e->getMessage()); + } + } + + /** + * Build the actor string from the current request context. + */ + public static function actor(): string + { + $ip = $_SERVER['REMOTE_ADDR'] ?? 'cli'; + $user = $_SESSION['admin_user'] ?? null; + return $user ? "$user@$ip" : $ip; + } + + /** + * JSON-encode data, redacting sensitive/password fields. + */ + private static function safeJsonEncode(array $data): string + { + $safe = $data; + // Redact password-like fields + foreach (['password', 'pass', 'secret', 'token', 'credential'] as $key) { + if (array_key_exists($key, $safe)) { + $safe[$key] = '[REDACTED]'; + } + } + return json_encode($safe, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR); + } +} diff --git a/app/src/Database.php b/app/src/Database.php index deba4c2..6f6bd34 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -64,6 +64,17 @@ class Database return $root . '/storage/xamxam.db'; } + /** + * Fetch a single row by ID from a table. Returns null if not found. + */ + private function fetchRow(string $table, int $id): ?array + { + $stmt = $this->pdo->prepare("SELECT * FROM $table WHERE id = ?"); + $stmt->execute([$id]); + $row = $stmt->fetch(); + return $row !== false ? $row : null; + } + /** * Get singleton instance (for front-backend) * @return Database @@ -154,7 +165,7 @@ class Database public function getLatestYearTheses(int $limit = 24): array { $sql = 'SELECT * FROM v_theses_public - WHERE year = (SELECT MAX(year) FROM theses WHERE is_published = 1) + WHERE year = (SELECT MAX(year) FROM theses WHERE is_published = 1 AND deleted_at IS NULL) ORDER BY RANDOM() LIMIT :limit'; $stmt = $this->pdo->prepare($sql); @@ -168,7 +179,7 @@ class Database */ public function getLatestPublishedYear(): ?int { - $stmt = $this->pdo->query('SELECT MAX(year) FROM theses WHERE is_published = 1'); + $stmt = $this->pdo->query('SELECT MAX(year) FROM theses WHERE is_published = 1 AND deleted_at IS NULL'); $val = $stmt->fetchColumn(); return $val ? (int)$val : null; } @@ -178,7 +189,7 @@ class Database */ public function countPublishedTheses() { - $sql = 'SELECT COUNT(*) as count FROM theses WHERE is_published = 1'; + $sql = 'SELECT COUNT(*) as count FROM theses WHERE is_published = 1 AND deleted_at IS NULL'; $stmt = $this->pdo->query($sql); $result = $stmt->fetch(); return $result['count']; @@ -479,7 +490,7 @@ class Database FROM theses t JOIN thesis_authors ta ON ta.thesis_id = t.id JOIN authors a ON a.id = ta.author_id - WHERE t.is_published = 1 + WHERE t.is_published = 1 AND t.deleted_at IS NULL GROUP BY t.id ORDER BY MIN(a.name) ASC' ); @@ -541,7 +552,7 @@ class Database public function getAvailableYears() { - $sql = 'SELECT DISTINCT year FROM theses WHERE is_published = 1 ORDER BY year DESC'; + $sql = 'SELECT DISTINCT year FROM theses WHERE is_published = 1 AND deleted_at IS NULL ORDER BY year DESC'; $stmt = $this->pdo->query($sql); return $stmt->fetchAll(PDO::FETCH_COLUMN); } @@ -581,7 +592,7 @@ class Database $sql = 'SELECT DISTINCT tg.id, tg.name FROM tags tg JOIN thesis_tags tt ON tg.id = tt.tag_id JOIN theses th ON tt.thesis_id = th.id - WHERE th.is_published = 1 + WHERE th.is_published = 1 AND th.deleted_at IS NULL AND tg.deleted_at IS NULL ORDER BY tg.name'; $stmt = $this->pdo->query($sql); return $stmt->fetchAll(); @@ -617,7 +628,7 @@ class Database // Build WHERE + bindings excluding one dimension (for that column's own relevance) $buildWhere = function (string $exclude) use ($filters): array { - $conditions = ['t.is_published = 1']; + $conditions = ['t.is_published = 1', 't.deleted_at IS NULL']; $bindings = []; if ($exclude !== 'years' && !empty($filters['years'])) { @@ -670,7 +681,7 @@ class Database // Years — single-valued FK: use full intersection (including own filter). // Clicking one year should fade years that have zero theses in the current result. $matchedYearsIds = array_column($exec("SELECT DISTINCT t.year $baseJoins WHERE $wAll", $bAll), 'year'); - $allYears = array_column($exec('SELECT DISTINCT year FROM theses WHERE is_published=1 ORDER BY year DESC', []), 'year'); + $allYears = array_column($exec('SELECT DISTINCT year FROM theses WHERE is_published=1 AND deleted_at IS NULL ORDER BY year DESC', []), 'year'); $yearsOut = array_map(fn ($y) => ['value' => $y, 'matched' => in_array($y, $matchedYearsIds, true)], $allYears); // AP programs — single-valued FK: use full intersection. @@ -701,7 +712,7 @@ class Database 'SELECT DISTINCT tg.name FROM tags tg JOIN thesis_tags tt ON tg.id = tt.tag_id JOIN theses th ON tt.thesis_id = th.id - WHERE th.is_published = 1 ORDER BY tg.name', + WHERE th.is_published = 1 AND th.deleted_at IS NULL AND tg.deleted_at IS NULL ORDER BY tg.name', [] ), 'name'); $kwOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedKw, true)], $allKw); @@ -748,7 +759,7 @@ class Database */ public function getAllLanguages(): array { - $stmt = $this->pdo->query("SELECT id, UPPER(SUBSTR(name,1,1)) || SUBSTR(name,2) as name, created_at FROM languages ORDER BY name"); + $stmt = $this->pdo->query("SELECT id, UPPER(SUBSTR(name,1,1)) || SUBSTR(name,2) as name, created_at FROM languages WHERE deleted_at IS NULL ORDER BY name"); return $stmt->fetchAll(); } @@ -772,6 +783,7 @@ class Database CASE WHEN name GLOB '*[À-ÿ]*' THEN 0 ELSE 1 END AS is_ascii FROM languages WHERE LOWER(name) IN ('français', 'anglais', 'néerlandais', 'francais', 'neerlandais') + AND deleted_at IS NULL ORDER BY grp, is_ascii" )->fetchAll(); @@ -852,7 +864,7 @@ class Database LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id LEFT JOIN authors a ON ta.author_id = a.id - WHERE 1=1'; + WHERE t.deleted_at IS NULL'; $params = []; @@ -900,7 +912,7 @@ class Database LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id LEFT JOIN authors a ON ta.author_id = a.id LEFT JOIN access_types at ON t.access_type_id = at.id - WHERE 1=1'; + WHERE t.deleted_at IS NULL'; $params = []; @@ -957,7 +969,7 @@ class Database */ public function getAllYears(): array { - $stmt = $this->pdo->query('SELECT DISTINCT year FROM theses ORDER BY year DESC'); + $stmt = $this->pdo->query('SELECT DISTINCT year FROM theses WHERE deleted_at IS NULL ORDER BY year DESC'); return $stmt->fetchAll(PDO::FETCH_COLUMN); } @@ -976,7 +988,8 @@ class Database COUNT(*) AS total, SUM(CASE WHEN is_published = 1 THEN 1 ELSE 0 END) AS published, SUM(CASE WHEN is_published = 0 THEN 1 ELSE 0 END) AS pending - FROM theses' + FROM theses + WHERE deleted_at IS NULL' ); $row = $stmt->fetch(); return [ @@ -1157,7 +1170,7 @@ class Database return null; } - $stmt = $this->pdo->prepare('SELECT id FROM tags WHERE name = ?'); + $stmt = $this->pdo->prepare('SELECT id FROM tags WHERE name = ? AND deleted_at IS NULL'); $stmt->execute([$name]); $row = $stmt->fetch(); @@ -1167,7 +1180,12 @@ class Database $stmt = $this->pdo->prepare('INSERT INTO tags (name) VALUES (?)'); $stmt->execute([$name]); - return (int)$this->pdo->lastInsertId(); + $newId = (int)$this->pdo->lastInsertId(); + + require_once __DIR__ . '/Audit.php'; + Audit::log($this, Audit::actor(), 'INSERT', 'tags', $newId, null, ['id' => $newId, 'name' => $name]); + + return $newId; } @@ -1189,6 +1207,7 @@ class Database COUNT(DISTINCT tt.thesis_id) as thesis_count FROM tags tg LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id + WHERE tg.deleted_at IS NULL GROUP BY tg.id ORDER BY thesis_count DESC, tg.name COLLATE NOCASE LIMIT 10 @@ -1199,7 +1218,7 @@ class Database COUNT(DISTINCT tt.thesis_id) as thesis_count FROM tags tg LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id - WHERE tg.name LIKE ? + WHERE tg.name LIKE ? AND tg.deleted_at IS NULL GROUP BY tg.id ORDER BY tg.name = ? DESC, thesis_count DESC, tg.name COLLATE NOCASE LIMIT 10 @@ -1219,6 +1238,7 @@ class Database COUNT(DISTINCT tt.thesis_id) as thesis_count FROM tags tg LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id + WHERE tg.deleted_at IS NULL GROUP BY tg.id ORDER BY tg.name COLLATE NOCASE '); @@ -1234,13 +1254,17 @@ class Database if ($newName === '') { throw new Exception('Le nom du tag ne peut pas être vide.'); } - // Check uniqueness - $stmt = $this->pdo->prepare('SELECT id FROM tags WHERE name = ? AND id != ?'); + // Check uniqueness (excluding soft-deleted rows) + $stmt = $this->pdo->prepare('SELECT id FROM tags WHERE name = ? AND id != ? AND deleted_at IS NULL'); $stmt->execute([$newName, $id]); if ($stmt->fetch()) { throw new Exception('Un tag avec ce nom existe déjà.'); } + require_once __DIR__ . '/Audit.php'; + $old = $this->fetchRow('tags', $id); $this->pdo->prepare('UPDATE tags SET name = ? WHERE id = ?')->execute([$newName, $id]); + $new = $this->fetchRow('tags', $id); + Audit::log($this, Audit::actor(), 'UPDATE', 'tags', $id, $old, $new); } /** @@ -1261,8 +1285,11 @@ class Database ')->execute([$targetId, $sourceId]); // Delete the old source rows $this->pdo->prepare('DELETE FROM thesis_tags WHERE tag_id = ?')->execute([$sourceId]); - // Delete the source tag itself - $this->pdo->prepare('DELETE FROM tags WHERE id = ?')->execute([$sourceId]); + // Soft-delete the source tag itself + require_once __DIR__ . '/Audit.php'; + $old = $this->fetchRow('tags', $sourceId); + $this->pdo->prepare("UPDATE tags SET deleted_at = datetime('now') WHERE id = ?")->execute([$sourceId]); + Audit::log($this, Audit::actor(), 'DELETE', 'tags', $sourceId, $old); $this->pdo->commit(); } catch (\Throwable $e) { $this->pdo->rollBack(); @@ -1271,11 +1298,14 @@ class Database } /** - * Delete a tag and all its thesis_tags rows (cascades via FK). + * Soft-delete a tag (sets deleted_at). */ public function deleteTag(int $id): void { - $this->pdo->prepare('DELETE FROM tags WHERE id = ?')->execute([$id]); + require_once __DIR__ . '/Audit.php'; + $old = $this->fetchRow('tags', $id); + $this->pdo->prepare("UPDATE tags SET deleted_at = datetime('now') WHERE id = ?")->execute([$id]); + Audit::log($this, Audit::actor(), 'DELETE', 'tags', $id, $old); } // ======================================================================== @@ -1296,6 +1326,7 @@ class Database COUNT(DISTINCT tl.thesis_id) as thesis_count FROM languages l LEFT JOIN thesis_languages tl ON l.id = tl.language_id + WHERE l.deleted_at IS NULL GROUP BY LOWER(l.name) ORDER BY thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE LIMIT 10 @@ -1307,7 +1338,7 @@ class Database COUNT(DISTINCT tl.thesis_id) as thesis_count FROM languages l LEFT JOIN thesis_languages tl ON l.id = tl.language_id - WHERE LOWER(l.name) LIKE LOWER(?) + WHERE LOWER(l.name) LIKE LOWER(?) AND l.deleted_at IS NULL GROUP BY LOWER(l.name) ORDER BY LOWER(MIN(l.name)) = LOWER(?) DESC, thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE LIMIT 10 @@ -1329,6 +1360,7 @@ class Database COUNT(DISTINCT tl.thesis_id) as thesis_count FROM languages l LEFT JOIN thesis_languages tl ON l.id = tl.language_id + WHERE l.deleted_at IS NULL GROUP BY LOWER(l.name) ORDER BY LOWER(MIN(l.name)) COLLATE NOCASE '); @@ -1343,6 +1375,7 @@ class Database $dupes = $this->pdo->query(' SELECT LOWER(name) as lname, MIN(id) as keep_id FROM languages + WHERE deleted_at IS NULL GROUP BY LOWER(name) HAVING COUNT(*) > 1 ')->fetchAll(); @@ -1361,9 +1394,9 @@ class Database ) ')->execute([$dup['lname'], $dup['keep_id']]); - $this->pdo->prepare(' - DELETE FROM languages WHERE LOWER(name) = ? AND id != ? - ')->execute([$dup['lname'], $dup['keep_id']]); + $this->pdo->prepare( + "UPDATE languages SET deleted_at = datetime('now') WHERE LOWER(name) = ? AND id != ?" + )->execute([$dup['lname'], $dup['keep_id']]); } } @@ -1376,16 +1409,20 @@ class Database if ($newName === '') { throw new Exception('Le nom de la langue ne peut pas être vide.'); } - $stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND id != ?'); + $stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND id != ? AND deleted_at IS NULL'); $stmt->execute([$newName, $id]); if ($stmt->fetch()) { throw new Exception('Une langue avec ce nom existe déjà.'); } + require_once __DIR__ . '/Audit.php'; + $old = $this->fetchRow('languages', $id); $this->pdo->prepare('UPDATE languages SET name = ? WHERE id = ?')->execute([$newName, $id]); + $new = $this->fetchRow('languages', $id); + Audit::log($this, Audit::actor(), 'UPDATE', 'languages', $id, $old, $new); } /** - * Merge sourceId into targetId: reassign all thesis_languages rows, then delete source. + * Merge sourceId into targetId: reassign all thesis_languages rows, then soft-delete source. */ public function mergeLanguage(int $sourceId, int $targetId): void { @@ -1399,7 +1436,11 @@ class Database SELECT ?, thesis_id FROM thesis_languages WHERE language_id = ? ')->execute([$targetId, $sourceId]); $this->pdo->prepare('DELETE FROM thesis_languages WHERE language_id = ?')->execute([$sourceId]); - $this->pdo->prepare('DELETE FROM languages WHERE id = ?')->execute([$sourceId]); + // Soft-delete the source language + require_once __DIR__ . '/Audit.php'; + $old = $this->fetchRow('languages', $sourceId); + $this->pdo->prepare("UPDATE languages SET deleted_at = datetime('now') WHERE id = ?")->execute([$sourceId]); + Audit::log($this, Audit::actor(), 'DELETE', 'languages', $sourceId, $old); $this->pdo->commit(); } catch (\Throwable $e) { $this->pdo->rollBack(); @@ -1408,11 +1449,14 @@ class Database } /** - * Delete a language and all its thesis_languages rows. + * Soft-delete a language (sets deleted_at). */ public function deleteLanguage(int $id): void { - $this->pdo->prepare('DELETE FROM languages WHERE id = ?')->execute([$id]); + require_once __DIR__ . '/Audit.php'; + $old = $this->fetchRow('languages', $id); + $this->pdo->prepare("UPDATE languages SET deleted_at = datetime('now') WHERE id = ?")->execute([$id]); + Audit::log($this, Audit::actor(), 'DELETE', 'languages', $id, $old); } /** @@ -1487,10 +1531,14 @@ class Database */ public function setVisibility(int $thesisId, ?int $accessTypeId): void { + require_once __DIR__ . '/Audit.php'; + $old = $this->fetchRow('theses', $thesisId); $stmt = $this->pdo->prepare( 'UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' ); $stmt->execute([$accessTypeId, $thesisId]); + $new = $this->fetchRow('theses', $thesisId); + Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId, $old, $new); } /** @@ -1503,11 +1551,17 @@ class Database if (empty($thesisIds)) { return; } + require_once __DIR__ . '/Audit.php'; + $actor = Audit::actor(); $placeholders = implode(',', array_fill(0, count($thesisIds), '?')); $params = array_merge([$accessTypeId], $thesisIds); $this->pdo->prepare( "UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)" )->execute($params); + foreach ($thesisIds as $id) { + $new = $this->fetchRow('theses', $id); + Audit::log($this, $actor, 'UPDATE', 'theses', $id, null, $new); + } } /** @@ -1515,9 +1569,13 @@ class Database */ public function setPublished(int $thesisId, bool $published): void { + require_once __DIR__ . '/Audit.php'; + $old = $this->fetchRow('theses', $thesisId); $this->pdo->prepare( 'UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' )->execute([$published ? 1 : 0, $thesisId]); + $new = $this->fetchRow('theses', $thesisId); + Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId, $old, $new); } /** @@ -1529,11 +1587,17 @@ class Database if (empty($thesisIds)) { return; } + require_once __DIR__ . '/Audit.php'; + $actor = Audit::actor(); $placeholders = implode(',', array_fill(0, count($thesisIds), '?')); $params = array_merge([$published ? 1 : 0], $thesisIds); $this->pdo->prepare( "UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)" )->execute($params); + foreach ($thesisIds as $id) { + $new = $this->fetchRow('theses', $id); + Audit::log($this, $actor, 'UPDATE', 'theses', $id, null, $new); + } } /** @@ -1721,8 +1785,8 @@ class Database throw new \InvalidArgumentException('Language name must not be empty.'); } - // 1. Exact lowercase match - $stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) LIMIT 1'); + // 1. Exact lowercase match (skip soft-deleted rows) + $stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND deleted_at IS NULL LIMIT 1'); $stmt->execute([$name]); $id = $stmt->fetchColumn(); if ($id !== false) { @@ -1733,7 +1797,7 @@ class Database // iconv 'ASCII//TRANSLIT' turns é→e, ç→c, etc. $asciiName = @iconv('UTF-8', 'ASCII//TRANSLIT', $name); if ($asciiName !== false && $asciiName !== $name) { - $all = $this->pdo->query('SELECT id, name FROM languages')->fetchAll(); + $all = $this->pdo->query('SELECT id, name FROM languages WHERE deleted_at IS NULL')->fetchAll(); foreach ($all as $row) { $rowAscii = @iconv('UTF-8', 'ASCII//TRANSLIT', strtolower($row['name'])); if ($rowAscii !== false && $rowAscii === $asciiName) { @@ -1743,7 +1807,12 @@ class Database } $this->pdo->prepare('INSERT INTO languages (name) VALUES (?)')->execute([$name]); - return (int)$this->pdo->lastInsertId(); + $newId = (int)$this->pdo->lastInsertId(); + + require_once __DIR__ . '/Audit.php'; + Audit::log($this, Audit::actor(), 'INSERT', 'languages', $newId, null, ['id' => $newId, 'name' => $name]); + + return $newId; } /** @@ -1955,6 +2024,11 @@ class Database */ public function updateThesis(int $thesisId, array $data): void { + require_once __DIR__ . '/Audit.php'; + Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId, + $this->fetchRow('theses', $thesisId) + ); + $stmt = $this->pdo->prepare(' UPDATE theses SET title = ?, @@ -2082,29 +2156,28 @@ class Database !empty($data['cc2r']) ? 1 : 0, ]); - return (int)$this->pdo->lastInsertId(); + $newId = (int)$this->pdo->lastInsertId(); + + require_once __DIR__ . '/Audit.php'; + $new = $this->fetchRow('theses', $newId); + Audit::log($this, Audit::actor(), 'INSERT', 'theses', $newId, null, $new); + + return $newId; } /** - * Delete a single thesis and all its related data (cascade via FK). - * Removes thesis files from disk (covers are stored in thesis_files and handled here). + * Soft-delete a single thesis (sets deleted_at). */ public function deleteThesis(int $thesisId): void { - // Clean up thesis files from disk - $files = $this->getThesisFiles($thesisId); - foreach ($files as $file) { - if (!empty($file['file_path']) && file_exists($file['file_path'])) { - @unlink($file['file_path']); - } - } - - // DB cascade handles junction tables - $this->pdo->prepare('DELETE FROM theses WHERE id = ?')->execute([$thesisId]); + require_once __DIR__ . '/Audit.php'; + $old = $this->fetchRow('theses', $thesisId); + $this->pdo->prepare("UPDATE theses SET deleted_at = datetime('now') WHERE id = ?")->execute([$thesisId]); + Audit::log($this, Audit::actor(), 'DELETE', 'theses', $thesisId, $old); } /** - * Delete multiple theses at once. + * Soft-delete multiple theses at once. * @param int[] $thesisIds */ public function bulkDeleteTheses(array $thesisIds): void @@ -2113,18 +2186,98 @@ class Database return; } - // Clean up files for each thesis + require_once __DIR__ . '/Audit.php'; + $actor = Audit::actor(); + foreach ($thesisIds as $id) { - $files = $this->getThesisFiles($id); - foreach ($files as $file) { - if (!empty($file['file_path']) && file_exists($file['file_path'])) { - @unlink($file['file_path']); - } + $old = $this->fetchRow('theses', $id); + $this->pdo->prepare("UPDATE theses SET deleted_at = datetime('now') WHERE id = ?")->execute([$id]); + Audit::log($this, $actor, 'DELETE', 'theses', $id, $old); + } + } + + /** + * Get trashed (soft-deleted) theses for the admin corbeille view. + */ + public function getTrashedTheses(): array + { + $stmt = $this->pdo->query(' + SELECT t.id, t.identifier, t.title, t.subtitle, t.year, + GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) as authors, + t.deleted_at + FROM theses t + LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id + LEFT JOIN authors a ON ta.author_id = a.id + WHERE t.deleted_at IS NOT NULL + GROUP BY t.id + ORDER BY t.deleted_at DESC + '); + return $stmt->fetchAll(); + } + + /** + * Count trashed (soft-deleted) theses. + */ + public function countTrashedTheses(): int + { + return (int)$this->pdo->query( + 'SELECT COUNT(*) FROM theses WHERE deleted_at IS NOT NULL' + )->fetchColumn(); + } + + /** + * Restore a soft-deleted thesis. + */ + public function restoreThesis(int $thesisId): void + { + require_once __DIR__ . '/Audit.php'; + Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId, + $this->fetchRow('theses', $thesisId) + ); + $this->pdo->prepare('UPDATE theses SET deleted_at = NULL WHERE id = ?')->execute([$thesisId]); + $new = $this->fetchRow('theses', $thesisId); + Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId, null, $new); + } + + /** + * Permanently delete a thesis (hard delete — files too). + * Only called from the corbeille for truly irreversible cleanup. + */ + public function hardDeleteThesis(int $thesisId): void + { + require_once __DIR__ . '/Audit.php'; + $old = $this->fetchRow('theses', $thesisId); + + // Clean up thesis files from disk + $files = $this->getThesisFiles($thesisId); + foreach ($files as $file) { + if (!empty($file['file_path']) && file_exists($file['file_path'])) { + @unlink($file['file_path']); } } - $placeholders = implode(',', array_fill(0, count($thesisIds), '?')); - $this->pdo->prepare("DELETE FROM theses WHERE id IN ($placeholders)")->execute($thesisIds); + $this->pdo->prepare('DELETE FROM theses WHERE id = ?')->execute([$thesisId]); + Audit::log($this, Audit::actor(), 'DELETE', 'theses', $thesisId, $old); + } + + /** + * Restore a soft-deleted tag. + */ + public function restoreTag(int $id): void + { + require_once __DIR__ . '/Audit.php'; + Audit::log($this, Audit::actor(), 'UPDATE', 'tags', $id, $this->fetchRow('tags', $id)); + $this->pdo->prepare('UPDATE tags SET deleted_at = NULL WHERE id = ?')->execute([$id]); + } + + /** + * Restore a soft-deleted language. + */ + public function restoreLanguage(int $id): void + { + require_once __DIR__ . '/Audit.php'; + Audit::log($this, Audit::actor(), 'UPDATE', 'languages', $id, $this->fetchRow('languages', $id)); + $this->pdo->prepare('UPDATE languages SET deleted_at = NULL WHERE id = ?')->execute([$id]); } /** @@ -2202,7 +2355,7 @@ class Database public function deleteThesisFile(int $fileId, int $thesisId): ?string { $stmt = $this->pdo->prepare( - 'SELECT file_path FROM thesis_files WHERE id = ? AND thesis_id = ? LIMIT 1' + 'SELECT * FROM thesis_files WHERE id = ? AND thesis_id = ? LIMIT 1' ); $stmt->execute([$fileId, $thesisId]); $row = $stmt->fetch(); @@ -2210,6 +2363,10 @@ class Database return null; } $this->pdo->prepare('DELETE FROM thesis_files WHERE id = ?')->execute([$fileId]); + + require_once __DIR__ . '/Audit.php'; + Audit::log($this, Audit::actor(), 'DELETE', 'thesis_files', $fileId, $row); + return $row['file_path']; } diff --git a/app/storage/backups/db-2026-05-11T01-03-26.db.gz b/app/storage/backups/db-2026-05-11T01-03-26.db.gz new file mode 100644 index 0000000..c846d23 Binary files /dev/null and b/app/storage/backups/db-2026-05-11T01-03-26.db.gz differ diff --git a/app/storage/schema.sql b/app/storage/schema.sql index 7b524ce..2a0cd4a 100644 --- a/app/storage/schema.sql +++ b/app/storage/schema.sql @@ -84,7 +84,8 @@ INSERT OR IGNORE INTO finality_types (name) VALUES CREATE TABLE IF NOT EXISTS languages ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT DEFAULT NULL ); INSERT OR IGNORE INTO languages (name) VALUES @@ -112,7 +113,8 @@ INSERT OR IGNORE INTO format_types (name) VALUES CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + deleted_at TEXT DEFAULT NULL ); CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); @@ -200,6 +202,9 @@ CREATE TABLE IF NOT EXISTS theses ( -- CC2r acceptance (collected in student form) cc2r BOOLEAN DEFAULT 0, + -- Soft delete support + deleted_at TEXT DEFAULT NULL, + -- Timestamps created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, @@ -538,11 +543,12 @@ LEFT JOIN authors a ON ta.author_id = a.id LEFT JOIN thesis_supervisors ts ON t.id = ts.thesis_id LEFT JOIN supervisors s ON ts.supervisor_id = s.id LEFT JOIN thesis_languages tl ON t.id = tl.thesis_id -LEFT JOIN languages l ON tl.language_id = l.id +LEFT JOIN languages l ON tl.language_id = l.id AND l.deleted_at IS NULL LEFT JOIN thesis_formats tf ON t.id = tf.thesis_id LEFT JOIN format_types fmt ON tf.format_id = fmt.id LEFT JOIN thesis_tags tt ON t.id = tt.thesis_id -LEFT JOIN tags tg ON tt.tag_id = tg.id +LEFT JOIN tags tg ON tt.tag_id = tg.id AND tg.deleted_at IS NULL +WHERE t.deleted_at IS NULL GROUP BY t.id; -- Published theses only (for public view) diff --git a/app/templates/admin/acces.php b/app/templates/admin/acces.php index b1c545a..8347a8f 100644 --- a/app/templates/admin/acces.php +++ b/app/templates/admin/acces.php @@ -584,6 +584,32 @@ +%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +\\\\\\\ to: psvklxsu ec511543 "fix: exclude entire var/ from rsync --delete to preserve logs" (rebased revision) ++ $linkName = $link['name'] ?? ''; +++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: psvklxsu ec511543 "fix: exclude entire var/ from rsync --delete to preserve logs" (rebased revision) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +- $linkName = $link['name'] ?? ''; +- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: ouqzmwvn fafb3fc6 "feat: implement SQLite backup & data integrity plan (Phases 2-4)" (rebased revision) + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; + $linkLockedYear = $link['locked_year'] ?? null; ++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) ++\\\\\\\ to: ouqzmwvn 97412d13 "feat: implement SQLite backup & data integrity plan (Phases 2-4)" (rebased revision) +++ $linkName = $link['name'] ?? ''; +++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: ouqzmwvn 97412d13 "feat: implement SQLite backup & data integrity plan (Phases 2-4)" (rebased revision) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +- $linkName = $link['name'] ?? ''; +- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination) +\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: wstuyzym f5810c41 "feat: implement SQLite backup & data integrity plan (Phases 2-4)" (rebased revision) + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; + $linkLockedYear = $link['locked_year'] ?? null; ++%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) ++\\\\\\\ to: wstuyzym 5886355c "feat: implement SQLite backup & data integrity plan (Phases 2-4)" (rebased revision) +++ $linkName = $link['name'] ?? ''; ++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; ?> diff --git a/app/templates/admin/contenus.php b/app/templates/admin/contenus.php index 739558b..85f0cf3 100644 --- a/app/templates/admin/contenus.php +++ b/app/templates/admin/contenus.php @@ -88,16 +88,17 @@
Restrictions d'accès aux fichiers -
- +
+