refactor: move Restrictions d'accès aux fichiers from contenus.php to acces.php, cleanup section

This commit is contained in:
Pontoporeia
2026-05-11 11:40:50 +02:00
parent d000f9e1d4
commit 1b0451581d
7 changed files with 152 additions and 67 deletions

19
TODO.md
View File

@@ -1,5 +1,24 @@
# TODO
## Move Restrictions d'accès aux fichiers to acces.php
- [x] Remove fieldset from templates/admin/contenus.php
- [x] Add fieldset to templates/admin/acces.php
- [x] Load $siteSettings in admin/acces.php controller
- [x] Update redirect in settings.php for formulaire_restrictions → /admin/acces.php
## Fix PeerTube upload — Google-resumable protocol adherence
- [x] Use Location header from init response (not reconstruct URL from JSON body)
- [x] Switch chunk method from PUT → PATCH (Google-resumable variant)
- [x] Use actual file MIME type in chunk Content-Type (not application/octet-stream)
- [x] Ensure chunk size is multiple of 256 KB
- [x] Add PATCH/HEAD methods to httpRequest()
- [x] Add CURLOPT_HEADERFUNCTION to capture response headers
- [x] Disable CURLOPT_FOLLOWLOCATION to preserve Location header
- [x] Add cancelUpload() helper for Delete-on-error cleanup
- [ ] Test with actual PeerTube instance
## HTMX Toast Feedback for Settings Checkboxes (contenus.php)
- [x] Add `hx-target` response divs to the three fieldsets in contenus.php

View File

@@ -15,8 +15,12 @@ $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https'
$baseUrl = $protocol . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
// ── Demandes d'accès aux fichiers ─────────────────────────────────────────────
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/Controllers/FileAccessController.php';
$db = new Database();
$siteSettings = $db->getAllSettings();
$controller = FileAccessController::create();
$vars = $controller->handle();
extract($vars);

View File

