Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 131 additions & 5 deletions backend/app/Controllers/User/WebSpaces/WebSpaceFilesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use App\Helpers\WebSpaceFileShare;
use App\WebSpaceSubuserPermissions;
use App\Helpers\FeatherQuilldClient;
use App\Helpers\WebSpacePathValidator;
use App\Helpers\WebSpacePluginEvents;
use App\Helpers\CheckWebSpacePermission;
use App\Plugins\Events\Events\WebSpaceEvent;
Expand Down Expand Up @@ -90,6 +91,9 @@ public function contents(Request $request, string $uuidShort): Response
if ($file === '') {
return ApiResponse::error('Missing file query parameter', 'MISSING_FILE', 400);
}
if (($rejected = WebSpacePathValidator::reject($file, 'file')) !== null) {
return $rejected;
}

$daemon = FeatherQuilldClient::getWebSpaceFileContents($resolved['webNode'], $resolved['uuid'], $file);

Expand All @@ -113,6 +117,9 @@ public function write(Request $request, string $uuidShort): Response
if ($file === '') {
return ApiResponse::error('file is required', 'MISSING_FILE', 400);
}
if (($rejected = WebSpacePathValidator::reject($file, 'file')) !== null) {
return $rejected;
}

$contents = array_key_exists('contents', $content) ? (string) $content['contents'] : '';
$daemon = FeatherQuilldClient::writeWebSpaceFile($resolved['webNode'], $resolved['uuid'], $file, $contents);
Expand All @@ -137,6 +144,9 @@ public function createDirectory(Request $request, string $uuidShort): Response
if ($name === '') {
return ApiResponse::error('name is required', 'MISSING_NAME', 400);
}
if (($rejected = WebSpacePathValidator::reject($name, 'name')) !== null) {
return $rejected;
}

$daemon = FeatherQuilldClient::createWebSpaceDirectory($resolved['webNode'], $resolved['uuid'], $name);

Expand Down Expand Up @@ -173,6 +183,12 @@ public function rename(Request $request, string $uuidShort): Response
if ($from === '' || $to === '') {
return ApiResponse::error('from and to are required', 'MISSING_PATHS', 400);
}
if (($rejected = WebSpacePathValidator::reject($from, 'from')) !== null) {
return $rejected;
}
if (($rejected = WebSpacePathValidator::reject($to, 'to')) !== null) {
return $rejected;
}

$daemon = FeatherQuilldClient::renameWebSpaceFile($resolved['webNode'], $resolved['uuid'], $from, $to);

Expand Down Expand Up @@ -200,6 +216,12 @@ public function copy(Request $request, string $uuidShort): Response
if ($from === '') {
return ApiResponse::error('from is required', 'MISSING_PATH', 400);
}
if (($rejected = WebSpacePathValidator::reject($from, 'from')) !== null) {
return $rejected;
}
if ($to !== '' && ($rejected = WebSpacePathValidator::reject($to, 'to')) !== null) {
return $rejected;
}

