release(v1.9.6): hardened resumable uploads, menu/tag UI polish and hidden temp folders (closes #67)

This commit is contained in:
Ryan
2025-11-14 04:59:58 -05:00
committed by GitHub
parent ef47ad2b52
commit 402f590163
11 changed files with 1457 additions and 737 deletions

View File

@@ -5,116 +5,143 @@ require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/UploadModel.php';
class UploadController {
public function handleUpload(): void {
class UploadController
{
public function handleUpload(): void
{
header('Content-Type: application/json');
// ---- 1) CSRF (header or form field) ----
$headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
$received = '';
if (!empty($headersArr['x-csrf-token'])) {
$received = trim($headersArr['x-csrf-token']);
} elseif (!empty($_POST['csrf_token'])) {
$received = trim($_POST['csrf_token']);
} elseif (!empty($_POST['upload_token'])) {
// legacy alias
$received = trim($_POST['upload_token']);
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$requestParams = ($method === 'GET') ? $_GET : $_POST;
// Detect Resumable.js chunk "test" requests (testChunks=true, default GET)
$isResumableTest =
($method === 'GET'
&& isset($requestParams['resumableChunkNumber'])
&& isset($requestParams['resumableIdentifier']));
// ---- 1) CSRF (skip for resumable GET tests Resumable only cares about HTTP status) ----
if (!$isResumableTest) {
$headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
$received = '';
if (!empty($headersArr['x-csrf-token'])) {
$received = trim($headersArr['x-csrf-token']);
} elseif (!empty($requestParams['csrf_token'])) {
$received = trim((string)$requestParams['csrf_token']);
} elseif (!empty($requestParams['upload_token'])) {
// legacy alias
$received = trim((string)$requestParams['upload_token']);
}
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
// Soft-fail so client can retry with refreshed token
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
http_response_code(200);
echo json_encode([
'csrf_expired' => true,
'csrf_token' => $_SESSION['csrf_token'],
]);
return;
}
}
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
// Soft-fail so client can retry with refreshed token
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
http_response_code(200);
echo json_encode([
'csrf_expired' => true,
'csrf_token' => $_SESSION['csrf_token']
]);
return;
}
// ---- 2) Auth + account-level flags ----
if (empty($_SESSION['authenticated'])) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
return;
}
$username = (string)($_SESSION['username'] ?? '');
$userPerms = loadUserPermissions($username) ?: [];
$isAdmin = ACL::isAdmin($userPerms);
// Admins should never be blocked by account-level "disableUpload"
if (!$isAdmin && !empty($userPerms['disableUpload'])) {
http_response_code(403);
echo json_encode(['error' => 'Upload disabled for this user.']);
return;
}
// ---- 3) Folder-level WRITE permission (ACL) ----
// Always require client to send the folder; fall back to GET if needed.
$folderParam = isset($_POST['folder'])
? (string)$_POST['folder']
: (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
// Prefer the unified param array, fall back to GET only if needed.
$folderParam = isset($requestParams['folder'])
? (string)$requestParams['folder']
: (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
// Decode %xx (e.g., "test%20folder") then normalize
$folderParam = rawurldecode($folderParam);
$targetFolder = ACL::normalizeFolder($folderParam);
// Decode %xx (e.g., "test%20folder") then normalize
$folderParam = rawurldecode($folderParam);
$targetFolder = ACL::normalizeFolder($folderParam);
// Admins bypass folder canWrite checks
$username = (string)($_SESSION['username'] ?? '');
$userPerms = loadUserPermissions($username) ?: [];
$isAdmin = ACL::isAdmin($userPerms);
// Admins bypass folder canWrite checks
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
http_response_code(403);
echo json_encode([
'error' => 'Forbidden: no write access to folder "' . $targetFolder . '".',
]);
return;
}
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
return;
// ---- 4) Delegate to model (force the sanitized folder) ----
$requestParams['folder'] = $targetFolder;
// Keep legacy behavior for anything still reading $_POST directly
$_POST['folder'] = $targetFolder;
$result = UploadModel::handleUpload($requestParams, $_FILES);
// ---- 5) Special handling for Resumable.js GET tests ----
// Resumable only inspects HTTP status:
// 200 => chunk exists (skip)
// 404/other => chunk missing (upload)
if ($isResumableTest && isset($result['status'])) {
if ($result['status'] === 'found') {
http_response_code(200);
} else {
http_response_code(202); // 202 Accepted = chunk not found
}
echo json_encode($result);
return;
}
// ---- 6) Normal response handling ----
if (isset($result['error'])) {
http_response_code(400);
echo json_encode($result);
return;
}
if (isset($result['status'])) {
echo json_encode($result);
return;
}
echo json_encode([
'success' => $result['success'] ?? 'File uploaded successfully',
'newFilename' => $result['newFilename'] ?? null,
]);
}
// ---- 4) Delegate to model (force the sanitized folder) ----
$_POST['folder'] = $targetFolder; // in case model reads superglobal
$post = $_POST;
$post['folder'] = $targetFolder;
public function removeChunks(): void
{
header('Content-Type: application/json');
$result = UploadModel::handleUpload($post, $_FILES);
$receivedToken = isset($_POST['csrf_token']) ? trim((string)$_POST['csrf_token']) : '';
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token']);
return;
}
// ---- 5) Response (unchanged) ----
if (isset($result['error'])) {
http_response_code(400);
echo json_encode($result);
return;
if (!isset($_POST['folder'])) {
http_response_code(400);
echo json_encode(['error' => 'No folder specified']);
return;
}
$folderRaw = (string)$_POST['folder'];
$folder = ACL::normalizeFolder(rawurldecode($folderRaw));
echo json_encode(UploadModel::removeChunks($folder));
}
if (isset($result['status'])) {
echo json_encode($result);
return;
}
echo json_encode([
'success' => 'File uploaded successfully',
'newFilename' => $result['newFilename'] ?? null
]);
}
public function removeChunks(): void {
header('Content-Type: application/json');
$receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : '';
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token']);
return;
}
if (!isset($_POST['folder'])) {
http_response_code(400);
echo json_encode(['error' => 'No folder specified']);
return;
}
$folderRaw = (string)$_POST['folder'];
$folder = ACL::normalizeFolder(rawurldecode($folderRaw));
echo json_encode(UploadModel::removeChunks($folder));
}
}