@@ -162,8 +162,10 @@ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// Redirect back to wherever the form came from, defaulting to parametres
$redirect = '/admin/parametres.php';
if (in_array($section, ['formulaire_restrictions', 'formulaire_acces', 'objet_types'], true)) {
if (in_array($section, ['formulaire_acces', 'objet_types'], true)) {
$redirect = '/admin/contenus.php';
} elseif ($section === 'formulaire_restrictions') {
$redirect = '/admin/acces.php';
}
header('Location: ' . $redirect);
exit;

View File

@@ -17,10 +17,11 @@
* instance's /api/v1/oauth-clients/local endpoint and cached in-memory
* per process lifetime.
*
* Upload uses the resumable protocol:
* POST /api/v1/videos/upload-resumable — init
* PUT /api/v1/videos/upload-resumable — send chunk
* DELETE /api/v1/videos/upload-resumable — cancel
* Upload uses the Google-resumable protocol:
* POST /api/v1/videos/upload-resumable — init (→ Location header with upload URL token)
* PATCH <Location URL> — send chunk
* HEAD <Location URL> — resume check
* DELETE <Location URL> — cancel
*/
class PeerTubeService
{
@@ -205,33 +206,31 @@ class PeerTubeService
'Content-Length: ' . strlen($initBody),
]);
$initJson = json_decode($initResponse['body'], true);
if ($initResponse['status'] < 200 || $initResponse['status'] >= 300) {
// PeerTube Google-resumable returns the upload session URL in the Location header.
// The JSON body contains video.id (upload session ID, not final video ID).
$chunkUrl = $initResponse['headers']['location'] ?? $initResponse['headers']['Location'] ?? null;
if (!$chunkUrl) {
$initJson = json_decode($initResponse['body'], true);
$msg = $initJson['error'] ?? $initJson['detail'] ?? $initResponse['body'];
throw new \RuntimeException('PeerTube upload init failed (' . $initResponse['status'] . '): ' . $msg);
throw new \RuntimeException('PeerTube upload init: no Location header (' . $initResponse['status'] . '): ' . $msg);
}
// Small files may complete in one shot
$shortUuid = $initJson['video']['shortUUID'] ?? $initJson['video']['uuid'] ?? null;
if ($shortUuid && !isset($initJson['video']['id'])) {
$watchUrl = rtrim($baseUrl, '/') . '/videos/watch/' . $shortUuid;
return ['uuid' => $shortUuid, 'watchUrl' => $watchUrl];
// Relative Location? Make it absolute.
if (!str_starts_with($chunkUrl, 'http')) {
$chunkUrl = rtrim($baseUrl, '/') . $chunkUrl;
}
$uploadId = $initJson['video']['id'] ?? null;
if (!$uploadId) {
throw new \RuntimeException('PeerTube upload init: no upload session returned.');
}
// ── Step 2: Send chunks ──
$chunkUrl = $baseUrl . '/api/v1/videos/upload-resumable?upload_id=' . urlencode((string)$uploadId);
// ── Step 2: Send chunks via PATCH (Google-resumable variant) ──
$fh = fopen($filePath, 'rb');
if (!$fh) {
throw new \RuntimeException('Cannot open file for resumable upload.');
}
$chunkSize = 4 * 1024 * 1024;
// Chunk size: 1 MB, must be a multiple of 256 KB (262144 bytes).
$chunkSizeBase = 256 * 1024;
$chunkSize = max($chunkSizeBase, min(4 * 1024 * 1024, (int)ceil($fileSize / 100)));
$chunkSize = (int)ceil($chunkSize / $chunkSizeBase) * $chunkSizeBase;
$offset = 0;
$lastResponse = null;
@@ -240,9 +239,9 @@ class PeerTubeService
$chunkLen = strlen($chunk);
$end = $offset + $chunkLen - 1;
$resp = self::httpRequest($chunkUrl, 'PUT', $chunk, [
$resp = self::httpRequest($chunkUrl, 'PATCH', $chunk, [
'Authorization: Bearer ' . $token,
'Content-Type: application/octet-stream',
'Content-Type: ' . $mimeType,
'Content-Range: bytes ' . $offset . '-' . $end . '/' . $fileSize,
'Content-Length: ' . $chunkLen,
], 600);
@@ -256,13 +255,12 @@ class PeerTubeService
break;
}
} elseif ($resp['status'] === 308) {
// Resume Incomplete — chunk accepted, continue
continue;
} else {
fclose($fh);
try {
self::httpRequest($chunkUrl, 'DELETE', '', [
'Authorization: Bearer ' . $token, 'Content-Length: 0',
], 10);
self::cancelUpload($chunkUrl, $token);
} catch (\Throwable $e) { /* ignore */ }
$errJson = json_decode($resp['body'], true);
$msg = $errJson['error'] ?? $errJson['detail'] ?? $resp['body'];
@@ -436,10 +434,25 @@ class PeerTubeService
// HTTP helper
// -------------------------------------------------------------------------
/**
* Cancel a resumable upload session.
*/
private static function cancelUpload(string $chunkUrl, string $token): void
{
self::httpRequest($chunkUrl, 'DELETE', '', [
'Authorization: Bearer ' . $token,
'Content-Length: 0',
], 10);
}
// -------------------------------------------------------------------------
// HTTP helper
// -------------------------------------------------------------------------
/**
* Minimal cURL HTTP helper.
*
* @return array{status:int, body:string}
* @return array{status:int, body:string, headers:array<string,string>}
*/
public static function httpRequest(
string $url,
@@ -457,31 +470,47 @@ class PeerTubeService
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_FOLLOWLOCATION => false, // Must be false to capture Location header
CURLOPT_MAXREDIRS => 3,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_HEADERFUNCTION => function ($ch, $headerLine) use (&$responseHeaders) {
$len = strlen($headerLine);
$parts = explode(':', $headerLine, 2);
if (count($parts) === 2) {
$responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return $len;
},
]);
$responseHeaders = [];
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} elseif ($method === 'PUT') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} elseif ($method === 'PATCH') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
} elseif ($method === 'DELETE') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
} elseif ($method === 'HEAD') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'HEAD');
curl_setopt($ch, CURLOPT_NOBODY, true);
}
$responseBody = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
if ($responseBody === false) {
if ($responseBody === false && $method !== 'HEAD') {
throw new \RuntimeException('Erreur réseau PeerTube : ' . $error);
}
return ['status' => $status, 'body' => (string)$responseBody];
return ['status' => $status, 'body' => (string)$responseBody, 'headers' => $responseHeaders];
}
}

View File

