mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
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:
322
app/src/PeerTubeService.php
Normal file
322
app/src/PeerTubeService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user