mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
feat: implement SQLite backup & data integrity plan (Phases 2-4)
This commit is contained in:
107
TODO.md
107
TODO.md
@@ -1,76 +1,37 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
- [x] Fix email addresses in about.php contacts section not using EmailObfuscator for link text
|
## SQLite Backup & Data Integrity (docs/backup-plan.md)
|
||||||
- [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)
|
|
||||||
|
|
||||||
- [x] Replace HTMX+PHP file upload queues with client-side JS
|
### Phase 1 — WAL Mode
|
||||||
- [x] Fix submit button on all forms — add JS/PHP debug logging
|
- [x] WAL mode already active (`PRAGMA journal_mode` → `wal`) — set in Database constructor
|
||||||
- [x] Fix file-upload-queue.js: redirect detection broken due to opaque redirect (switched from fetch to XHR for reliable responseURL)
|
- [ ] Verify `-wal` and `-shm` sidecar files exist after writes
|
||||||
- [x] Add `console.log` tracing on JS submit interception
|
- [ ] Verify nginx/PHP write access to sidecar files on server
|
||||||
- [x] Add `error_log` entry-point logging to all 16 PHP action files
|
|
||||||
- [x] Add double-submit guard (`_xamxamActiveSubmit`)
|
### Phase 2 — Audit Log
|
||||||
- [x] Fix spurious HTMX console warnings from checkbox-list default hx-include
|
- [x] `admin_audit_log` table already exists (migration 009), `AdminLogger` already writes to it
|
||||||
- [x] Fix duplicate language entries (accented vs non-accented variants)
|
- [x] Create the `audit_log` table for data-level audit (before/after row snapshots)
|
||||||
- [x] Fix checkbox click in admin index navigating to recapitulatif instead of toggling
|
- [x] Create `Audit.php` helper class
|
||||||
- [x] Deduplicate getPredefinedLanguages() query
|
- [x] Instrument all DELETE, UPDATE, INSERT operations on core tables (theses, tags, languages, thesis_files)
|
||||||
- [x] Accent-tolerant getOrCreateLanguage() to prevent future duplicates
|
- [ ] Verify by triggering a test delete and querying `SELECT * FROM audit_log ORDER BY id DESC LIMIT 5`
|
||||||
- [x] Delete orphan non-accented language rows from DB
|
|
||||||
- [x] Migrate file upload queues to FilePond
|
### Phase 3 — Soft Deletes
|
||||||
- [x] Download filepond.min.js + filepond.min.css as local assets
|
- [x] Add `deleted_at` columns to `languages`, `tags`, `theses`
|
||||||
- [x] Create file-upload-filepond.js (init script for FilePond instances)
|
- [x] Rebuild views `v_theses_full` and `v_theses_public` with `deleted_at IS NULL` filters
|
||||||
- [x] Rewrite fichiers-fragment.php: replace custom picker/queue DOM with FilePond targets
|
- [x] Update `schema.sql` for fresh installs
|
||||||
- [x] Rewrite fieldset-files.php: same migration (dead code but kept consistent)
|
- [x] Replace all hard DELETEs with soft deletes (`DELETE` → `UPDATE ... SET deleted_at = ...`)
|
||||||
- [x] Update admin/add.php, admin/edit.php, partage/index.php: swap sortable+file-upload-queue for filepond
|
- [x] Add `deleted_at IS NULL` to all SELECT queries touching these tables
|
||||||
- [x] Remove file-upload-queue.js and sortable.min.js
|
- [x] Add admin "Corbeille" view for soft-deleted theses with Restore and Hard Delete actions
|
||||||
- [x] Clean up CSS: remove .fq-*, .tfe-file-queue styles, add filepond.css + theme overrides
|
- [ ] Test each htmx-driven element (language search, tag search, repertoire filters) to confirm deleted entries don't appear
|
||||||
- [x] Decouple format extras from main file inputs — slot-based HTMX swaps preserve FilePond instances
|
- [ ] Admin: add soft-deleted tags/languages view with restore option
|
||||||
- [x] Fix initFilePonds → window.XamxamInitFilePonds bug
|
|
||||||
- [x] Verify backend $_FILES['queue_file'][*] data flow unchanged
|
### Phase 4 — Hourly Snapshots via Cronjob
|
||||||
- [x] Add FilePond pools for couverture + note_intention (extracted from file-field.php inner <form>)
|
- [x] Create `scripts/backup-sqlite.sh` (hot backup via `sqlite3 .backup`, gzip, retention pruning)
|
||||||
- [x] Fix video/audio pools: allowMultiple: true, not single-file
|
- [x] Test locally — backup created, restores correctly
|
||||||
- [x] Add QUEUE_CONFIG for cover (20MB single) and note_intention (100MB PDF single)
|
- [x] Add `just backup-snapshot` command for local ad-hoc backups
|
||||||
- [x] Disable dedicated video/audio upload slots — video/audio files now go through TFE FilePond input
|
- [ ] Deploy backup script to server (`/usr/local/bin/backup-sqlite.sh`)
|
||||||
- [x] Comment out slot-video and slot-audio in fichiers-fragment.php (keep code, render always-hidden)
|
- [ ] Create `/var/backups/xamxam/` directory on server
|
||||||
- [x] Remove HTMX swap triggers from Vidéo/Audio checkboxes
|
- [ ] Add cron jobs (hourly 30d + daily 90d)
|
||||||
- [x] Clean up slot-video/slot-audio from file-upload-filepond.js beforeSwap handler
|
- [ ] Test restore from production backup
|
||||||
- [x] Fix missing endif after removing elseif chain (parse error)
|
|
||||||
- [x] Fix annexe validation error + FilePond type validation + styling
|
### Phase 5 — Remote Sync *(for later)*
|
||||||
- [x] Make annexe pool always visible (remove checkbox+HTMX swap, always on, optional)
|
- [ ] (Deferred)
|
||||||
- [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
|
|
||||||
|
|||||||
16
app/migrations/applied/026_audit_log.sql
Normal file
16
app/migrations/applied/026_audit_log.sql
Normal file
@@ -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);
|
||||||
80
app/migrations/applied/027_soft_deletes.sql
Normal file
80
app/migrations/applied/027_soft_deletes.sql
Normal file
@@ -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;
|
||||||
60
app/public/admin/actions/corbeille.php
Normal file
60
app/public/admin/actions/corbeille.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../../../bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||||
|
error_log('[corbeille.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | action=' . ($_POST['action'] ?? 'none'));
|
||||||
|
|
||||||
|
AdminAuth::requireLogin();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|
||||||
|
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||||
|
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
||||||
|
http_response_code(403);
|
||||||
|
die("Accès refusé.");
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../src/Database.php';
|
||||||
|
require_once __DIR__ . '/../../../src/AdminLogger.php';
|
||||||
|
require_once __DIR__ . '/../../../src/ErrorHandler.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = new Database();
|
||||||
|
$logger = AdminLogger::make();
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'restore':
|
||||||
|
$thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT);
|
||||||
|
if (!$thesisId) throw new Exception("ID invalide.");
|
||||||
|
$db->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;
|
||||||
@@ -22,6 +22,8 @@ $isHxRequest = (isset($_SERVER['HTTP_HX_REQUEST']) && $_SERVER['HTTP_HX_REQUEST'
|
|||||||
$section = $_POST['section'] ?? '';
|
$section = $_POST['section'] ?? '';
|
||||||
|
|
||||||
if ($section === 'formulaire') {
|
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 = [
|
$allowed = [
|
||||||
'access_type_libre_enabled',
|
'access_type_libre_enabled',
|
||||||
'access_type_interne_enabled',
|
'access_type_interne_enabled',
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
|||||||
$langName = strtolower($langName);
|
$langName = strtolower($langName);
|
||||||
if ($langName === '') continue;
|
if ($langName === '') continue;
|
||||||
// Lookup case-insensitively; insert if missing (stored lowercase).
|
// 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]);
|
$s->execute([$langName]);
|
||||||
$r = $s->fetch();
|
$r = $s->fetch();
|
||||||
$langId = $r ? (int)$r['id'] : null;
|
$langId = $r ? (int)$r['id'] : null;
|
||||||
@@ -470,6 +470,9 @@ try {
|
|||||||
$years = $db->getAllYears();
|
$years = $db->getAllYears();
|
||||||
$orientations = $db->getAllOrientations();
|
$orientations = $db->getAllOrientations();
|
||||||
$apPrograms = $db->getAllAPPrograms();
|
$apPrograms = $db->getAllAPPrograms();
|
||||||
|
$trashCount = $db->countTrashedTheses();
|
||||||
|
$tab = $_GET['tab'] ?? 'list';
|
||||||
|
$trashedTheses = ($tab === 'trash') ? $db->getTrashedTheses() : [];
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Error loading theses list: " . $e->getMessage());
|
error_log("Error loading theses list: " . $e->getMessage());
|
||||||
die("Erreur lors du chargement de la liste.");
|
die("Erreur lors du chargement de la liste.");
|
||||||
@@ -478,11 +481,19 @@ try {
|
|||||||
$isHtmx = ($_SERVER['HTTP_HX_REQUEST'] ?? '') === 'true';
|
$isHtmx = ($_SERVER['HTTP_HX_REQUEST'] ?? '') === 'true';
|
||||||
$isAdmin = true; $bodyClass = 'admin-body';
|
$isAdmin = true; $bodyClass = 'admin-body';
|
||||||
if ($isHtmx) {
|
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 {
|
} else {
|
||||||
require_once APP_ROOT . '/templates/head.php';
|
require_once APP_ROOT . '/templates/head.php';
|
||||||
include APP_ROOT . '/templates/header.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';
|
require_once APP_ROOT . '/templates/admin/footer.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
80
app/src/Audit.php
Normal file
80
app/src/Audit.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data-level audit logger.
|
||||||
|
*
|
||||||
|
* Writes a row to the `audit_log` table for every INSERT, UPDATE, or DELETE
|
||||||
|
* on core data tables. Unlike AdminLogger (which tracks admin *actions*),
|
||||||
|
* this captures the actual data change — before and after snapshots.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* Audit::log($db, $actor, 'DELETE', 'tags', $tagId, $oldRow);
|
||||||
|
* Audit::log($db, $actor, 'UPDATE', 'theses', $thesisId, $oldRow, $newRow);
|
||||||
|
* Audit::log($db, $actor, 'INSERT', 'tags', $newId, null, $newRow);
|
||||||
|
*/
|
||||||
|
class Audit
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Log a data mutation.
|
||||||
|
*
|
||||||
|
* @param Database $db Database instance.
|
||||||
|
* @param string $actor Who triggered this (IP, username, 'system', etc.)
|
||||||
|
* @param string $action 'INSERT', 'UPDATE', or 'DELETE'.
|
||||||
|
* @param string $tableName The table being mutated.
|
||||||
|
* @param int|null $recordId The primary key of the affected row.
|
||||||
|
* @param array|null $oldData Row data before the mutation (null for INSERT).
|
||||||
|
* @param array|null $newData Row data after the mutation (null for DELETE).
|
||||||
|
*/
|
||||||
|
public static function log(
|
||||||
|
Database $db,
|
||||||
|
string $actor,
|
||||||
|
string $action,
|
||||||
|
string $tableName,
|
||||||
|
?int $recordId,
|
||||||
|
?array $oldData = null,
|
||||||
|
?array $newData = null
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$stmt = $db->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,17 @@ class Database
|
|||||||
return $root . '/storage/xamxam.db';
|
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)
|
* Get singleton instance (for front-backend)
|
||||||
* @return Database
|
* @return Database
|
||||||
@@ -154,7 +165,7 @@ class Database
|
|||||||
public function getLatestYearTheses(int $limit = 24): array
|
public function getLatestYearTheses(int $limit = 24): array
|
||||||
{
|
{
|
||||||
$sql = 'SELECT * FROM v_theses_public
|
$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()
|
ORDER BY RANDOM()
|
||||||
LIMIT :limit';
|
LIMIT :limit';
|
||||||
$stmt = $this->pdo->prepare($sql);
|
$stmt = $this->pdo->prepare($sql);
|
||||||
@@ -168,7 +179,7 @@ class Database
|
|||||||
*/
|
*/
|
||||||
public function getLatestPublishedYear(): ?int
|
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();
|
$val = $stmt->fetchColumn();
|
||||||
return $val ? (int)$val : null;
|
return $val ? (int)$val : null;
|
||||||
}
|
}
|
||||||
@@ -178,7 +189,7 @@ class Database
|
|||||||
*/
|
*/
|
||||||
public function countPublishedTheses()
|
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);
|
$stmt = $this->pdo->query($sql);
|
||||||
$result = $stmt->fetch();
|
$result = $stmt->fetch();
|
||||||
return $result['count'];
|
return $result['count'];
|
||||||
@@ -479,7 +490,7 @@ class Database
|
|||||||
FROM theses t
|
FROM theses t
|
||||||
JOIN thesis_authors ta ON ta.thesis_id = t.id
|
JOIN thesis_authors ta ON ta.thesis_id = t.id
|
||||||
JOIN authors a ON a.id = ta.author_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
|
GROUP BY t.id
|
||||||
ORDER BY MIN(a.name) ASC'
|
ORDER BY MIN(a.name) ASC'
|
||||||
);
|
);
|
||||||
@@ -541,7 +552,7 @@ class Database
|
|||||||
|
|
||||||
public function getAvailableYears()
|
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);
|
$stmt = $this->pdo->query($sql);
|
||||||
return $stmt->fetchAll(PDO::FETCH_COLUMN);
|
return $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
}
|
}
|
||||||
@@ -581,7 +592,7 @@ class Database
|
|||||||
$sql = 'SELECT DISTINCT tg.id, tg.name FROM tags tg
|
$sql = 'SELECT DISTINCT tg.id, tg.name FROM tags tg
|
||||||
JOIN thesis_tags tt ON tg.id = tt.tag_id
|
JOIN thesis_tags tt ON tg.id = tt.tag_id
|
||||||
JOIN theses th ON tt.thesis_id = th.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';
|
ORDER BY tg.name';
|
||||||
$stmt = $this->pdo->query($sql);
|
$stmt = $this->pdo->query($sql);
|
||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
@@ -617,7 +628,7 @@ class Database
|
|||||||
|
|
||||||
// Build WHERE + bindings excluding one dimension (for that column's own relevance)
|
// Build WHERE + bindings excluding one dimension (for that column's own relevance)
|
||||||
$buildWhere = function (string $exclude) use ($filters): array {
|
$buildWhere = function (string $exclude) use ($filters): array {
|
||||||
$conditions = ['t.is_published = 1'];
|
$conditions = ['t.is_published = 1', 't.deleted_at IS NULL'];
|
||||||
$bindings = [];
|
$bindings = [];
|
||||||
|
|
||||||
if ($exclude !== 'years' && !empty($filters['years'])) {
|
if ($exclude !== 'years' && !empty($filters['years'])) {
|
||||||
@@ -670,7 +681,7 @@ class Database
|
|||||||
// Years — single-valued FK: use full intersection (including own filter).
|
// Years — single-valued FK: use full intersection (including own filter).
|
||||||
// Clicking one year should fade years that have zero theses in the current result.
|
// 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');
|
$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);
|
$yearsOut = array_map(fn ($y) => ['value' => $y, 'matched' => in_array($y, $matchedYearsIds, true)], $allYears);
|
||||||
|
|
||||||
// AP programs — single-valued FK: use full intersection.
|
// AP programs — single-valued FK: use full intersection.
|
||||||
@@ -701,7 +712,7 @@ class Database
|
|||||||
'SELECT DISTINCT tg.name FROM tags tg
|
'SELECT DISTINCT tg.name FROM tags tg
|
||||||
JOIN thesis_tags tt ON tg.id = tt.tag_id
|
JOIN thesis_tags tt ON tg.id = tt.tag_id
|
||||||
JOIN theses th ON tt.thesis_id = th.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');
|
), 'name');
|
||||||
$kwOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedKw, true)], $allKw);
|
$kwOut = array_map(fn ($n) => ['value' => $n, 'matched' => in_array($n, $matchedKw, true)], $allKw);
|
||||||
@@ -748,7 +759,7 @@ class Database
|
|||||||
*/
|
*/
|
||||||
public function getAllLanguages(): array
|
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();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,6 +783,7 @@ class Database
|
|||||||
CASE WHEN name GLOB '*[À-ÿ]*' THEN 0 ELSE 1 END AS is_ascii
|
CASE WHEN name GLOB '*[À-ÿ]*' THEN 0 ELSE 1 END AS is_ascii
|
||||||
FROM languages
|
FROM languages
|
||||||
WHERE LOWER(name) IN ('français', 'anglais', 'néerlandais', 'francais', 'neerlandais')
|
WHERE LOWER(name) IN ('français', 'anglais', 'néerlandais', 'francais', 'neerlandais')
|
||||||
|
AND deleted_at IS NULL
|
||||||
ORDER BY grp, is_ascii"
|
ORDER BY grp, is_ascii"
|
||||||
)->fetchAll();
|
)->fetchAll();
|
||||||
|
|
||||||
@@ -852,7 +864,7 @@ class Database
|
|||||||
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
|
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 thesis_authors ta ON t.id = ta.thesis_id
|
||||||
LEFT JOIN authors a ON ta.author_id = a.id
|
LEFT JOIN authors a ON ta.author_id = a.id
|
||||||
WHERE 1=1';
|
WHERE t.deleted_at IS NULL';
|
||||||
|
|
||||||
$params = [];
|
$params = [];
|
||||||
|
|
||||||
@@ -900,7 +912,7 @@ class Database
|
|||||||
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
|
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
|
||||||
LEFT JOIN authors a ON ta.author_id = a.id
|
LEFT JOIN authors a ON ta.author_id = a.id
|
||||||
LEFT JOIN access_types at ON t.access_type_id = at.id
|
LEFT JOIN access_types at ON t.access_type_id = at.id
|
||||||
WHERE 1=1';
|
WHERE t.deleted_at IS NULL';
|
||||||
|
|
||||||
$params = [];
|
$params = [];
|
||||||
|
|
||||||
@@ -957,7 +969,7 @@ class Database
|
|||||||
*/
|
*/
|
||||||
public function getAllYears(): array
|
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);
|
return $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -976,7 +988,8 @@ class Database
|
|||||||
COUNT(*) AS total,
|
COUNT(*) AS total,
|
||||||
SUM(CASE WHEN is_published = 1 THEN 1 ELSE 0 END) AS published,
|
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
|
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();
|
$row = $stmt->fetch();
|
||||||
return [
|
return [
|
||||||
@@ -1157,7 +1170,7 @@ class Database
|
|||||||
return null;
|
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]);
|
$stmt->execute([$name]);
|
||||||
$row = $stmt->fetch();
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
@@ -1167,7 +1180,12 @@ class Database
|
|||||||
|
|
||||||
$stmt = $this->pdo->prepare('INSERT INTO tags (name) VALUES (?)');
|
$stmt = $this->pdo->prepare('INSERT INTO tags (name) VALUES (?)');
|
||||||
$stmt->execute([$name]);
|
$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
|
COUNT(DISTINCT tt.thesis_id) as thesis_count
|
||||||
FROM tags tg
|
FROM tags tg
|
||||||
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
|
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
|
||||||
|
WHERE tg.deleted_at IS NULL
|
||||||
GROUP BY tg.id
|
GROUP BY tg.id
|
||||||
ORDER BY thesis_count DESC, tg.name COLLATE NOCASE
|
ORDER BY thesis_count DESC, tg.name COLLATE NOCASE
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
@@ -1199,7 +1218,7 @@ class Database
|
|||||||
COUNT(DISTINCT tt.thesis_id) as thesis_count
|
COUNT(DISTINCT tt.thesis_id) as thesis_count
|
||||||
FROM tags tg
|
FROM tags tg
|
||||||
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
|
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
|
GROUP BY tg.id
|
||||||
ORDER BY tg.name = ? DESC, thesis_count DESC, tg.name COLLATE NOCASE
|
ORDER BY tg.name = ? DESC, thesis_count DESC, tg.name COLLATE NOCASE
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
@@ -1219,6 +1238,7 @@ class Database
|
|||||||
COUNT(DISTINCT tt.thesis_id) as thesis_count
|
COUNT(DISTINCT tt.thesis_id) as thesis_count
|
||||||
FROM tags tg
|
FROM tags tg
|
||||||
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
|
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
|
||||||
|
WHERE tg.deleted_at IS NULL
|
||||||
GROUP BY tg.id
|
GROUP BY tg.id
|
||||||
ORDER BY tg.name COLLATE NOCASE
|
ORDER BY tg.name COLLATE NOCASE
|
||||||
');
|
');
|
||||||
@@ -1234,13 +1254,17 @@ class Database
|
|||||||
if ($newName === '') {
|
if ($newName === '') {
|
||||||
throw new Exception('Le nom du tag ne peut pas être vide.');
|
throw new Exception('Le nom du tag ne peut pas être vide.');
|
||||||
}
|
}
|
||||||
// Check uniqueness
|
// Check uniqueness (excluding soft-deleted rows)
|
||||||
$stmt = $this->pdo->prepare('SELECT id FROM tags WHERE name = ? AND id != ?');
|
$stmt = $this->pdo->prepare('SELECT id FROM tags WHERE name = ? AND id != ? AND deleted_at IS NULL');
|
||||||
$stmt->execute([$newName, $id]);
|
$stmt->execute([$newName, $id]);
|
||||||
if ($stmt->fetch()) {
|
if ($stmt->fetch()) {
|
||||||
throw new Exception('Un tag avec ce nom existe déjà.');
|
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]);
|
$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]);
|
')->execute([$targetId, $sourceId]);
|
||||||
// Delete the old source rows
|
// Delete the old source rows
|
||||||
$this->pdo->prepare('DELETE FROM thesis_tags WHERE tag_id = ?')->execute([$sourceId]);
|
$this->pdo->prepare('DELETE FROM thesis_tags WHERE tag_id = ?')->execute([$sourceId]);
|
||||||
// Delete the source tag itself
|
// Soft-delete the source tag itself
|
||||||
$this->pdo->prepare('DELETE FROM tags WHERE id = ?')->execute([$sourceId]);
|
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();
|
$this->pdo->commit();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->pdo->rollBack();
|
$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
|
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
|
COUNT(DISTINCT tl.thesis_id) as thesis_count
|
||||||
FROM languages l
|
FROM languages l
|
||||||
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
||||||
|
WHERE l.deleted_at IS NULL
|
||||||
GROUP BY LOWER(l.name)
|
GROUP BY LOWER(l.name)
|
||||||
ORDER BY thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE
|
ORDER BY thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
@@ -1307,7 +1338,7 @@ class Database
|
|||||||
COUNT(DISTINCT tl.thesis_id) as thesis_count
|
COUNT(DISTINCT tl.thesis_id) as thesis_count
|
||||||
FROM languages l
|
FROM languages l
|
||||||
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
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)
|
GROUP BY LOWER(l.name)
|
||||||
ORDER BY LOWER(MIN(l.name)) = LOWER(?) DESC, thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE
|
ORDER BY LOWER(MIN(l.name)) = LOWER(?) DESC, thesis_count DESC, LOWER(MIN(l.name)) COLLATE NOCASE
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
@@ -1329,6 +1360,7 @@ class Database
|
|||||||
COUNT(DISTINCT tl.thesis_id) as thesis_count
|
COUNT(DISTINCT tl.thesis_id) as thesis_count
|
||||||
FROM languages l
|
FROM languages l
|
||||||
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
||||||
|
WHERE l.deleted_at IS NULL
|
||||||
GROUP BY LOWER(l.name)
|
GROUP BY LOWER(l.name)
|
||||||
ORDER BY LOWER(MIN(l.name)) COLLATE NOCASE
|
ORDER BY LOWER(MIN(l.name)) COLLATE NOCASE
|
||||||
');
|
');
|
||||||
@@ -1343,6 +1375,7 @@ class Database
|
|||||||
$dupes = $this->pdo->query('
|
$dupes = $this->pdo->query('
|
||||||
SELECT LOWER(name) as lname, MIN(id) as keep_id
|
SELECT LOWER(name) as lname, MIN(id) as keep_id
|
||||||
FROM languages
|
FROM languages
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
GROUP BY LOWER(name)
|
GROUP BY LOWER(name)
|
||||||
HAVING COUNT(*) > 1
|
HAVING COUNT(*) > 1
|
||||||
')->fetchAll();
|
')->fetchAll();
|
||||||
@@ -1361,9 +1394,9 @@ class Database
|
|||||||
)
|
)
|
||||||
')->execute([$dup['lname'], $dup['keep_id']]);
|
')->execute([$dup['lname'], $dup['keep_id']]);
|
||||||
|
|
||||||
$this->pdo->prepare('
|
$this->pdo->prepare(
|
||||||
DELETE FROM languages WHERE LOWER(name) = ? AND id != ?
|
"UPDATE languages SET deleted_at = datetime('now') WHERE LOWER(name) = ? AND id != ?"
|
||||||
')->execute([$dup['lname'], $dup['keep_id']]);
|
)->execute([$dup['lname'], $dup['keep_id']]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1376,16 +1409,20 @@ class Database
|
|||||||
if ($newName === '') {
|
if ($newName === '') {
|
||||||
throw new Exception('Le nom de la langue ne peut pas être vide.');
|
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]);
|
$stmt->execute([$newName, $id]);
|
||||||
if ($stmt->fetch()) {
|
if ($stmt->fetch()) {
|
||||||
throw new Exception('Une langue avec ce nom existe déjà.');
|
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]);
|
$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
|
public function mergeLanguage(int $sourceId, int $targetId): void
|
||||||
{
|
{
|
||||||
@@ -1399,7 +1436,11 @@ class Database
|
|||||||
SELECT ?, thesis_id FROM thesis_languages WHERE language_id = ?
|
SELECT ?, thesis_id FROM thesis_languages WHERE language_id = ?
|
||||||
')->execute([$targetId, $sourceId]);
|
')->execute([$targetId, $sourceId]);
|
||||||
$this->pdo->prepare('DELETE FROM thesis_languages WHERE language_id = ?')->execute([$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();
|
$this->pdo->commit();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->pdo->rollBack();
|
$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
|
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
|
public function setVisibility(int $thesisId, ?int $accessTypeId): void
|
||||||
{
|
{
|
||||||
|
require_once __DIR__ . '/Audit.php';
|
||||||
|
$old = $this->fetchRow('theses', $thesisId);
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
'UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||||
);
|
);
|
||||||
$stmt->execute([$accessTypeId, $thesisId]);
|
$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)) {
|
if (empty($thesisIds)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
require_once __DIR__ . '/Audit.php';
|
||||||
|
$actor = Audit::actor();
|
||||||
$placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
|
$placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
|
||||||
$params = array_merge([$accessTypeId], $thesisIds);
|
$params = array_merge([$accessTypeId], $thesisIds);
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)"
|
"UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)"
|
||||||
)->execute($params);
|
)->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
|
public function setPublished(int $thesisId, bool $published): void
|
||||||
{
|
{
|
||||||
|
require_once __DIR__ . '/Audit.php';
|
||||||
|
$old = $this->fetchRow('theses', $thesisId);
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
'UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
'UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||||
)->execute([$published ? 1 : 0, $thesisId]);
|
)->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)) {
|
if (empty($thesisIds)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
require_once __DIR__ . '/Audit.php';
|
||||||
|
$actor = Audit::actor();
|
||||||
$placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
|
$placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
|
||||||
$params = array_merge([$published ? 1 : 0], $thesisIds);
|
$params = array_merge([$published ? 1 : 0], $thesisIds);
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
"UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)"
|
"UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)"
|
||||||
)->execute($params);
|
)->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.');
|
throw new \InvalidArgumentException('Language name must not be empty.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Exact lowercase match
|
// 1. Exact lowercase match (skip soft-deleted rows)
|
||||||
$stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) LIMIT 1');
|
$stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND deleted_at IS NULL LIMIT 1');
|
||||||
$stmt->execute([$name]);
|
$stmt->execute([$name]);
|
||||||
$id = $stmt->fetchColumn();
|
$id = $stmt->fetchColumn();
|
||||||
if ($id !== false) {
|
if ($id !== false) {
|
||||||
@@ -1733,7 +1797,7 @@ class Database
|
|||||||
// iconv 'ASCII//TRANSLIT' turns é→e, ç→c, etc.
|
// iconv 'ASCII//TRANSLIT' turns é→e, ç→c, etc.
|
||||||
$asciiName = @iconv('UTF-8', 'ASCII//TRANSLIT', $name);
|
$asciiName = @iconv('UTF-8', 'ASCII//TRANSLIT', $name);
|
||||||
if ($asciiName !== false && $asciiName !== $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) {
|
foreach ($all as $row) {
|
||||||
$rowAscii = @iconv('UTF-8', 'ASCII//TRANSLIT', strtolower($row['name']));
|
$rowAscii = @iconv('UTF-8', 'ASCII//TRANSLIT', strtolower($row['name']));
|
||||||
if ($rowAscii !== false && $rowAscii === $asciiName) {
|
if ($rowAscii !== false && $rowAscii === $asciiName) {
|
||||||
@@ -1743,7 +1807,12 @@ class Database
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->pdo->prepare('INSERT INTO languages (name) VALUES (?)')->execute([$name]);
|
$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
|
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('
|
$stmt = $this->pdo->prepare('
|
||||||
UPDATE theses SET
|
UPDATE theses SET
|
||||||
title = ?,
|
title = ?,
|
||||||
@@ -2082,29 +2156,28 @@ class Database
|
|||||||
!empty($data['cc2r']) ? 1 : 0,
|
!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).
|
* Soft-delete a single thesis (sets deleted_at).
|
||||||
* Removes thesis files from disk (covers are stored in thesis_files and handled here).
|
|
||||||
*/
|
*/
|
||||||
public function deleteThesis(int $thesisId): void
|
public function deleteThesis(int $thesisId): void
|
||||||
{
|
{
|
||||||
// Clean up thesis files from disk
|
require_once __DIR__ . '/Audit.php';
|
||||||
$files = $this->getThesisFiles($thesisId);
|
$old = $this->fetchRow('theses', $thesisId);
|
||||||
foreach ($files as $file) {
|
$this->pdo->prepare("UPDATE theses SET deleted_at = datetime('now') WHERE id = ?")->execute([$thesisId]);
|
||||||
if (!empty($file['file_path']) && file_exists($file['file_path'])) {
|
Audit::log($this, Audit::actor(), 'DELETE', 'theses', $thesisId, $old);
|
||||||
@unlink($file['file_path']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DB cascade handles junction tables
|
|
||||||
$this->pdo->prepare('DELETE FROM theses WHERE id = ?')->execute([$thesisId]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete multiple theses at once.
|
* Soft-delete multiple theses at once.
|
||||||
* @param int[] $thesisIds
|
* @param int[] $thesisIds
|
||||||
*/
|
*/
|
||||||
public function bulkDeleteTheses(array $thesisIds): void
|
public function bulkDeleteTheses(array $thesisIds): void
|
||||||
@@ -2113,18 +2186,98 @@ class Database
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up files for each thesis
|
require_once __DIR__ . '/Audit.php';
|
||||||
|
$actor = Audit::actor();
|
||||||
|
|
||||||
foreach ($thesisIds as $id) {
|
foreach ($thesisIds as $id) {
|
||||||
$files = $this->getThesisFiles($id);
|
$old = $this->fetchRow('theses', $id);
|
||||||
foreach ($files as $file) {
|
$this->pdo->prepare("UPDATE theses SET deleted_at = datetime('now') WHERE id = ?")->execute([$id]);
|
||||||
if (!empty($file['file_path']) && file_exists($file['file_path'])) {
|
Audit::log($this, $actor, 'DELETE', 'theses', $id, $old);
|
||||||
@unlink($file['file_path']);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = ?')->execute([$thesisId]);
|
||||||
$this->pdo->prepare("DELETE FROM theses WHERE id IN ($placeholders)")->execute($thesisIds);
|
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
|
public function deleteThesisFile(int $fileId, int $thesisId): ?string
|
||||||
{
|
{
|
||||||
$stmt = $this->pdo->prepare(
|
$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]);
|
$stmt->execute([$fileId, $thesisId]);
|
||||||
$row = $stmt->fetch();
|
$row = $stmt->fetch();
|
||||||
@@ -2210,6 +2363,10 @@ class Database
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$this->pdo->prepare('DELETE FROM thesis_files WHERE id = ?')->execute([$fileId]);
|
$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'];
|
return $row['file_path'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
app/storage/backups/db-2026-05-11T01-03-26.db.gz
Normal file
BIN
app/storage/backups/db-2026-05-11T01-03-26.db.gz
Normal file
Binary file not shown.
@@ -84,7 +84,8 @@ INSERT OR IGNORE INTO finality_types (name) VALUES
|
|||||||
CREATE TABLE IF NOT EXISTS languages (
|
CREATE TABLE IF NOT EXISTS languages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
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
|
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 (
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
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);
|
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 acceptance (collected in student form)
|
||||||
cc2r BOOLEAN DEFAULT 0,
|
cc2r BOOLEAN DEFAULT 0,
|
||||||
|
|
||||||
|
-- Soft delete support
|
||||||
|
deleted_at TEXT DEFAULT NULL,
|
||||||
|
|
||||||
-- Timestamps
|
-- Timestamps
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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 thesis_supervisors ts ON t.id = ts.thesis_id
|
||||||
LEFT JOIN supervisors s ON ts.supervisor_id = s.id
|
LEFT JOIN supervisors s ON ts.supervisor_id = s.id
|
||||||
LEFT JOIN thesis_languages tl ON t.id = tl.thesis_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 thesis_formats tf ON t.id = tf.thesis_id
|
||||||
LEFT JOIN format_types fmt ON tf.format_id = fmt.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 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;
|
GROUP BY t.id;
|
||||||
|
|
||||||
-- Published theses only (for public view)
|
-- Published theses only (for public view)
|
||||||
|
|||||||
@@ -584,6 +584,32 @@
|
|||||||
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
|
+%%%%%%% 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)
|
+\\\\\\\ to: psvklxsu ec511543 "fix: exclude entire var/ from rsync --delete to preserve logs" (rebased revision)
|
||||||
++ $linkName = $link['name'] ?? '';
|
++ $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'])) : '';
|
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
|
||||||
?>
|
?>
|
||||||
<tr class="admin-table-row" onclick="event.stopPropagation(); window.open('/partage/<?= urlencode($link['slug']) ?>', '_blank')" style="cursor:pointer">
|
<tr class="admin-table-row" onclick="event.stopPropagation(); window.open('/partage/<?= urlencode($link['slug']) ?>', '_blank')" style="cursor:pointer">
|
||||||
|
|||||||
@@ -88,16 +88,17 @@
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Restrictions d'accès aux fichiers</legend>
|
<legend>Restrictions d'accès aux fichiers</legend>
|
||||||
|
|
||||||
<div class="param-form">
|
<div class="param-form" id="param-form-restrictions">
|
||||||
<input type="hidden" id="settings-csrf" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
|
|
||||||
<label class="param-checkbox">
|
<label class="param-checkbox">
|
||||||
|
<input type="hidden" name="restricted_files_enabled" value="0">
|
||||||
<input type="checkbox" name="restricted_files_enabled" value="1"
|
<input type="checkbox" name="restricted_files_enabled" value="1"
|
||||||
<?= ($siteSettings['restricted_files_enabled'] ?? '0') === '1' ? 'checked' : '' ?>
|
<?= ($siteSettings['restricted_files_enabled'] ?? '0') === '1' ? 'checked' : '' ?>
|
||||||
hx-post="/admin/actions/settings.php"
|
hx-post="/admin/actions/settings.php"
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-include="#settings-csrf"
|
hx-include="#param-form-restrictions"
|
||||||
hx-vals='{"section":"formulaire"}'>
|
hx-vals='{"section":"formulaire"}'>
|
||||||
<span>
|
<span>
|
||||||
<strong>Activer la restriction d'accès</strong><br>
|
<strong>Activer la restriction d'accès</strong><br>
|
||||||
@@ -111,16 +112,17 @@
|
|||||||
<legend>Degré d'ouverture</legend>
|
<legend>Degré d'ouverture</legend>
|
||||||
<p>Options de visibilité disponibles dans le formulaire d'ajout de TFE.</p>
|
<p>Options de visibilité disponibles dans le formulaire d'ajout de TFE.</p>
|
||||||
|
|
||||||
<div class="param-form">
|
<div class="param-form" id="param-form-acces">
|
||||||
<input type="hidden" id="settings-csrf-acces" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
|
|
||||||
<label class="param-checkbox">
|
<label class="param-checkbox">
|
||||||
|
<input type="hidden" name="access_type_libre_enabled" value="0">
|
||||||
<input type="checkbox" name="access_type_libre_enabled" value="1"
|
<input type="checkbox" name="access_type_libre_enabled" value="1"
|
||||||
<?= ($siteSettings['access_type_libre_enabled'] ?? '0') === '1' ? 'checked' : '' ?>
|
<?= ($siteSettings['access_type_libre_enabled'] ?? '0') === '1' ? 'checked' : '' ?>
|
||||||
hx-post="/admin/actions/settings.php"
|
hx-post="/admin/actions/settings.php"
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-include="#settings-csrf-acces"
|
hx-include="#param-form-acces"
|
||||||
hx-vals='{"section":"formulaire"}'>
|
hx-vals='{"section":"formulaire"}'>
|
||||||
<span>
|
<span>
|
||||||
<strong>Libre</strong><br>
|
<strong>Libre</strong><br>
|
||||||
@@ -129,12 +131,13 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="param-checkbox">
|
<label class="param-checkbox">
|
||||||
|
<input type="hidden" name="access_type_interne_enabled" value="0">
|
||||||
<input type="checkbox" name="access_type_interne_enabled" value="1"
|
<input type="checkbox" name="access_type_interne_enabled" value="1"
|
||||||
<?= ($siteSettings['access_type_interne_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
|
<?= ($siteSettings['access_type_interne_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
|
||||||
hx-post="/admin/actions/settings.php"
|
hx-post="/admin/actions/settings.php"
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-include="#settings-csrf-acces"
|
hx-include="#param-form-acces"
|
||||||
hx-vals='{"section":"formulaire"}'>
|
hx-vals='{"section":"formulaire"}'>
|
||||||
<span>
|
<span>
|
||||||
<strong>Interne</strong><br>
|
<strong>Interne</strong><br>
|
||||||
@@ -143,12 +146,13 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="param-checkbox">
|
<label class="param-checkbox">
|
||||||
|
<input type="hidden" name="access_type_interdit_enabled" value="0">
|
||||||
<input type="checkbox" name="access_type_interdit_enabled" value="1"
|
<input type="checkbox" name="access_type_interdit_enabled" value="1"
|
||||||
<?= ($siteSettings['access_type_interdit_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
|
<?= ($siteSettings['access_type_interdit_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
|
||||||
hx-post="/admin/actions/settings.php"
|
hx-post="/admin/actions/settings.php"
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-include="#settings-csrf-acces"
|
hx-include="#param-form-acces"
|
||||||
hx-vals='{"section":"formulaire"}'>
|
hx-vals='{"section":"formulaire"}'>
|
||||||
<span>
|
<span>
|
||||||
<strong>Interdit</strong><br>
|
<strong>Interdit</strong><br>
|
||||||
@@ -163,8 +167,8 @@
|
|||||||
<p>Active ou désactive les types de travaux dans les formulaires et la consultation. Un type désactivé ne peut plus être soumis ni affiché sur le site.</p>
|
<p>Active ou désactive les types de travaux dans les formulaires et la consultation. Un type désactivé ne peut plus être soumis ni affiché sur le site.</p>
|
||||||
<p class="param-note">Le type <strong>TFE</strong> est toujours actif et ne peut pas être désactivé.</p>
|
<p class="param-note">Le type <strong>TFE</strong> est toujours actif et ne peut pas être désactivé.</p>
|
||||||
|
|
||||||
<div class="param-form">
|
<div class="param-form" id="param-form-types">
|
||||||
<input type="hidden" id="settings-csrf-types" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
|
|
||||||
<label class="param-checkbox param-checkbox--disabled">
|
<label class="param-checkbox param-checkbox--disabled">
|
||||||
<input type="checkbox" disabled checked>
|
<input type="checkbox" disabled checked>
|
||||||
@@ -175,12 +179,13 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="param-checkbox">
|
<label class="param-checkbox">
|
||||||
|
<input type="hidden" name="objet_these_enabled" value="0">
|
||||||
<input type="checkbox" name="objet_these_enabled" value="1"
|
<input type="checkbox" name="objet_these_enabled" value="1"
|
||||||
<?= ($siteSettings['objet_these_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
|
<?= ($siteSettings['objet_these_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
|
||||||
hx-post="/admin/actions/settings.php"
|
hx-post="/admin/actions/settings.php"
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-include="#settings-csrf-types"
|
hx-include="#param-form-types"
|
||||||
hx-vals='{"section":"objet_types"}'>
|
hx-vals='{"section":"objet_types"}'>
|
||||||
<span>
|
<span>
|
||||||
<strong>Thèse</strong><br>
|
<strong>Thèse</strong><br>
|
||||||
@@ -189,12 +194,13 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="param-checkbox">
|
<label class="param-checkbox">
|
||||||
|
<input type="hidden" name="objet_frart_enabled" value="0">
|
||||||
<input type="checkbox" name="objet_frart_enabled" value="1"
|
<input type="checkbox" name="objet_frart_enabled" value="1"
|
||||||
<?= ($siteSettings['objet_frart_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
|
<?= ($siteSettings['objet_frart_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
|
||||||
hx-post="/admin/actions/settings.php"
|
hx-post="/admin/actions/settings.php"
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-include="#settings-csrf-types"
|
hx-include="#param-form-types"
|
||||||
hx-vals='{"section":"objet_types"}'>
|
hx-vals='{"section":"objet_types"}'>
|
||||||
<span>
|
<span>
|
||||||
<strong>Frart</strong><br>
|
<strong>Frart</strong><br>
|
||||||
|
|||||||
82
app/templates/admin/index-trash.php
Normal file
82
app/templates/admin/index-trash.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<main id="main-content" class="admin-main--list">
|
||||||
|
<div class="admin-list-toolbar admin-list-toolbar--list">
|
||||||
|
<div class="admin-toolbar-top">
|
||||||
|
<div class="admin-toolbar-title-row">
|
||||||
|
<h1><a href="/admin/" class="admin-back-btn" title="Retour à la liste"><svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm48-88a8,8,0,0,1-8,8H107.31l18.35,18.34a8,8,0,0,1-11.32,11.32l-32-32a8,8,0,0,1,0-11.32l32-32a8,8,0,0,1,11.32,11.32L107.31,120H168A8,8,0,0,1,176,128Z"></path></svg></a> Corbeille</h1>
|
||||||
|
<span class="admin-stat admin-stat--inline" style="margin-left:auto"><?= count($trashedTheses) ?> TFE(s)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$flash = App::consumeFlash();
|
||||||
|
?>
|
||||||
|
<?php if ($flash['success']): ?>
|
||||||
|
<div class="flash-success" role="alert"><?= htmlspecialchars($flash['success']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($flash['error']): ?>
|
||||||
|
<div class="flash-error" role="alert"><?= htmlspecialchars($flash['error']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (empty($trashedTheses)): ?>
|
||||||
|
<div class="admin-empty">La corbeille est vide.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="post" action="actions/corbeille.php" style="margin-bottom:var(--space-xs)">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
|
<input type="hidden" name="action" value="empty_trash">
|
||||||
|
<button type="submit" class="btn btn--danger btn--sm" onclick="return confirm('Vider toute la corbeille ? Cette action est irréversible.')">
|
||||||
|
Vider la corbeille
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">ID</th>
|
||||||
|
<th scope="col">Titre</th>
|
||||||
|
<th scope="col">Auteur(s)</th>
|
||||||
|
<th scope="col">Année</th>
|
||||||
|
<th scope="col">Supprimé le</th>
|
||||||
|
<th scope="col" style="width:1%">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($trashedTheses as $t): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= htmlspecialchars($t['identifier'] ?? '#' . $t['id']) ?></td>
|
||||||
|
<td>
|
||||||
|
<strong><?= htmlspecialchars($t['title']) ?></strong>
|
||||||
|
<?php if (!empty($t['subtitle'])): ?>
|
||||||
|
<br><small><?= htmlspecialchars($t['subtitle']) ?></small>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td><?= htmlspecialchars($t['authors'] ?? '—') ?></td>
|
||||||
|
<td><?= (int)$t['year'] ?></td>
|
||||||
|
<td><?= htmlspecialchars($t['deleted_at']) ?></td>
|
||||||
|
<td class="admin-actions-col">
|
||||||
|
<div class="admin-actions">
|
||||||
|
<form method="post" action="actions/corbeille.php" class="admin-inline-form" style="display:inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
|
<input type="hidden" name="action" value="restore">
|
||||||
|
<input type="hidden" name="thesis_id" value="<?= (int)$t['id'] ?>">
|
||||||
|
<button type="submit" class="admin-icon-btn admin-icon-btn--edit" title="Restaurer">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM176,208H80a8,8,0,0,1,0-16h96a8,8,0,0,1,0,16Zm0-32H80a8,8,0,0,1,0-16h96a8,8,0,0,1,0,16Z"/></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="actions/corbeille.php" class="admin-inline-form" style="display:inline"
|
||||||
|
onsubmit="return confirm('Supprimer définitivement ce TFE ? Cette action est irréversible.')">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
|
<input type="hidden" name="action" value="hard_delete">
|
||||||
|
<input type="hidden" name="thesis_id" value="<?= (int)$t['id'] ?>">
|
||||||
|
<button type="submit" class="admin-icon-btn admin-icon-btn--delete" title="Supprimer définitivement">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"/></svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</main>
|
||||||
@@ -32,6 +32,11 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input
|
|||||||
<div class="admin-btn-group">
|
<div class="admin-btn-group">
|
||||||
<a href="/admin/add.php" class="btn btn--primary btn--sm">+ Ajouter un TFE</a>
|
<a href="/admin/add.php" class="btn btn--primary btn--sm">+ Ajouter un TFE</a>
|
||||||
<a href="/admin/tags.php" class="btn btn--primary btn--sm">Mots-clés</a>
|
<a href="/admin/tags.php" class="btn btn--primary btn--sm">Mots-clés</a>
|
||||||
|
<?php if ($trashCount > 0): ?>
|
||||||
|
<a href="/admin/index.php?tab=trash" class="btn btn--sm <?= $tab === 'trash' ? 'btn--primary' : 'btn--secondary' ?>">
|
||||||
|
Corbeille (<?= $trashCount ?>)
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
<button type="button" class="btn btn--primary btn--sm" id="import-dialog-btn"
|
<button type="button" class="btn btn--primary btn--sm" id="import-dialog-btn"
|
||||||
onclick="document.getElementById('import-dialog').showModal()">
|
onclick="document.getElementById('import-dialog').showModal()">
|
||||||
Importer
|
Importer
|
||||||
|
|||||||
5
justfile
5
justfile
@@ -176,6 +176,11 @@ query:
|
|||||||
backup:
|
backup:
|
||||||
@sqlite3 app/storage/xamxam.db .dump > app/storage/backup_$(date +%Y%m%d_%H%M%S).sql
|
@sqlite3 app/storage/xamxam.db .dump > app/storage/backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
|
||||||
|
[group('database')]
|
||||||
|
backup-snapshot:
|
||||||
|
# Hot backup using SQLite's .backup API (WAL-safe), then gzip.
|
||||||
|
@DB_PATH=app/storage/xamxam.db BACKUP_DIR=app/storage/backups RETENTION_DAYS=30 bash scripts/backup-sqlite.sh
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Utils
|
# Utils
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
35
scripts/backup-sqlite.sh
Executable file
35
scripts/backup-sqlite.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# backup-sqlite.sh — Safe hot backup of the XAMXAM SQLite database.
|
||||||
|
#
|
||||||
|
# Uses sqlite3's .backup command (WAL-safe) then gzip-compresses.
|
||||||
|
# Prunes backups older than RETENTION_DAYS.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# /usr/local/bin/backup-sqlite.sh # default: 30 days
|
||||||
|
# RETENTION_DAYS=90 /usr/local/bin/backup-sqlite.sh # 90 days
|
||||||
|
#
|
||||||
|
# Expected to be run from cron:
|
||||||
|
# 0 * * * * /usr/local/bin/backup-sqlite.sh >> /var/log/sqlite-backup.log 2>&1
|
||||||
|
# 0 2 * * * RETENTION_DAYS=90 /usr/local/bin/backup-sqlite.sh >> /var/log/sqlite-backup.log 2>&1
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DB_PATH="${DB_PATH:-/var/www/xamxam/storage/xamxam.db}"
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/var/backups/xamxam}"
|
||||||
|
RETENTION_DAYS="${RETENTION_DAYS:-30}"
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +"%Y-%m-%dT%H-%M-%S")
|
||||||
|
BACKUP_FILE="$BACKUP_DIR/db-$TIMESTAMP.db.gz"
|
||||||
|
TMP_SNAPSHOT="/tmp/xamxam-snapshot-$$.db"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# Safe hot backup using SQLite's online backup API
|
||||||
|
sqlite3 "$DB_PATH" ".backup $TMP_SNAPSHOT"
|
||||||
|
gzip -c "$TMP_SNAPSHOT" > "$BACKUP_FILE"
|
||||||
|
rm -f "$TMP_SNAPSHOT"
|
||||||
|
|
||||||
|
# Prune old backups
|
||||||
|
find "$BACKUP_DIR" -name "*.db.gz" -mtime "+${RETENTION_DAYS}" -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup written: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
|
||||||
Reference in New Issue
Block a user