$daemon = FeatherQuilldClient::copyWebSpaceFile(
$resolved['webNode'],
Expand Down Expand Up @@ -236,8 +258,14 @@ public function copyMany(Request $request, string $uuidShort): Response
if ($paths === []) {
return ApiResponse::error('files must be a non-empty array', 'MISSING_FILES', 400);
}
if (($invalid = WebSpacePathValidator::firstInvalid($paths)) !== null) {
return ApiResponse::error("Invalid path in files: {$invalid}", 'INVALID_PATH', 400);
}

$destination = isset($content['destination']) ? trim((string) $content['destination']) : '';
if ($destination !== '' && ($rejected = WebSpacePathValidator::reject($destination, 'destination')) !== null) {
return $rejected;
}

$daemon = FeatherQuilldClient::copyManyWebSpaceFiles(
$resolved['webNode'],
Expand Down Expand Up @@ -270,6 +298,12 @@ public function createSymlink(Request $request, string $uuidShort): Response
if ($link === '' || $target === '') {
return ApiResponse::error('link and target are required', 'MISSING_PATH', 400);
}
if (($rejected = WebSpacePathValidator::reject($link, 'link')) !== null) {
return $rejected;
}
if (($rejected = WebSpacePathValidator::reject($target, 'target')) !== null) {
return $rejected;
}

$daemon = FeatherQuilldClient::createWebSpaceSymlink(
$resolved['webNode'],
Expand Down Expand Up @@ -314,6 +348,9 @@ public function fingerprints(Request $request, string $uuidShort): Response
if ($paths === []) {
return ApiResponse::error('files must be a non-empty array', 'MISSING_FILES', 400);
}
if (($invalid = WebSpacePathValidator::firstInvalid($paths)) !== null) {
return ApiResponse::error("Invalid path in files: {$invalid}", 'INVALID_PATH', 400);
}

$algorithm = strtolower(trim((string) $request->query->get('algorithm', 'sha256')));
if (!in_array($algorithm, ['sha1', 'sha256'], true)) {
Expand Down Expand Up @@ -355,6 +392,9 @@ public function delete(Request $request, string $uuidShort): Response
if ($paths === []) {
return ApiResponse::error('files must be a non-empty array', 'MISSING_FILES', 400);
}
if (($invalid = WebSpacePathValidator::firstInvalid($paths)) !== null) {
return ApiResponse::error("Invalid path in files: {$invalid}", 'INVALID_PATH', 400);
}

$permanent = !empty($content['permanent']);
$useTrash = !array_key_exists('use_trash', $content) || !empty($content['use_trash']);
Expand Down Expand Up @@ -400,6 +440,9 @@ public function compress(Request $request, string $uuidShort): Response
}

$root = (string) ($content['root'] ?? $content['directory'] ?? '/');
if (($rejected = WebSpacePathValidator::reject($root, 'root')) !== null) {
return $rejected;
}
$files = $content['files'] ?? null;
if (!is_array($files) || $files === []) {
return ApiResponse::error('files must be a non-empty array', 'MISSING_FILES', 400);
Expand All @@ -409,8 +452,14 @@ public function compress(Request $request, string $uuidShort): Response
if ($paths === []) {
return ApiResponse::error('files must be a non-empty array', 'MISSING_FILES', 400);
}
if (($invalid = WebSpacePathValidator::firstInvalid($paths)) !== null) {
return ApiResponse::error("Invalid path in files: {$invalid}", 'INVALID_PATH', 400);
}

$name = isset($content['name']) ? trim((string) $content['name']) : null;
if ($name !== null && $name !== '' && !WebSpacePathValidator::isSafeFilename($name)) {
return ApiResponse::error("Invalid name: must not contain '/', '\\', or be '.'/'..'", 'INVALID_NAME', 400);
}
$extension = trim((string) ($content['extension'] ?? 'tar.gz'));
if ($extension === '') {
$extension = 'tar.gz';
Expand Down Expand Up @@ -448,6 +497,12 @@ public function decompress(Request $request, string $uuidShort): Response
if ($file === '') {
return ApiResponse::error('file is required', 'MISSING_FILE', 400);
}
if (($rejected = WebSpacePathValidator::reject($file, 'file')) !== null) {
return $rejected;
}
if (($rejected = WebSpacePathValidator::reject($root, 'root')) !== null) {
return $rejected;
}

$daemon = FeatherQuilldClient::decompressWebSpaceFile(
$resolved['webNode'],
Expand Down Expand Up @@ -489,6 +544,9 @@ public function chmod(Request $request, string $uuidShort): Response
if ($path === '' || $mode === '') {
continue;
}
if (WebSpacePathValidator::sanitizeRelativePath($path) === null) {
return ApiResponse::error("Invalid path in files: {$path}", 'INVALID_PATH', 400);
}
$normalized[] = ['file' => $path, 'mode' => $mode];
}

Expand Down Expand Up @@ -548,7 +606,13 @@ public function pull(Request $request, string $uuidShort): Response
}

$directory = (string) ($content['directory'] ?? $content['root'] ?? '/');
if (($rejected = WebSpacePathValidator::reject($directory, 'directory')) !== null) {
return $rejected;
}
$fileName = isset($content['file_name']) ? trim((string) $content['file_name']) : (isset($content['filename']) ? trim((string) $content['filename']) : null);
if ($fileName !== null && $fileName !== '' && !WebSpacePathValidator::isSafeFilename($fileName)) {
return ApiResponse::error("Invalid file_name: must not contain '/', '\\', or be '.'/'..'", 'INVALID_NAME', 400);
}

if (!empty($content['background'])) {
$daemon = FeatherQuilldClient::pullWebSpaceFileBackground(
Expand Down Expand Up @@ -592,6 +656,9 @@ public function download(Request $request, string $uuidShort): Response
if ($file === '') {
return ApiResponse::error('file is required', 'MISSING_FILE', 400);
}
if (($rejected = WebSpacePathValidator::reject($file, 'file')) !== null) {
return $rejected;
}

$daemon = FeatherQuilldClient::downloadWebSpaceFile($resolved['webNode'], $resolved['uuid'], $file);
if (!$daemon['ok']) {
Expand Down Expand Up @@ -623,6 +690,9 @@ public function upload(Request $request, string $uuidShort): Response
}

$directory = (string) $request->query->get('directory', $request->request->get('directory', '/'));
if (($rejected = WebSpacePathValidator::reject($directory, 'directory')) !== null) {
return $rejected;
}
$files = $request->files->all();
if ($files === []) {
return ApiResponse::error('No files uploaded', 'MISSING_FILES', 400);
Expand All @@ -635,11 +705,15 @@ public function upload(Request $request, string $uuidShort): Response
if (!$one) {
continue;
}
$originalName = (string) $one->getClientOriginalName();
if (!WebSpacePathValidator::isSafeFilename($originalName)) {
return ApiResponse::error("Invalid uploaded filename: {$originalName}", 'INVALID_FILENAME', 400);
}
$last = FeatherQuilldClient::uploadWebSpaceFile(
$resolved['webNode'],
$resolved['uuid'],
$directory !== '' ? $directory : '/',
(string) $one->getClientOriginalName(),
$originalName,
(string) $one->getPathname(),
(string) ($one->getMimeType() ?: 'application/octet-stream'),
);
Expand All @@ -652,11 +726,15 @@ public function upload(Request $request, string $uuidShort): Response
if (!$uploaded) {
continue;
}
$originalName = (string) $uploaded->getClientOriginalName();
if (!WebSpacePathValidator::isSafeFilename($originalName)) {
return ApiResponse::error("Invalid uploaded filename: {$originalName}", 'INVALID_FILENAME', 400);
}
$last = FeatherQuilldClient::uploadWebSpaceFile(
$resolved['webNode'],
$resolved['uuid'],
$directory !== '' ? $directory : '/',
(string) $uploaded->getClientOriginalName(),
$originalName,
(string) $uploaded->getPathname(),
(string) ($uploaded->getMimeType() ?: 'application/octet-stream'),
);
Expand Down Expand Up @@ -789,6 +867,9 @@ public function downloadDirectory(Request $request, string $uuidShort): Response
if ($directory === '') {
return ApiResponse::error('path is required', 'MISSING_PATH', 400);
}
if (($rejected = WebSpacePathValidator::reject($directory, 'path')) !== null) {
return $rejected;
}

$daemon = FeatherQuilldClient::downloadWebSpaceDirectory(
$resolved['webNode'],
Expand Down Expand Up @@ -824,6 +905,12 @@ public function listArchive(Request $request, string $uuidShort): Response
if ($file === '') {
return ApiResponse::error('file is required', 'MISSING_FILE', 400);
}
if (($rejected = WebSpacePathValidator::reject($directory, 'directory')) !== null) {
return $rejected;
}
if (($rejected = WebSpacePathValidator::reject($file, 'file')) !== null) {
return $rejected;
}

$daemon = FeatherQuilldClient::listWebSpaceArchive(
$resolved['webNode'],
Expand Down Expand Up @@ -853,12 +940,25 @@ public function extractArchiveSelection(Request $request, string $uuidShort): Re
return ApiResponse::error('entries must be a non-empty array', 'MISSING_ENTRIES', 400);
}

$root = (string) ($content['root'] ?? '/');
$file = (string) ($content['file'] ?? '');
$destination = (string) ($content['destination'] ?? '/');
if (($rejected = WebSpacePathValidator::reject($root, 'root')) !== null) {
return $rejected;
}
if (($rejected = WebSpacePathValidator::reject($file, 'file')) !== null) {
return $rejected;
}
if (($rejected = WebSpacePathValidator::reject($destination, 'destination')) !== null) {
return $rejected;
}

$daemon = FeatherQuilldClient::extractWebSpaceArchiveSelection(
$resolved['webNode'],
$resolved['uuid'],
(string) ($content['root'] ?? '/'),
(string) ($content['file'] ?? ''),
(string) ($content['destination'] ?? '/'),
$root,
$file,
$destination,
array_values(array_map('strval', $entries)),
);

Expand Down Expand Up @@ -914,7 +1014,13 @@ public function getUploadUrl(Request $request, string $uuidShort): Response
}

$directory = (string) $request->query->get('directory', '/');
if (($rejected = WebSpacePathValidator::reject($directory, 'directory')) !== null) {
return $rejected;
}
$fileName = trim((string) $request->query->get('file_name', ''));
if ($fileName !== '' && !WebSpacePathValidator::isSafeFilename($fileName)) {
return ApiResponse::error("Invalid file_name: must not contain '/', '\\', or be '.'/'..'", 'INVALID_NAME', 400);
}
$token = FeatherQuilldClient::createWebSpaceUploadToken(
$resolved['webNode'],
$resolved['uuid'],
Expand Down Expand Up @@ -946,6 +1052,9 @@ public function shareFile(Request $request, string $uuidShort): Response
if ($file === '') {
return ApiResponse::error('file is required', 'MISSING_FILE', 400);
}
if (($rejected = WebSpacePathValidator::reject($file, 'file')) !== null) {
return $rejected;
}

$ttlDays = (int) ($content['ttl_days'] ?? 1);
if (!in_array($ttlDays, [1, 5], true)) {
Expand Down Expand Up @@ -1104,7 +1213,24 @@ private function daemonResponse(array $daemon, string $errorCode, ?array $resolv
$this->logFileActivity($resolved, $activityEvent, $activityMeta);
}

// FeatherQuilld daemon endpoints are inconsistent: some return a bare payload
// (e.g. {"ok": true}, or {"ok": true, "data": {"path": ...}}), others wrap their
// *entire* payload in a "data" key with nothing else alongside it, as a list or object
// (list/search/pull-jobs return {"data": [...]} or {"data": {"entries": [...]}}).
// ApiResponse::success() always wraps whatever we pass it in another "data" key, so
// blindly forwarding the full daemon body for the latter group used to produce
// {"data": {"data": [...]}} — the frontend reads response.data and got the wrapper
// object instead of the actual list, showing "No Files Found" even though the daemon
// returned real entries. Unwrap the daemon's own "data" key only when it is the sole
// top-level key AND itself an array/object, so there is always exactly one "data"
// envelope for those endpoints. Endpoints that pair "data" with sibling keys (e.g.
// "ok") keep both intact, and endpoints whose "data" value is a bare scalar (e.g.
// files/contents returning {"data": "<file text>"}) are left untouched — the frontend
// already expects that shape as response.data.data for the file editor.
$body = is_array($daemon['body']) ? $daemon['body'] : ['data' => $daemon['body']];
if (array_keys($body) === ['data'] && is_array($body['data'])) {
$body = $body['data'];
}

return ApiResponse::success($body, 'OK', 200);
}
Expand Down
Loading