From 3871f9fd1661688bed4f7dd23912be0ebf50973c Mon Sep 17 00:00:00 2001 From: Ryan Date: Mon, 16 Mar 2026 23:32:33 -0400 Subject: [PATCH] release(v3.10.0): resumable upload hardening and ONLYOFFICE callback authorization tightening - upload(resumable): stop deriving temporary chunk directories from raw client identifiers and switch to hashed internal temp-folder names - upload(cleanup): require authenticated upload access for resumable temp-folder removal and keep recursive cleanup bounded to the intended staging root - upload(compat): preserve normal resumable upload flow while making temp-path resolution consistent across probe, write, and cleanup paths - onlyoffice(callback): issue save callbacks only for editable sessions, bind callbacks to the authorized actor/file, and stop trusting body-supplied editor identities - onlyoffice(origin): restrict callback fetch URLs to the configured Document Server origin while keeping callback JWT validation compatible with existing deployments --- CHANGELOG.md | 35 +++ src/FileRise/Domain/UploadModel.php | 91 +++++- .../Http/Controllers/OnlyOfficeController.php | 268 ++++++++++++++---- .../Http/Controllers/UploadController.php | 29 +- 4 files changed, 349 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c27851..19c34f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## Changes 03/16/2026 (v3.10.0) + +`release(v3.10.0): resumable upload hardening and ONLYOFFICE callback authorization tightening` + +**Commit message** + +```text +release(v3.10.0): resumable upload hardening and ONLYOFFICE callback authorization tightening + +- upload(resumable): stop deriving temporary chunk directories from raw client identifiers and switch to hashed internal temp-folder names +- upload(cleanup): require authenticated upload access for resumable temp-folder removal and keep recursive cleanup bounded to the intended staging root +- upload(compat): preserve normal resumable upload flow while making temp-path resolution consistent across probe, write, and cleanup paths +- onlyoffice(callback): issue save callbacks only for editable sessions, bind callbacks to the authorized actor/file, and stop trusting body-supplied editor identities +- onlyoffice(origin): restrict callback fetch URLs to the configured Document Server origin while keeping callback JWT validation compatible with existing deployments +``` + +**Changed** + +- **Resumable temp-folder naming** + - Resumable upload staging now maps client identifiers to hashed internal temp-folder names instead of using raw identifier values directly in filesystem paths. + - The same temp-folder mapping is now used consistently for chunk probe, chunk staging, and resumable cleanup operations. + +**Fixed** + +- **Resumable cleanup guardrails** + - Tightened resumable temp-folder cleanup so recursive deletion stays bounded to the expected staging area. + - The resumable cleanup endpoint now requires an authenticated session with upload permission for the target folder before removing chunk temp data. + +- **ONLYOFFICE save authorization** + - View-only ONLYOFFICE sessions no longer receive save-capable callback URLs. + - ONLYOFFICE save callbacks are now bound to the authorized actor and file, and no longer trust body-supplied editor identities. + - Save fetches are restricted to the configured ONLYOFFICE Document Server origin before FileRise downloads updated content and writes it back to disk. + +--- + ## Changes 03/15/2026 (v3.9.4) `release(v3.9.4): preserve legacy compatibility path while keeping post-rotation key-file preference` diff --git a/src/FileRise/Domain/UploadModel.php b/src/FileRise/Domain/UploadModel.php index f8f52a2..5401f2e 100644 --- a/src/FileRise/Domain/UploadModel.php +++ b/src/FileRise/Domain/UploadModel.php @@ -119,6 +119,53 @@ class UploadModel return $folderSan; } + private static function normalizeResumableIdentifier($identifier): ?string + { + $identifier = trim((string)$identifier); + if ($identifier === '') { + return null; + } + if (strlen($identifier) > 512) { + return null; + } + if (preg_match('/[\x00-\x1F\x7F]/', $identifier)) { + return null; + } + return $identifier; + } + + private static function resumableTempFolderName($identifier): ?string + { + $normalized = self::normalizeResumableIdentifier($identifier); + if ($normalized === null) { + return null; + } + return 'resumable_' . hash('sha256', $normalized); + } + + private static function resumableTempDir(string $baseUploadDir, $identifier): ?string + { + $folderName = self::resumableTempFolderName($identifier); + if ($folderName === null) { + return null; + } + return rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR . $folderName . DIRECTORY_SEPARATOR; + } + + private static function isPathWithinRoot(string $path, string $root): bool + { + $rootReal = realpath($root); + $pathReal = realpath($path); + if ($rootReal === false || $pathReal === false) { + return false; + } + + $rootNorm = rtrim(str_replace('\\', '/', $rootReal), '/') . '/'; + $pathNorm = rtrim(str_replace('\\', '/', $pathReal), '/') . '/'; + + return strpos($pathNorm, $rootNorm) === 0; + } + private static function loadResumableIndex(): array { $path = self::resumableIndexPath(); @@ -700,16 +747,23 @@ class UploadModel && isset($post['resumableChunkNumber'], $post['resumableIdentifier']) ) { $chunkNumber = (int)($post['resumableChunkNumber'] ?? 0); - $resumableIdentifier = $post['resumableIdentifier'] ?? ''; + $resumableIdentifier = self::normalizeResumableIdentifier($post['resumableIdentifier'] ?? ''); $folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root')); + if ($chunkNumber < 1 || $resumableIdentifier === null) { + return ['error' => 'Invalid resumable upload request']; + } + $baseUploadDir = self::stagingRoot($isLocal); if ($folderSan !== '') { $baseUploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR; } - $tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR; + $tempDir = self::resumableTempDir($baseUploadDir, $resumableIdentifier); + if ($tempDir === null) { + return ['error' => 'Invalid resumable upload request']; + } $chunkFile = $tempDir . $chunkNumber; return ['status' => file_exists($chunkFile) ? 'found' : 'not found']; @@ -719,10 +773,14 @@ class UploadModel if (isset($post['resumableChunkNumber'])) { $chunkNumber = (int)$post['resumableChunkNumber']; $totalChunks = (int)$post['resumableTotalChunks']; - $resumableIdentifier = $post['resumableIdentifier'] ?? ''; + $resumableIdentifier = self::normalizeResumableIdentifier($post['resumableIdentifier'] ?? ''); $resumableFilename = urldecode(basename($post['resumableFilename'] ?? '')); $relativeSubDir = ''; + if ($chunkNumber < 1 || $totalChunks < 1 || $chunkNumber > $totalChunks || $resumableIdentifier === null) { + return ['error' => 'Invalid resumable upload request']; + } + if (!empty($post['resumableRelativePath'])) { [$subDir, $relFile] = self::parseRelativePath((string)$post['resumableRelativePath']); if ($subDir === null) { @@ -757,7 +815,10 @@ class UploadModel return ['error' => 'Failed to create upload directory']; } - $tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR; + $tempDir = self::resumableTempDir($baseUploadDir, $resumableIdentifier); + if ($tempDir === null) { + return ['error' => 'Invalid resumable upload request']; + } if (!self::ensureDir($tempDir, $createdDirs)) { return ['error' => 'Failed to create temporary chunk directory']; } @@ -780,8 +841,10 @@ class UploadModel } } - $cleanupChunk = function () use ($tempDir, $createdDirs, $stagingRoot, $isLocal): void { - self::rrmdir($tempDir); + $cleanupChunk = function () use ($tempDir, $baseUploadDir, $createdDirs, $stagingRoot, $isLocal): void { + if (self::isPathWithinRoot($tempDir, $baseUploadDir)) { + self::rrmdir($tempDir); + } if (!$isLocal) { self::cleanupCreatedDirs($createdDirs, $stagingRoot); } @@ -1279,10 +1342,13 @@ class UploadModel public static function removeChunks(string $folder): array { $folder = urldecode($folder); - // The folder name should exactly match the "resumable_" pattern. - $regex = "/^resumable_" . PATTERN_FOLDER_NAME . "$/u"; - if (!preg_match($regex, $folder)) { - return ['error' => 'Invalid folder name']; + if (strpos($folder, 'resumable_') === 0) { + $folder = substr($folder, strlen('resumable_')); + } + + $tempFolderName = self::resumableTempFolderName($folder); + if ($tempFolderName === null) { + return ['error' => 'Invalid resumable identifier']; } $isLocal = self::isLocalSourceType(); @@ -1292,9 +1358,12 @@ class UploadModel $baseDir = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $targetFolder) . DIRECTORY_SEPARATOR; } - $tempDir = $baseDir . $folder; + $tempDir = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR . $tempFolderName; $hadDir = is_dir($tempDir); if ($hadDir) { + if (!self::isPathWithinRoot($tempDir, $baseDir)) { + return ['error' => 'Invalid temporary folder']; + } self::rrmdir($tempDir); } if (is_dir($tempDir)) { diff --git a/src/FileRise/Http/Controllers/OnlyOfficeController.php b/src/FileRise/Http/Controllers/OnlyOfficeController.php index 4d85cf2..fd30d50 100644 --- a/src/FileRise/Http/Controllers/OnlyOfficeController.php +++ b/src/FileRise/Http/Controllers/OnlyOfficeController.php @@ -8,6 +8,7 @@ use FileRise\Support\ACL; use FileRise\Storage\SourceContext; use FileRise\Storage\StorageRegistry; use FileRise\Domain\AdminModel; +use FileRise\Domain\AuthModel; use FileRise\Domain\FileModel; // src/controllers/OnlyOfficeController.php @@ -162,6 +163,143 @@ class OnlyOfficeController return rtrim(strtr(base64_encode($s), '+/', '-_'), '='); } + private function decodeJwtPayload(string $jwt, string $secret): ?array + { + $parts = explode('.', $jwt, 3); + if (count($parts) !== 3) { + return null; + } + + [$b64Header, $b64Payload, $b64Sig] = $parts; + $headerJson = $this->b64uDec($b64Header); + $payloadJson = $this->b64uDec($b64Payload); + $sig = $this->b64uDec($b64Sig); + if ($headerJson === false || $payloadJson === false || $sig === false) { + return null; + } + + $header = json_decode($headerJson, true); + if (!is_array($header) || strtoupper((string)($header['alg'] ?? '')) !== 'HS256') { + return null; + } + + $calc = hash_hmac('sha256', $b64Header . '.' . $b64Payload, $secret, true); + if (!hash_equals($calc, $sig)) { + return null; + } + + $payload = json_decode($payloadJson, true); + return is_array($payload) ? $payload : null; + } + + private function createSignedPayloadToken(array $payload, string $secret): string + { + $data = json_encode($payload, JSON_UNESCAPED_SLASHES); + if (!is_string($data) || $data === '') { + return ''; + } + $sig = hash_hmac('sha256', $data, $secret, true); + return $this->b64uEnc($data) . '.' . $this->b64uEnc($sig); + } + + private function decodeSignedPayloadToken(string $token, string $secret): ?array + { + if ($token === '' || strpos($token, '.') === false) { + return null; + } + [$b64Data, $b64Sig] = explode('.', $token, 2); + $data = $this->b64uDec($b64Data); + $sig = $this->b64uDec($b64Sig); + if ($data === false || $sig === false) { + return null; + } + $calc = hash_hmac('sha256', $data, $secret, true); + if (!hash_equals($calc, $sig)) { + return null; + } + $payload = json_decode($data, true); + return is_array($payload) ? $payload : null; + } + + private function effectiveAclPermsForUser(string $username): array + { + $perms = loadUserPermissions($username) ?: []; + if (AuthModel::getUserRole($username) === '1') { + $perms['admin'] = true; + } + return is_array($perms) ? $perms : []; + } + + private function callbackJwtTokenFromRequest(array $rawBody): string + { + $bodyToken = (string)($rawBody['token'] ?? ''); + if ($bodyToken !== '') { + return trim($bodyToken); + } + + $headers = array_change_key_case(getallheaders() ?: [], CASE_LOWER); + $auth = trim((string)($headers['authorization'] ?? '')); + if ($auth !== '' && preg_match('/^Bearer\s+(.+)$/i', $auth, $m)) { + return trim((string)$m[1]); + } + + return ''; + } + + private function trustedCallbackBody(array $rawBody, string $secret): ?array + { + $jwt = $this->callbackJwtTokenFromRequest($rawBody); + if ($jwt === '') { + return $rawBody; + } + + $payload = $this->decodeJwtPayload($jwt, $secret); + if (!is_array($payload)) { + return null; + } + + if (isset($payload['payload']) && is_array($payload['payload'])) { + return $payload['payload']; + } + + return $payload; + } + + private function normalizeOrigin(string $url): ?array + { + $url = trim($url); + if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) { + return null; + } + + $scheme = strtolower((string)(parse_url($url, PHP_URL_SCHEME) ?: '')); + $host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?: '')); + if (($scheme !== 'http' && $scheme !== 'https') || $host === '') { + return null; + } + + $port = (int)(parse_url($url, PHP_URL_PORT) ?: ($scheme === 'https' ? 443 : 80)); + + return [ + 'scheme' => $scheme, + 'host' => $host, + 'port' => $port, + ]; + } + + private function isAllowedOnlyOfficeUrl(string $url): bool + { + $target = $this->normalizeOrigin($url); + $docs = $this->normalizeOrigin($this->effectiveDocsOrigin()); + if ($target === null || $docs === null) { + return false; + } + + return $target['scheme'] === $docs['scheme'] + && $target['host'] === $docs['host'] + && $target['port'] === $docs['port']; + } + private function normalizeSourceId($id): string { $id = trim((string)$id); @@ -316,7 +454,7 @@ class OnlyOfficeController @session_start(); $user = $_SESSION['username'] ?? 'anonymous'; - $perms = []; + $perms = $this->effectiveAclPermsForUser((string)$user); $isAdmin = \ACL::isAdmin($perms); $enabled = $this->effectiveEnabled(); @@ -426,21 +564,31 @@ class OnlyOfficeController $tok = $this->b64uEnc($data) . '.' . $this->b64uEnc($sig); $fileUrl = $fileOriginForDocs . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok); - $cbExp = time() + 10 * 60; - $cbSigBase = ($sourceId !== '') - ? ($folder . '|' . $file . '|' . $sourceId . '|' . $cbExp) - : ($folder . '|' . $file . '|' . $cbExp); - $cbSig = hash_hmac('sha256', $cbSigBase, $secret); - $callbackUrl = $fileOriginForDocs . '/api/onlyoffice/callback.php' - . '?folder=' . rawurlencode($folder) - . '&file=' . rawurlencode($file) - . '&exp=' . $cbExp - . '&sig=' . $cbSig; - if ($sourceId !== '') { - $callbackUrl .= '&sourceId=' . rawurlencode($sourceId); + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx'); + $canSave = $canEdit && !in_array($ext, self::OO_NEVER_EDIT, true); + $callbackUrl = null; + if ($canSave) { + $cbExp = time() + 10 * 60; + $cbPayload = [ + 'f' => $folder, + 'n' => $file, + 'u' => $user, + 'edit' => true, + 'op' => 'onlyoffice_save', + 'exp' => $cbExp, + ]; + if ($sourceId !== '') { + $cbPayload['sid'] = $sourceId; + } + $cbTok = $this->createSignedPayloadToken($cbPayload, $secret); + if ($cbTok === '') { + http_response_code(500); + echo '{"error":"Failed to generate ONLYOFFICE callback token"}'; + return; + } + $callbackUrl = $fileOriginForDocs . '/api/onlyoffice/callback.php?tok=' . rawurlencode($cbTok); } - $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx'); $docType = in_array($ext, ['xls','xlsx','ods','csv'], true) ? 'cell' : (in_array($ext, ['ppt','pptx','odp'], true) ? 'slide' : 'word'); $filePath = $downloadInfo['filePath'] ?? ''; @@ -467,18 +615,21 @@ class OnlyOfficeController 'permissions' => [ 'download' => true, 'print' => true, - 'edit' => $canEdit && !in_array($ext, self::OO_NEVER_EDIT, true), + 'edit' => $canSave, ], ], 'documentType' => $docType, 'editorConfig' => [ - 'callbackUrl' => $callbackUrl, 'user' => ['id' => $user, 'name' => $user], 'lang' => 'en', ], 'type' => 'desktop', ]; + if ($callbackUrl !== null) { + $cfgOut['editorConfig']['callbackUrl'] = $callbackUrl; + } + // JWT sign cfg $h = $this->b64uEnc(json_encode(['alg' => 'HS256','typ' => 'JWT'])); $p = $this->b64uEnc(json_encode($cfgOut, JSON_UNESCAPED_SLASHES)); @@ -510,51 +661,33 @@ class OnlyOfficeController return; } - $folderRaw = (string)($_GET['folder'] ?? 'root'); - $fileRaw = (string)($_GET['file'] ?? ''); - $exp = (int)($_GET['exp'] ?? 0); - $sig = (string)($_GET['sig'] ?? ''); - $sourceIdRaw = (string)($_GET['sourceId'] ?? ($_GET['sid'] ?? '')); - $sourceId = $this->normalizeSourceId($sourceIdRaw); - if ($sourceIdRaw !== '' && $sourceId === '') { - $this->ooLog('error', "invalid source id in callback"); + $callbackToken = trim((string)($_GET['tok'] ?? '')); + $callbackPayload = $this->decodeSignedPayloadToken($callbackToken, $secret); + if (!is_array($callbackPayload)) { + $this->ooLog('error', 'invalid callback token'); echo '{"error":6}'; return; } - $calcBase = ($sourceId !== '') - ? "$folderRaw|$fileRaw|$sourceId|$exp" - : "$folderRaw|$fileRaw|$exp"; - $calc = hash_hmac('sha256', $calcBase, $secret); - - // Debug-only preflight (no secrets; show short sigs) - if ($this->ooDebug()) { - $this->ooLog('debug', sprintf( - "PRE f='%s' n='%s' sid='%s' exp=%d sig[8]=%s calc[8]=%s", - $folderRaw, - $fileRaw, - $sourceId, - $exp, - substr($sig, 0, 8), - substr($calc, 0, 8) - )); - } - $folder = \ACL::normalizeFolder($folderRaw); - $file = basename($fileRaw); - if (!$exp || time() > $exp) { - $this->ooLog('error', "expired exp for $folder/$file"); + $folder = \ACL::normalizeFolder((string)($callbackPayload['f'] ?? 'root')); + $file = basename((string)($callbackPayload['n'] ?? '')); + $actor = (string)($callbackPayload['u'] ?? ''); + $allowEdit = !empty($callbackPayload['edit']); + $op = (string)($callbackPayload['op'] ?? ''); + $exp = (int)($callbackPayload['exp'] ?? 0); + $sourceIdRaw = (string)($callbackPayload['sid'] ?? ($callbackPayload['sourceId'] ?? '')); + $sourceId = ''; + + if ($file === '' || $actor === '' || !$allowEdit || $op !== 'onlyoffice_save' || !$exp || time() > $exp) { + $this->ooLog('error', "expired or invalid callback token for $folder/$file"); echo '{"error":6}'; return; } - if (!hash_equals($calc, $sig)) { - $this->ooLog('error', "sig mismatch for $folder/$file"); - echo '{"error":6}'; - return; - } - if ($sourceId !== '') { - [$sourceId, $sourceErr] = $this->resolveSourceId($sourceId, true); + + if ($sourceIdRaw !== '') { + [$sourceId, $sourceErr] = $this->resolveSourceId($sourceIdRaw, true); if ($sourceErr !== null) { - $this->ooLog('error', "invalid source for callback: $sourceId"); + $this->ooLog('error', "invalid source for callback: $sourceIdRaw"); echo '{"error":6}'; return; } @@ -565,17 +698,25 @@ class OnlyOfficeController $this->ooLog('debug', 'BODY len=' . strlen($raw)); } - $body = json_decode($raw, true) ?: []; - $status = (int)($body['status'] ?? 0); - $actor = (string)($body['actions'][0]['userid'] ?? ''); + $rawBody = json_decode($raw, true); + $rawBody = is_array($rawBody) ? $rawBody : []; + $jwt = $this->callbackJwtTokenFromRequest($rawBody); + $body = $this->trustedCallbackBody($rawBody, $secret); + if (!is_array($body)) { + $this->ooLog('error', "missing or invalid callback JWT for $folder/$file"); + echo '{"error":6}'; + return; + } + if ($jwt === '') { + $this->ooLog('warn', "callback JWT missing; falling back to signed callback token only for $folder/$file"); + } - $actorIsAdmin = (defined('DEFAULT_ADMIN_USER') && $actor !== '' && strcasecmp($actor, (string)DEFAULT_ADMIN_USER) === 0) - || (strcasecmp($actor, 'admin') === 0); - $perms = $actorIsAdmin ? ['admin' => true] : []; + $status = (int)($body['status'] ?? 0); + $perms = $this->effectiveAclPermsForUser($actor); // Save-on statuses: 2/6/7 if (in_array($status, [2,6,7], true)) { - if (!$actor || !\ACL::canEdit($actor, $perms, $folder)) { + if (!\ACL::canEdit($actor, $perms, $folder)) { $this->ooLog('error', "ACL deny edit: actor='$actor' folder='$folder'"); echo '{"error":6}'; return; @@ -586,6 +727,11 @@ class OnlyOfficeController echo '{"error":6}'; return; } + if (!$this->isAllowedOnlyOfficeUrl($saveUrl)) { + $this->ooLog('error', "disallowed save url for $folder/$file"); + echo '{"error":6}'; + return; + } // fetch saved file $data = null; @@ -595,7 +741,7 @@ class OnlyOfficeController $ch = curl_init($saveUrl); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, + CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 45, CURLOPT_HTTPHEADER => ['Accept: */*','User-Agent: FileRise-ONLYOFFICE-Callback'], diff --git a/src/FileRise/Http/Controllers/UploadController.php b/src/FileRise/Http/Controllers/UploadController.php index fcb1e17..d43bb26 100644 --- a/src/FileRise/Http/Controllers/UploadController.php +++ b/src/FileRise/Http/Controllers/UploadController.php @@ -240,6 +240,22 @@ class UploadController return; } + 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); + + if (!$isAdmin && !empty($userPerms['disableUpload'])) { + http_response_code(403); + echo json_encode(['error' => 'Upload disabled for this user.']); + return; + } + $sourceId = trim((string)($_POST['sourceId'] ?? '')); if ($sourceId !== '' && class_exists('SourceContext') && SourceContext::sourcesEnabled()) { if (!preg_match('/^[A-Za-z0-9_-]{1,64}$/', $sourceId)) { @@ -262,14 +278,23 @@ class UploadController return; } + $targetFolderRaw = isset($_POST['targetFolder']) ? (string)$_POST['targetFolder'] : 'root'; + $targetFolder = ACL::normalizeFolder(rawurldecode($targetFolderRaw)); + if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) { + http_response_code(403); + echo json_encode([ + 'error' => 'Forbidden: no write access to folder "' . $targetFolder . '".', + ]); + 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)); + $folder = rawurldecode((string)$_POST['folder']); echo json_encode(UploadModel::removeChunks($folder)); }