mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
feat: fix file deletion on save + trash policy + documents/ prefix + relink browser
1. note_intention: Delete old file only when a genuinely new upload arrives
(32-char hex file_id), not when the FilePond pool preserves an existing
file by sending its DB integer ID. Previously the DB integer ID
triggered $hasNewNote=true, which deleted the existing note_intention
from disk+DB, then handleFilePondSingleFile couldn't re-process it
because the regex requires a hex pattern. Same fix applied to cover.
2. All file deletions now use deleteThesisFileToTrash() which renames
files to tmp/_trash/ instead of unlinking. The trash preserves
original filenames prefixed with DB id for traceability. Skips
website URLs and PeerTube refs (no disk file).
3. Storage prefix changed from theses/ to documents/ to reflect that
the folder holds all document types (determined by file_type in DB).
MediaController visibility gate supports both prefixes for backward
compat with existing files.
4. File browser + relink feature for orphaned files:
- /admin/fragments/file-browser.php — HTMX tree browser for
storage/documents/ and storage/theses/
- /admin/actions/filepond/relink.php — POST endpoint that inserts
a thesis_files row pointing to existing on-disk file
- Per-pool "📂 Relier" buttons (edit mode only)
- JS: XamxamOpenFileBrowser / XamxamRelinkFile with FilePond integration
- CSS: .relink-modal dialog + .file-browser tree styles
This commit is contained in:
@@ -50,8 +50,8 @@ class MediaController
|
||||
exit;
|
||||
}
|
||||
|
||||
// 3. Visibility gate for thesis files
|
||||
if (preg_match('#^theses/#', $requestedPath)) {
|
||||
// 3. Visibility gate for thesis files (both legacy theses/ and new documents/ paths)
|
||||
if (preg_match('#^(theses|documents)/#', $requestedPath)) {
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
try {
|
||||
|
||||
@@ -191,7 +191,7 @@ class ThesisCreateController
|
||||
$tf = $this->buildThesisFolder($data['annee'], $allAuthorsStr, $data['titre']);
|
||||
$folderName = $this->ensureUniqueFolder($tf['folderPath']);
|
||||
// Rebuild path with potentially modified folder name
|
||||
$folderPath = 'theses/' . $data['annee'] . '/' . $folderName . '/';
|
||||
$folderPath = 'documents/' . $data['annee'] . '/' . $folderName . '/';
|
||||
$filePrefix = $folderName;
|
||||
|
||||
if (!empty($post['filepond_mode'])) {
|
||||
|
||||
@@ -322,11 +322,11 @@ class ThesisEditController
|
||||
$existingFiles = $this->db->getThesisFiles($thesisId);
|
||||
foreach ($existingFiles as $f) {
|
||||
$fp = $f['file_path'] ?? '';
|
||||
if (str_starts_with($fp, 'theses/')) {
|
||||
if (str_starts_with($fp, 'documents/') || str_starts_with($fp, 'theses/')) {
|
||||
$parts = explode('/', $fp);
|
||||
if (count($parts) >= 3) {
|
||||
$folderName = $parts[2];
|
||||
$folderPath = 'theses/' . $year . '/' . $folderName . '/';
|
||||
$folderPath = 'documents/' . $year . '/' . $folderName . '/';
|
||||
$filePrefix = $folderName;
|
||||
break;
|
||||
}
|
||||
@@ -341,17 +341,15 @@ class ThesisEditController
|
||||
|
||||
// ── Cover image (outside transaction — filesystem op) ─────────────────
|
||||
if (!empty($post['filepond_mode'])) {
|
||||
// Async path: cover file_id arrives in post, not $_FILES
|
||||
if (!empty($post['remove_cover'])) {
|
||||
// Delete old cover only if a genuinely new cover was uploaded (hex file_id).
|
||||
// Existing cover preserved in FilePond sends its DB integer ID — skip.
|
||||
$coverIdRaw = ($post['queue_file']['cover'] ?? null);
|
||||
$coverId = is_array($coverIdRaw) ? ($coverIdRaw[0] ?? null) : $coverIdRaw;
|
||||
$isNewCover = $coverId !== null && $coverId !== '' && preg_match('/^[a-f0-9]{32}$/', (string)$coverId);
|
||||
if ($isNewCover) {
|
||||
foreach ($existingFiles as $f) {
|
||||
if ($f['file_type'] === 'cover') {
|
||||
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
|
||||
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
|
||||
$abs = STORAGE_ROOT . '/' . $f['file_path'];
|
||||
if (file_exists($abs)) {
|
||||
@unlink($abs);
|
||||
}
|
||||
}
|
||||
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -360,13 +358,7 @@ class ThesisEditController
|
||||
} elseif (!empty($post['remove_cover'])) {
|
||||
foreach ($existingFiles as $f) {
|
||||
if ($f['file_type'] === 'cover') {
|
||||
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
|
||||
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
|
||||
$abs = STORAGE_ROOT . '/' . $f['file_path'];
|
||||
if (file_exists($abs)) {
|
||||
@unlink($abs);
|
||||
}
|
||||
}
|
||||
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -376,19 +368,16 @@ class ThesisEditController
|
||||
|
||||
// ── Note d'intention (replace if uploaded) ────────────────────────────
|
||||
if (!empty($post['filepond_mode'])) {
|
||||
// Remove old note_intention if new one is uploaded via async path
|
||||
$newNoteId = ($post['queue_file']['note_intention'] ?? null);
|
||||
$hasNewNote = $newNoteId !== null && (is_array($newNoteId) ? !empty($newNoteId) : $newNoteId !== '');
|
||||
if ($hasNewNote) {
|
||||
// Only delete + replace if a genuinely new file was uploaded (hex file_id).
|
||||
// Existing files preserved in the FilePond pool send their DB integer ID;
|
||||
// we must NOT delete them — they're already stored.
|
||||
$noteIdRaw = ($post['queue_file']['note_intention'] ?? null);
|
||||
$noteId = is_array($noteIdRaw) ? ($noteIdRaw[0] ?? null) : $noteIdRaw;
|
||||
$isNewNote = $noteId !== null && $noteId !== '' && preg_match('/^[a-f0-9]{32}$/', (string)$noteId);
|
||||
if ($isNewNote) {
|
||||
foreach ($existingFiles as $f) {
|
||||
if ($f['file_type'] === 'note_intention') {
|
||||
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
|
||||
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
|
||||
$abs = STORAGE_ROOT . '/' . $f['file_path'];
|
||||
if (file_exists($abs)) {
|
||||
@unlink($abs);
|
||||
}
|
||||
}
|
||||
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -399,13 +388,7 @@ class ThesisEditController
|
||||
if (!empty($files['note_intention']['tmp_name'] ?? null) && ($files['note_intention']['error'] ?? -1) === UPLOAD_ERR_OK) {
|
||||
foreach ($existingFiles as $f) {
|
||||
if ($f['file_type'] === 'note_intention') {
|
||||
$this->db->deleteThesisFile((int)$f['id'], $thesisId);
|
||||
if (!empty($f['file_path']) && defined('STORAGE_ROOT')) {
|
||||
$abs = STORAGE_ROOT . '/' . $f['file_path'];
|
||||
if (file_exists($abs)) {
|
||||
@unlink($abs);
|
||||
}
|
||||
}
|
||||
$this->deleteThesisFileToTrash((int)$f['id'], $thesisId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -421,16 +404,7 @@ class ThesisEditController
|
||||
if ($fileId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$filePath = $this->db->deleteThesisFile($fileId, $thesisId);
|
||||
if ($filePath && defined('STORAGE_ROOT')) {
|
||||
// Skip filesystem deletion for website URLs (not real files)
|
||||
if (!str_starts_with($filePath, 'http://') && !str_starts_with($filePath, 'https://')) {
|
||||
$abs = STORAGE_ROOT . '/' . $filePath;
|
||||
if (file_exists($abs)) {
|
||||
@unlink($abs);
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->deleteThesisFileToTrash($fileId, $thesisId);
|
||||
}
|
||||
|
||||
// ── Reorder existing files ────────────────────────────────────────────
|
||||
@@ -681,7 +655,7 @@ class ThesisEditController
|
||||
{
|
||||
$websiteUrl = trim($post['website_url'] ?? '');
|
||||
|
||||
// Remove existing website rows
|
||||
// Remove existing website rows (website URLs have no disk file)
|
||||
$existingFiles = $this->db->getThesisFiles($thesisId);
|
||||
foreach ($existingFiles as $f) {
|
||||
if ($f['file_type'] === 'website') {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Shared file-upload logic used by ThesisCreateController and
|
||||
* ThesisEditController. All on-disk files are stored under:
|
||||
*
|
||||
* theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
|
||||
* documents/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
|
||||
*
|
||||
* Filenames follow:
|
||||
*
|
||||
@@ -88,7 +88,7 @@ trait ThesisFileHandler
|
||||
*
|
||||
* @param int $thesisId
|
||||
* @param array|null $upload Single-file $_FILES entry.
|
||||
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder (e.g. "theses/2025/2025_SMITH_Mon_Titre/").
|
||||
* @param string $folderPath Relative path from STORAGE_ROOT to the thesis folder (e.g. "documents/2025/2025_SMITH_Mon_Titre/").
|
||||
* @param string $filePrefix The prefix shared by all files in this folder (e.g. "2025_SMITH_Mon_Titre").
|
||||
*/
|
||||
protected function handleCoverUpload(int $thesisId, ?array $upload, string $folderPath, string $filePrefix): void
|
||||
@@ -802,7 +802,7 @@ trait ThesisFileHandler
|
||||
/**
|
||||
* Build the folder path and file prefix for a thesis.
|
||||
*
|
||||
* Folder: theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
|
||||
* Folder: documents/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
|
||||
* Prefix: {YYYY}_{AUTHORS}_{TITLE_SLUG}
|
||||
*
|
||||
* @return array{folderPath: string, filePrefix: string, folderName: string}
|
||||
@@ -814,7 +814,7 @@ trait ThesisFileHandler
|
||||
|
||||
$folderName = $year . '_' . $authorSlug . '_' . $titleSlug;
|
||||
$filePrefix = $folderName;
|
||||
$folderPath = 'theses/' . $year . '/' . $folderName . '/';
|
||||
$folderPath = 'documents/' . $year . '/' . $folderName . '/';
|
||||
|
||||
return [
|
||||
'folderPath' => $folderPath,
|
||||
@@ -1203,4 +1203,55 @@ trait ThesisFileHandler
|
||||
}
|
||||
@rmdir($tmpDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a thesis_files row, moving the on-disk file to the trash directory
|
||||
* instead of unlinking it. Website URLs and PeerTube references are only
|
||||
* removed from the database (no disk file).
|
||||
*/
|
||||
protected function deleteThesisFileToTrash(int $fileId, int $thesisId): void
|
||||
{
|
||||
if ($fileId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the file row to get the path before deleting the DB record
|
||||
$existingFiles = $this->db->getThesisFiles($thesisId);
|
||||
$fileRow = null;
|
||||
foreach ($existingFiles as $f) {
|
||||
if ((int)$f['id'] === $fileId) {
|
||||
$fileRow = $f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$filePath = $this->db->deleteThesisFile($fileId, $thesisId);
|
||||
|
||||
if ($filePath && defined('STORAGE_ROOT')) {
|
||||
// Skip filesystem for website URLs and PeerTube IDs (not real files)
|
||||
if (str_starts_with($filePath, 'http://') || str_starts_with($filePath, 'https://')) {
|
||||
return;
|
||||
}
|
||||
if (str_starts_with($filePath, 'peertube_ids:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$abs = STORAGE_ROOT . '/' . $filePath;
|
||||
if (file_exists($abs)) {
|
||||
$trashDir = STORAGE_ROOT . '/tmp/_trash';
|
||||
if (!is_dir($trashDir)) {
|
||||
mkdir($trashDir, 0755, true);
|
||||
}
|
||||
// Keep original filename structure for traceability
|
||||
$trashName = $fileId . '_' . basename($filePath);
|
||||
$trashPath = $trashDir . '/' . $trashName;
|
||||
if (!rename($abs, $trashPath)) {
|
||||
// Fallback to unlink if rename fails (cross-device)
|
||||
@copy($abs, $trashPath);
|
||||
@unlink($abs);
|
||||
}
|
||||
error_log("ThesisFileHandler: file \$fileId moved to trash → \$trashName");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ class Database
|
||||
} catch (\PDOException $e) {
|
||||
// Column already exists — ignore
|
||||
}
|
||||
// Add 'locked_year' column to share_links if missing
|
||||
try {
|
||||
$this->pdo->exec("ALTER TABLE share_links ADD COLUMN locked_year INTEGER");
|
||||
} catch (\PDOException $e) {
|
||||
// Column already exists — ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1013,19 +1019,39 @@ class Database
|
||||
$email = null;
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare('SELECT id FROM authors WHERE name = ?');
|
||||
$cleanEmail = ($email !== null && $email !== '') ? $email : null;
|
||||
|
||||
// Try to find by name first
|
||||
$stmt = $this->pdo->prepare('SELECT id, email FROM authors WHERE name = ?');
|
||||
$stmt->execute([$name]);
|
||||
$author = $stmt->fetch();
|
||||
|
||||
if ($author) {
|
||||
// Always update email (may be null to clear) and show_contact.
|
||||
// Update email and show_contact unless that email belongs to another author.
|
||||
if ($cleanEmail !== null) {
|
||||
$dup = $this->pdo->prepare('SELECT id FROM authors WHERE email = ? AND id != ?');
|
||||
$dup->execute([$cleanEmail, $author['id']]);
|
||||
if ($dup->fetch()) {
|
||||
$cleanEmail = null; // don't steal another author's email
|
||||
}
|
||||
}
|
||||
$updateStmt = $this->pdo->prepare('UPDATE authors SET email = ?, show_contact = ? WHERE id = ?');
|
||||
$updateStmt->execute([$email && $email !== '' ? $email : null, $showContact ? 1 : 0, $author['id']]);
|
||||
$updateStmt->execute([$cleanEmail, $showContact ? 1 : 0, $author['id']]);
|
||||
return $author['id'];
|
||||
}
|
||||
|
||||
// If an author with this email already exists (different name), reuse that record.
|
||||
if ($cleanEmail !== null) {
|
||||
$byEmail = $this->pdo->prepare('SELECT id FROM authors WHERE email = ?');
|
||||
$byEmail->execute([$cleanEmail]);
|
||||
$existing = $byEmail->fetch();
|
||||
if ($existing) {
|
||||
return $existing['id'];
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare('INSERT INTO authors (name, email, show_contact) VALUES (?, ?, ?)');
|
||||
$stmt->execute([$name, $email, $showContact ? 1 : 0]);
|
||||
$stmt->execute([$name, $cleanEmail, $showContact ? 1 : 0]);
|
||||
return $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
@@ -1194,6 +1220,67 @@ class Database
|
||||
// TAG MANAGEMENT (admin)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Search supervisors by name prefix. Returns up to 10 matching supervisors.
|
||||
* If $query is empty, returns the most-used ones (up to 10).
|
||||
*
|
||||
* @param string $role Optional role filter: 'promoteur_interne' (is_external=0, role=promoteur),
|
||||
* 'promoteur_externe' (is_external=1, role=promoteur),
|
||||
* 'lecteur_interne' (is_external=0, role=lecteur),
|
||||
* 'lecteur_externe' (is_external=1, role=lecteur)
|
||||
*/
|
||||
public function searchSupervisors(string $query = '', string $role = ''): array
|
||||
{
|
||||
$query = trim($query);
|
||||
|
||||
// Map role to WHERE conditions
|
||||
$roleWhere = '';
|
||||
$roleParams = [];
|
||||
switch ($role) {
|
||||
case 'promoteur_interne':
|
||||
$roleWhere = 'AND ts.is_external = 0 AND ts.role = \'promoteur\'';
|
||||
break;
|
||||
case 'promoteur_externe':
|
||||
$roleWhere = 'AND ts.is_external = 1 AND ts.role = \'promoteur\'';
|
||||
break;
|
||||
case 'lecteur_interne':
|
||||
$roleWhere = 'AND ts.is_external = 0 AND ts.role = \'lecteur\'';
|
||||
break;
|
||||
case 'lecteur_externe':
|
||||
$roleWhere = 'AND ts.is_external = 1 AND ts.role = \'lecteur\'';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if ($query === '') {
|
||||
$stmt = $this->pdo->query('
|
||||
SELECT s.id, s.name,
|
||||
COUNT(DISTINCT ts.thesis_id) as thesis_count
|
||||
FROM supervisors s
|
||||
LEFT JOIN thesis_supervisors ts ON s.id = ts.supervisor_id
|
||||
' . $roleWhere . '
|
||||
GROUP BY s.id
|
||||
ORDER BY thesis_count DESC, s.name COLLATE NOCASE
|
||||
LIMIT 10
|
||||
');
|
||||
} else {
|
||||
$stmt = $this->pdo->prepare('
|
||||
SELECT s.id, s.name,
|
||||
COUNT(DISTINCT ts.thesis_id) as thesis_count
|
||||
FROM supervisors s
|
||||
LEFT JOIN thesis_supervisors ts ON s.id = ts.supervisor_id
|
||||
WHERE s.name LIKE ?
|
||||
' . $roleWhere . '
|
||||
GROUP BY s.id
|
||||
ORDER BY s.name = ? DESC, thesis_count DESC, s.name COLLATE NOCASE
|
||||
LIMIT 10
|
||||
');
|
||||
$stmt->execute([$query . '%', $query]);
|
||||
}
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search tags by name prefix. Returns up to 10 matching tags.
|
||||
* If $query is empty, returns the most-used tags (up to 10).
|
||||
@@ -1794,13 +1881,11 @@ class Database
|
||||
}
|
||||
|
||||
// 2. Accent-tolerant fallback: strip accents and re-compare.
|
||||
// iconv 'ASCII//TRANSLIT' turns é→e, ç→c, etc.
|
||||
$asciiName = @iconv('UTF-8', 'ASCII//TRANSLIT', $name);
|
||||
if ($asciiName !== false && $asciiName !== $name) {
|
||||
$asciiName = self::stripAccents($name);
|
||||
if ($asciiName !== $name) {
|
||||
$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) {
|
||||
if (self::stripAccents(strtolower($row['name'])) === $asciiName) {
|
||||
return (int)$row['id'];
|
||||
}
|
||||
}
|
||||
@@ -1952,7 +2037,7 @@ class Database
|
||||
}
|
||||
|
||||
/**
|
||||
* Check visibility for a file path under theses/.
|
||||
* Check visibility for a file path under documents/ or theses/.
|
||||
* Returns the access_type_id of the owning thesis, or null if the file
|
||||
* is not found or the path does not belong to a thesis file.
|
||||
*
|
||||
@@ -3071,4 +3156,50 @@ class Database
|
||||
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip accents from a UTF-8 string (é→e, ç→c, etc.).
|
||||
* Pure PHP fallback when iconv extension is not available.
|
||||
*/
|
||||
private static function stripAccents(string $str): string
|
||||
{
|
||||
if (function_exists('iconv')) {
|
||||
$result = @iconv('UTF-8', 'ASCII//TRANSLIT', $str);
|
||||
if ($result !== false) {
|
||||
return strtolower($result);
|
||||
}
|
||||
}
|
||||
|
||||
// Manual transliteration table for common accented chars
|
||||
static $map = null;
|
||||
if ($map === null) {
|
||||
$utf8 = [
|
||||
'À','Á','Â','Ã','Ä','Å','Æ','à','á','â','ã','ä','å','æ',
|
||||
'Ç','ç',
|
||||
'È','É','Ê','Ë','è','é','ê','ë',
|
||||
'Ì','Í','Î','Ï','ì','í','î','ï',
|
||||
'Ð','ð',
|
||||
'Ñ','ñ',
|
||||
'Ò','Ó','Ô','Õ','Ö','Ø','ò','ó','ô','õ','ö','ø',
|
||||
'Ù','Ú','Û','Ü','ù','ú','û','ü',
|
||||
'Ý','ý','ÿ',
|
||||
'Š','š','Ž','ž','Þ','þ','Œ','œ','ß',
|
||||
];
|
||||
$ascii = [
|
||||
'A','A','A','A','A','A','AE','a','a','a','a','a','a','ae',
|
||||
'C','c',
|
||||
'E','E','E','E','e','e','e','e',
|
||||
'I','I','I','I','i','i','i','i',
|
||||
'D','d',
|
||||
'N','n',
|
||||
'O','O','O','O','O','O','o','o','o','o','o','o',
|
||||
'U','U','U','U','u','u','u','u',
|
||||
'Y','y','y',
|
||||
'S','s','Z','z','TH','th','OE','oe','ss',
|
||||
];
|
||||
$map = array_combine($utf8, $ascii);
|
||||
}
|
||||
|
||||
return strtolower(strtr($str, $map));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class ShareLink
|
||||
* @param string|null $expiresAt ISO-8601 expiration date, null = never expires
|
||||
* @return array|null The created link row with _plain_password attached
|
||||
*/
|
||||
public function create(int $createdBy, ?string $expiresAt = null, ?string $objetRestriction = null, ?string $name = null): ?array
|
||||
public function create(int $createdBy, ?string $expiresAt = null, ?string $objetRestriction = null, ?string $name = null, ?int $lockedYear = null): ?array
|
||||
{
|
||||
$slug = self::generateSlug();
|
||||
$plainPassword = self::generatePassword();
|
||||
@@ -74,11 +74,16 @@ class ShareLink
|
||||
$objetRestriction = 'tfe';
|
||||
}
|
||||
|
||||
// Validate locked_year: must be a plausible academic year (2000..current+3)
|
||||
if ($lockedYear !== null && ($lockedYear < 2000 || $lockedYear > ((int)date('Y') + 3))) {
|
||||
$lockedYear = null;
|
||||
}
|
||||
|
||||
$stmt = $this->db->getConnection()->prepare(
|
||||
'INSERT INTO share_links (slug, name, objet_restriction, password_hash, encrypted_password, is_active, created_by, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?)'
|
||||
'INSERT INTO share_links (slug, name, objet_restriction, password_hash, encrypted_password, is_active, created_by, expires_at, locked_year)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$slug, $name, $objetRestriction, $passwordHash, Crypto::encrypt($plainPassword), $createdBy, $expiresAt]);
|
||||
$stmt->execute([$slug, $name, $objetRestriction, $passwordHash, Crypto::encrypt($plainPassword), $createdBy, $expiresAt, $lockedYear]);
|
||||
|
||||
$link = $this->findBySlug($slug);
|
||||
if ($link) {
|
||||
@@ -235,7 +240,15 @@ class ShareLink
|
||||
/**
|
||||
* Update a share link (name, expiration).
|
||||
*/
|
||||
public function update(int $id, ?string $name = null, ?string $expiresAt = null): void
|
||||
/**
|
||||
* Update a share link's name, expiration, and/or locked year.
|
||||
*
|
||||
* $lockedYear:
|
||||
* - null → leave unchanged (not present in POST)
|
||||
* - "" → clear (set to NULL in DB)
|
||||
* - non-empty string → parse as int, validate, and set
|
||||
*/
|
||||
public function update(int $id, ?string $name = null, ?string $expiresAt = null, mixed $lockedYear = null): void
|
||||
{
|
||||
$pdo = $this->db->getConnection();
|
||||
$fields = [];
|
||||
@@ -250,6 +263,17 @@ class ShareLink
|
||||
$fields[] = 'expires_at = ?';
|
||||
$params[] = $expiresAtVal;
|
||||
}
|
||||
if ($lockedYear !== null) {
|
||||
if ($lockedYear === '' || $lockedYear === false) {
|
||||
$fields[] = 'locked_year = NULL';
|
||||
} else {
|
||||
$year = filter_var($lockedYear, FILTER_VALIDATE_INT);
|
||||
if ($year !== false && $year >= 2000 && $year <= ((int)date('Y') + 3)) {
|
||||
$fields[] = 'locked_year = ?';
|
||||
$params[] = $year;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($fields)) {
|
||||
$params[] = $id;
|
||||
|
||||
Reference in New Issue
Block a user