@@ -844,6 +844,32 @@
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: usmyqlwr 6acee66b "cleanup: merge SMTP fields into single fieldset, rename to Emails" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: usmyqlwr 6acee66b "cleanup: merge SMTP fields into single fieldset, rename to Emails" (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: omwsuqoy 6cde5a47 "move Restrictions d'accès aux fichiers from contenus.php to acces.php" (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: omwsuqoy 5886b400 "move Restrictions d'accès aux fichiers from contenus.php to acces.php" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: omwsuqoy 5886b400 "move Restrictions d'accès aux fichiers from contenus.php to acces.php" (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: usxlqwxk 4dda0271 "Cleanup acces fichier section" (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: usxlqwxk 3cd56fd1 "Cleanup acces fichier section" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $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">
@@ -970,7 +996,39 @@
DEMANDES D'ACCÈS AUX FICHIERS
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="acces-fichiers-title">
<h2 id="acces-fichiers-title">Demandes d'accès aux fichiers</h2>
<h2>Fichiers</h2>
<h3 id="acces-fichiers-title">Restrictions d'accès aux fichiers</h3>
<fieldset id="fieldset-restrictions">
<legend>Paramètre global</legend>
<div class="param-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="formulaire_restrictions">
<label class="param-checkbox">
<input type="checkbox" name="restricted_files_enabled" value="1"
<?= ($siteSettings['restricted_files_enabled'] ?? '0') === '1' ? 'checked' : '' ?>
hx-post="/admin/actions/settings.php"
hx-trigger="change"
hx-target="#restrictions-response"
hx-swap="innerHTML"
hx-include="#fieldset-restrictions"
hx-on::before-request="console.log('[restrictions] sending checked=' + this.checked + ' POST keys will include all #fieldset-restrictions inputs')"
hx-on::after-request="console.log('[restrictions] response received')">
<span>
<strong>Activer la restriction d'accès</strong><br>
<small>Pour les TFE de type "Interne", masquer les fichiers et exiger une demande d'accès par email. Les métadonnées et le résumé restent visibles publiquement.</small>
</span>
</label>
</div>
<div id="restrictions-response" aria-live="polite"></div>
</fieldset>
<h3 id="acces-fichiers-title">Demandes d'accès aux fichiers</h3>
<div class="access-req-stats">
<div class="access-req-stat-card">

View File

@@ -85,33 +85,6 @@
<section aria-labelledby="form-settings-title">
<h2 id="form-settings-title">Paramètres du Formulaire</h2>
<fieldset id="fieldset-restrictions">
<legend>Restrictions d'accès aux fichiers</legend>
<div class="param-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="formulaire_restrictions">
<label class="param-checkbox">
<input type="checkbox" name="restricted_files_enabled" value="1"
<?= ($siteSettings['restricted_files_enabled'] ?? '0') === '1' ? 'checked' : '' ?>
hx-post="/admin/actions/settings.php"
hx-trigger="change"
hx-target="#restrictions-response"
hx-swap="innerHTML"
hx-include="#fieldset-restrictions"
hx-on::before-request="console.log('[restrictions] sending checked=' + this.checked + ' POST keys will include all #fieldset-restrictions inputs')"
hx-on::after-request="console.log('[restrictions] response received')">
<span>
<strong>Activer la restriction d'accès</strong><br>
<small>Pour les TFE de type "Interne", masquer les fichiers et exiger une demande d'accès par email. Les métadonnées et le résumé restent visibles publiquement.</small>
</span>
</label>
</div>
<div id="restrictions-response" aria-live="polite"></div>
</fieldset>
<fieldset id="fieldset-acces">
<legend>Degré d'ouverture</legend>
<p>Options de visibilité disponibles dans le formulaire d'ajout de TFE.</p>

View File

@@ -78,7 +78,9 @@
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="smtp">
<div class="param-grid">
<fieldset class="param-grid">
<legend>Paramètres email</legend>
<div>
<label for="smtp_host">Hôte SMTP</label>
<input type="text" id="smtp_host" name="smtp_host"
@@ -146,7 +148,7 @@
placeholder="admin@example.com">
<small>Reçoit les notifications (demandes d'accès, etc.). Si vide, utilise l'adresse d'expédition.</small>
</div>
</div>
</fieldset>
<button type="submit" class="btn btn--primary">Enregistrer</button>
</form>
@@ -182,10 +184,6 @@
Intégration avec une instance PeerTube pour l'hébergement des vidéos et fichiers audio.
Les fichiers sont uploadés via l'API PeerTube et intégrés comme lecteurs embarqués sur la page du TFE.
</p>
<p class="param-note">
⚠ L'activation nécessite un quota d'upload suffisant sur l'instance PeerTube.
Laissez désactivé jusqu'à obtention du quota.
</p>
<div class="param-smtp-status">
<?php if ($peerTubeConfigured): ?>
@@ -213,12 +211,14 @@
</label>
</fieldset>
<fieldset class="param-grid">
<legend>Paramètres Peertube</legend>
<p class="param-note">
L'authentification PeerTube utilise les mêmes identifiants que le
<strong>relay SMTP</strong> configuré ci-dessus.
</p>
<div class="param-grid">
<div>
<label for="peertube_instance_url">URL de l'instance PeerTube</label>
<input type="url" id="peertube_instance_url" name="peertube_instance_url"
@@ -243,7 +243,7 @@
<option value="3" <?= (int)$peerTubeSettings['privacy'] === 3 ? 'selected' : '' ?>>Privée</option>
</select>
</div>
</div>
</fieldset>
<button type="submit" class="btn btn--primary">Enregistrer</button>
<button type="button" class="btn btn--secondary" id="peertube-test-btn"