feat: dual upload system — direct file storage + PeerTube API integration

Adds a parallel PeerTube upload system behind a feature flag (disabled by default
until upload quota is granted). When disabled, the existing direct file upload
path works unchanged.

Files:
- src/PeerTubeService.php — credential storage (encrypted), OAuth2 token
  retrieval, multipart upload to /api/v1/videos/upload
- migrations/021_peertube_settings.sql — peertube_settings singleton table
  + peertube_upload_enabled site_setting (default 0)
- admin/actions/settings.php — peertube section handler
- admin/parametres.php / templates/admin/parametres.php — PeerTube UI section
- partage/fichiers-fragment.php — shows file inputs when enabled, TODO notice otherwise
- ThesisCreateController / ThesisEditController — handlePeerTubeUpload()
- tfe.php — PeerTube iframe embed detection
- AdminLogger — logPeerTubeUpdate()
This commit is contained in:
Pontoporeia
2026-05-08 16:48:34 +02:00
parent 11e61226e2
commit 03c5fd217e
12 changed files with 658 additions and 4 deletions

322
app/src/PeerTubeService.php Normal file
View File

@@ -0,0 +1,322 @@
<?php
/**
* PeerTubeService
*
* Handles credential storage and video/audio uploads to a PeerTube instance
* via its REST API. Follows the same patterns as SmtpRelay:
* - Static CRUD on a dedicated settings table (peertube_settings)
* - A feature-flag setting (peertube_upload_enabled) in site_settings
* - An upload() method that POSTs a file to the PeerTube /api/v1/videos/upload
* endpoint and returns the resulting watch URL
*
* PeerTube API reference:
* POST /api/v1/videos/upload — resumable / direct upload
* POST /api/v1/users/token — OAuth2 password grant
*
* The stored access token is refreshed automatically when it expires (401).
* Credentials (password + token) are encrypted at rest via Crypto.php.
*/
class PeerTubeService
{
// -------------------------------------------------------------------------
// DB CRUD
// -------------------------------------------------------------------------
/**
* Return current PeerTube settings from the DB.
*
* @return array{instance_url:string,username:string,password:string,channel_id:int,privacy:int}
*/
public static function getSettings(Database $db): array
{
$stmt = $db->getPDO()->prepare(
'SELECT instance_url, username, password, channel_id, privacy
FROM peertube_settings WHERE id = 1 LIMIT 1'
);
$stmt->execute();
$row = $stmt->fetch();
if ($row) {
require_once __DIR__ . '/Crypto.php';
$row['password'] = Crypto::decrypt($row['password']);
}
return $row ?: [
'instance_url' => '',
'username' => '',
'password' => '',
'channel_id' => 1,
'privacy' => 1, // 1=Public, 2=Unlisted, 3=Private
];
}
/**
* Upsert PeerTube settings.
*/
public static function updateSettings(Database $db, array $data): void
{
$current = self::getSettings($db);
$merged = array_merge($current, $data);
require_once __DIR__ . '/Crypto.php';
// Normalise instance URL: strip trailing slash
$instanceUrl = rtrim(trim($merged['instance_url']), '/');
$channelId = max(1, (int)$merged['channel_id']);
$privacy = in_array((int)$merged['privacy'], [1, 2, 3], true)
? (int)$merged['privacy'] : 1;
$pdo = $db->getPDO();
// Upsert row (id=1 is always the singleton row)
$exists = $pdo->query('SELECT COUNT(*) FROM peertube_settings WHERE id = 1')->fetchColumn();
if ($exists) {
$stmt = $pdo->prepare(
'UPDATE peertube_settings
SET instance_url = :url,
username = :user,
password = :pass,
channel_id = :chan,
privacy = :priv,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1'
);
} else {
$stmt = $pdo->prepare(
'INSERT INTO peertube_settings (id, instance_url, username, password, channel_id, privacy)
VALUES (1, :url, :user, :pass, :chan, :priv)'
);
}
$stmt->execute([
':url' => $instanceUrl,
':user' => trim($merged['username']),
':pass' => Crypto::encrypt($merged['password']),
':chan' => $channelId,
':priv' => $privacy,
]);
}
/**
* Whether PeerTube credentials are fully configured.
*/
public static function isConfigured(Database $db): bool
{
$s = self::getSettings($db);
return $s['instance_url'] !== '' && $s['username'] !== '';
}
/**
* Whether the PeerTube upload feature flag is enabled.
*/
public static function isEnabled(Database $db): bool
{
return $db->getSetting('peertube_upload_enabled', '0') === '1';
}
/**
* Test connectivity: obtain a token without uploading anything.
*
* @return array{ok:bool, error:string}
*/
public static function test(Database $db): array
{
$s = self::getSettings($db);
if ($s['instance_url'] === '') {
return ['ok' => false, 'error' => "URL de l'instance PeerTube non configurée."];
}
try {
self::obtainToken($s);
return ['ok' => true, 'error' => ''];
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
}
// -------------------------------------------------------------------------
// Upload
// -------------------------------------------------------------------------
/**
* Upload a local file to PeerTube.
*
* @param string $filePath Absolute path to the file on disk.
* @param string $title Video/audio title shown on PeerTube.
* @param string $description Optional description.
* @return string The public watch URL of the uploaded video.
* @throws \RuntimeException On any API or network error.
*/
public static function upload(
Database $db,
string $filePath,
string $title,
string $description = ''
): string {
$s = self::getSettings($db);
if ($s['instance_url'] === '') {
throw new \RuntimeException('PeerTube non configuré.');
}
$token = self::obtainToken($s);
$baseUrl = $s['instance_url'];
$uploadUrl = $baseUrl . '/api/v1/videos/upload';
// Build multipart body
$boundary = '----XamxamPT' . bin2hex(random_bytes(8));
$body = '';
$fields = [
'channelId' => (string)$s['channel_id'],
'name' => $title,
'description' => $description,
'privacy' => (string)$s['privacy'],
'waitTranscoding' => 'false',
];
foreach ($fields as $k => $v) {
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"{$k}\"\r\n\r\n";
$body .= $v . "\r\n";
}
// File part
$fileName = basename($filePath);
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->file($filePath);
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"videofile\"; filename=\"{$fileName}\"\r\n";
$body .= "Content-Type: {$mimeType}\r\n\r\n";
$body .= file_get_contents($filePath) . "\r\n";
$body .= "--{$boundary}--\r\n";
$response = self::httpRequest($uploadUrl, 'POST', $body, [
'Authorization: Bearer ' . $token,
'Content-Type: multipart/form-data; boundary=' . $boundary,
'Content-Length: ' . strlen($body),
]);
$json = json_decode($response['body'], true);
if ($response['status'] < 200 || $response['status'] >= 300) {
$msg = $json['error'] ?? $json['detail'] ?? $response['body'];
throw new \RuntimeException('PeerTube upload failed (' . $response['status'] . '): ' . $msg);
}
$shortUuid = $json['video']['shortUUID'] ?? $json['video']['uuid'] ?? null;
if ($shortUuid === null) {
throw new \RuntimeException('PeerTube upload: no video UUID in response.');
}
return rtrim($baseUrl, '/') . '/videos/watch/' . $shortUuid;
}
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
/**
* Obtain an OAuth2 access token via password grant.
*
* @throws \RuntimeException on failure.
*/
private static function obtainToken(array $s): string
{
$tokenUrl = $s['instance_url'] . '/api/v1/users/token';
$body = http_build_query([
'client_id' => self::getClientId($s),
'client_secret' => self::getClientSecret($s),
'grant_type' => 'password',
'response_type' => 'code',
'username' => $s['username'],
'password' => $s['password'],
]);
$response = self::httpRequest($tokenUrl, 'POST', $body, [
'Content-Type: application/x-www-form-urlencoded',
'Content-Length: ' . strlen($body),
]);
$json = json_decode($response['body'], true);
if ($response['status'] !== 200 || empty($json['access_token'])) {
$msg = $json['error_description'] ?? $json['error'] ?? $response['body'];
throw new \RuntimeException('PeerTube auth failed (' . $response['status'] . '): ' . $msg);
}
return $json['access_token'];
}
/**
* Fetch the OAuth2 client_id from the PeerTube instance.
*/
private static function getClientId(array $s): string
{
return self::getOAuthClient($s)['client_id'];
}
private static function getClientSecret(array $s): string
{
return self::getOAuthClient($s)['client_secret'];
}
private static function getOAuthClient(array $s): array
{
$url = $s['instance_url'] . '/api/v1/oauth-clients/local';
$response = self::httpRequest($url, 'GET', '', []);
$json = json_decode($response['body'], true);
if ($response['status'] !== 200 || empty($json['client_id'])) {
throw new \RuntimeException(
'Impossible de récupérer les identifiants OAuth de l\'instance PeerTube (' . $response['status'] . ').'
);
}
return $json;
}
/**
* Minimal cURL HTTP helper.
*
* @return array{status:int, body:string}
*/
private static function httpRequest(
string $url,
string $method,
string $body,
array $headers,
int $timeout = 300
): array {
if (!function_exists('curl_init')) {
throw new \RuntimeException('L\'extension PHP cURL est requise pour l\'intégration PeerTube.');
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => $headers,
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($responseBody === false) {
throw new \RuntimeException('Erreur réseau PeerTube : ' . $error);
}
return ['status' => $status, 'body' => (string)$responseBody];
}
}