Add WebDAV support with user folderOnly restrictions

This commit is contained in:
Ryan
2025-04-21 10:39:55 -04:00
committed by GitHub
parent 61357af203
commit a48ba09f02
13 changed files with 993 additions and 104 deletions

View File

@@ -450,56 +450,57 @@ class FileController {
header('Content-Type: application/json');
// --- CSRF Protection ---
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = isset($headersArr['x-csrf-token']) ? trim($headersArr['x-csrf-token']) : '';
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = $headersArr['x-csrf-token'] ?? '';
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]);
exit;
}
// Ensure user is authenticated.
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
// --- Authentication Check ---
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit;
}
// Check if the user is allowed to save files (not read-only).
$username = $_SESSION['username'] ?? '';
// --- Readonly check ---
$userPermissions = loadUserPermissions($username);
if ($username && isset($userPermissions['readOnly']) && $userPermissions['readOnly'] === true) {
if ($username && !empty($userPermissions['readOnly'])) {
echo json_encode(["error" => "Read-only users are not allowed to save files."]);
exit;
}
// Get JSON input.
// --- Input parsing ---
$data = json_decode(file_get_contents("php://input"), true);
if (!$data) {
echo json_encode(["error" => "No data received"]);
exit;
}
if (!isset($data["fileName"]) || !isset($data["content"])) {
if (empty($data) || !isset($data["fileName"], $data["content"])) {
http_response_code(400);
echo json_encode(["error" => "Invalid request data", "received" => $data]);
exit;
}
$fileName = basename($data["fileName"]);
// Determine the folder. Default to "root" if not provided.
$folder = isset($data["folder"]) ? trim($data["folder"]) : "root";
$folder = isset($data["folder"]) ? trim($data["folder"]) : "root";
// Validate folder if not root.
// --- Folder validation ---
if (strtolower($folder) !== "root" && !preg_match(REGEX_FOLDER_NAME, $folder)) {
echo json_encode(["error" => "Invalid folder name"]);
exit;
}
$folder = trim($folder, "/\\ ");
// Delegate to the model.
$result = FileModel::saveFile($folder, $fileName, $data["content"]);
// --- Delegate to model, passing the uploader ---
// Make sure FileModel::saveFile signature is:
// saveFile(string $folder, string $fileName, $content, ?string $uploader = null)
$result = FileModel::saveFile(
$folder,
$fileName,
$data["content"],
$username // ← pass the real uploader here
);
echo json_encode($result);
}

View File

@@ -383,88 +383,95 @@ class FileModel {
}
}
/**
* Saves file content to disk and updates folder metadata.
*
* @param string $folder The target folder where the file is to be saved (e.g. "root" or a subfolder).
* @param string $fileName The name of the file.
* @param string $content The file content.
* @return array Returns an associative array with either a "success" key or an "error" key.
*/
public static function saveFile($folder, $fileName, $content) {
// Sanitize and determine the folder name.
$folder = trim($folder) ?: 'root';
$fileName = basename(trim($fileName));
/*
* Save a files contents *and* record its metadata, including who uploaded it.
*
* @param string $folder Folder key (e.g. "root" or "invoices/2025")
* @param string $fileName Basename of the file
* @param resource|string $content File contents (stream or string)
* @param string|null $uploader Username of uploader (if null, falls back to session)
* @return array ["success"=>"…"] or ["error"=>"…"]
*/
public static function saveFile(string $folder, string $fileName, $content, ?string $uploader = null): array {
// Sanitize inputs
$folder = trim($folder) ?: 'root';
$fileName = basename(trim($fileName));
// Validate folder: if not "root", must match REGEX_FOLDER_NAME.
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name"];
// Validate folder name
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name"];
}
// Determine target directory
$baseDir = rtrim(UPLOAD_DIR, '/\\');
$targetDir = strtolower($folder) === 'root'
? $baseDir . DIRECTORY_SEPARATOR
: $baseDir . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR;
// Security check
if (strpos(realpath($targetDir), realpath($baseDir)) !== 0) {
return ["error" => "Invalid folder path"];
}
// Ensure directory exists
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
return ["error" => "Failed to create destination folder"];
}
$filePath = $targetDir . $fileName;
// ——— STREAM TO DISK ———
if (is_resource($content)) {
$out = fopen($filePath, 'wb');
if ($out === false) {
return ["error" => "Unable to open file for writing"];
}
// Determine base upload directory.
$baseDir = rtrim(UPLOAD_DIR, '/\\');
if (strtolower($folder) === 'root' || $folder === "") {
$targetDir = $baseDir . DIRECTORY_SEPARATOR;
} else {
$targetDir = $baseDir . DIRECTORY_SEPARATOR . trim($folder, "/\\ ") . DIRECTORY_SEPARATOR;
}
// (Optional security check to ensure targetDir is within baseDir.)
if (strpos(realpath($targetDir), realpath($baseDir)) !== 0) {
return ["error" => "Invalid folder path"];
}
// Create target directory if it doesn't exist.
if (!is_dir($targetDir)) {
if (!mkdir($targetDir, 0775, true)) {
return ["error" => "Failed to create destination folder"];
}
}
$filePath = $targetDir . $fileName;
// Attempt to save the file.
if (file_put_contents($filePath, $content) === false) {
stream_copy_to_stream($content, $out);
fclose($out);
} else {
if (file_put_contents($filePath, (string)$content) === false) {
return ["error" => "Error saving file"];
}
// Update metadata.
// Build metadata file path for the folder.
$metadataKey = (strtolower($folder) === "root" || $folder === "") ? "root" : $folder;
$metadataFileName = str_replace(['/', '\\', ' '], '-', trim($metadataKey)) . '_metadata.json';
$metadataFilePath = META_DIR . $metadataFileName;
if (file_exists($metadataFilePath)) {
$metadata = json_decode(file_get_contents($metadataFilePath), true);
} else {
$metadata = [];
}
if (!is_array($metadata)) {
$metadata = [];
}
$currentTime = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
// Update metadata for the file. If already exists, update its "modified" timestamp.
if (isset($metadata[$fileName])) {
$metadata[$fileName]['modified'] = $currentTime;
$metadata[$fileName]['uploader'] = $uploader; // optional: update uploader if desired.
} else {
$metadata[$fileName] = [
"uploaded" => $currentTime,
"modified" => $currentTime,
"uploader" => $uploader
];
}
// Write updated metadata.
if (file_put_contents($metadataFilePath, json_encode($metadata, JSON_PRETTY_PRINT)) === false) {
return ["error" => "Failed to update metadata"];
}
return ["success" => "File saved successfully"];
}
// ——— UPDATE METADATA ———
$metadataKey = strtolower($folder) === "root" ? "root" : $folder;
$metadataFileName = str_replace(['/', '\\', ' '], '-', trim($metadataKey)) . '_metadata.json';
$metadataFilePath = META_DIR . $metadataFileName;
// Load existing metadata
$metadata = [];
if (file_exists($metadataFilePath)) {
$existing = @json_decode(file_get_contents($metadataFilePath), true);
if (is_array($existing)) {
$metadata = $existing;
}
}
$currentTime = date(DATE_TIME_FORMAT);
// Use passed-in uploader, or fall back to session
if ($uploader === null) {
$uploader = $_SESSION['username'] ?? "Unknown";
}
if (isset($metadata[$fileName])) {
$metadata[$fileName]['modified'] = $currentTime;
$metadata[$fileName]['uploader'] = $uploader;
} else {
$metadata[$fileName] = [
"uploaded" => $currentTime,
"modified" => $currentTime,
"uploader" => $uploader
];
}
if (file_put_contents($metadataFilePath, json_encode($metadata, JSON_PRETTY_PRINT)) === false) {
return ["error" => "Failed to update metadata"];
}
return ["success" => "File saved successfully"];
}
/**
* Validates and retrieves information needed to download a file.
*

View File

@@ -0,0 +1,16 @@
<?php
// src/webdav/CurrentUser.php
namespace FileRise\WebDAV;
/**
* Singleton holder for the current WebDAV username.
*/
class CurrentUser {
private static string $user = 'Unknown';
public static function set(string $u): void {
self::$user = $u;
}
public static function get(): string {
return self::$user;
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace FileRise\WebDAV;
// Bootstrap constants and models
require_once __DIR__ . '/../../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT
require_once __DIR__ . '/../../vendor/autoload.php'; // SabreDAV
require_once __DIR__ . '/../../src/models/FolderModel.php';
require_once __DIR__ . '/../../src/models/FileModel.php';
require_once __DIR__ . '/FileRiseFile.php';
use Sabre\DAV\ICollection;
use Sabre\DAV\INode;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\Exception\Forbidden;
use FileRise\WebDAV\FileRiseFile;
use FolderModel;
use FileModel;
class FileRiseDirectory implements ICollection, INode {
private string $path;
private string $user;
private bool $folderOnly;
/**
* @param string $path Absolute filesystem path (no trailing slash)
* @param string $user Authenticated username
* @param bool $folderOnly If true, nonadmins only see $path/{user}
*/
public function __construct(string $path, string $user, bool $folderOnly) {
$this->path = rtrim($path, '/\\');
$this->user = $user;
$this->folderOnly = $folderOnly;
}
// ── INode ───────────────────────────────────────────
public function getName(): string {
return basename($this->path);
}
public function getLastModified(): int {
return filemtime($this->path);
}
public function delete(): void {
throw new Forbidden('Cannot delete this node');
}
public function setName($name): void {
throw new Forbidden('Renaming not supported');
}
// ── ICollection ────────────────────────────────────
public function getChildren(): array {
$nodes = [];
foreach (new \DirectoryIterator($this->path) as $item) {
if ($item->isDot()) continue;
$full = $item->getPathname();
if ($item->isDir()) {
$nodes[] = new self($full, $this->user, $this->folderOnly);
} else {
$nodes[] = new FileRiseFile($full, $this->user);
}
}
// Apply folderonly at the top level
if (
$this->folderOnly
&& realpath($this->path) === realpath(rtrim(UPLOAD_DIR,'/\\'))
) {
$nodes = array_filter($nodes, fn(INode $n)=> $n->getName() === $this->user);
}
return array_values($nodes);
}
public function childExists($name): bool {
return file_exists($this->path . DIRECTORY_SEPARATOR . $name);
}
public function getChild($name): INode {
$full = $this->path . DIRECTORY_SEPARATOR . $name;
if (!file_exists($full)) throw new NotFound("Not found: $name");
return is_dir($full)
? new self($full, $this->user, $this->folderOnly)
: new FileRiseFile($full, $this->user);
}
public function createFile($name, $data = null): INode {
$full = $this->path . DIRECTORY_SEPARATOR . $name;
$content = is_resource($data) ? stream_get_contents($data) : (string)$data;
// Compute folderkey relative to UPLOAD_DIR
$rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1);
$parts = explode('/', str_replace('\\','/',$rel));
$filename = array_pop($parts);
$folder = empty($parts) ? 'root' : implode('/', $parts);
FileModel::saveFile($folder, $filename, $content, $this->user);
return new FileRiseFile($full, $this->user);
}
public function createDirectory($name): INode {
$full = $this->path . DIRECTORY_SEPARATOR . $name;
$rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1);
$parent = dirname(str_replace('\\','/',$rel));
if ($parent === '.' || $parent === '/') $parent = '';
FolderModel::createFolder($name, $parent, $this->user);
return new self($full, $this->user, $this->folderOnly);
}
}

115
src/webdav/FileRiseFile.php Normal file
View File

@@ -0,0 +1,115 @@
<?php
// src/webdav/FileRiseFile.php
namespace FileRise\WebDAV;
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/../../src/models/FileModel.php';
use Sabre\DAV\IFile;
use Sabre\DAV\INode;
use Sabre\DAV\Exception\Forbidden;
use FileModel;
class FileRiseFile implements IFile, INode {
private string $path;
public function __construct(string $path) {
$this->path = $path;
}
// ── INode ───────────────────────────────────────────
public function getName(): string {
return basename($this->path);
}
public function getLastModified(): int {
return filemtime($this->path);
}
public function delete(): void {
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
$rel = substr($this->path, strlen($base));
$parts = explode(DIRECTORY_SEPARATOR, $rel);
$file = array_pop($parts);
$folder = empty($parts) ? 'root' : $parts[0];
FileModel::deleteFiles($folder, [$file]);
}
public function setName($newName): void {
throw new Forbidden('Renaming files not supported');
}
// ── IFile ───────────────────────────────────────────
public function get() {
return fopen($this->path, 'rb');
}
public function put($data): ?string {
// 1) Save incoming data
file_put_contents(
$this->path,
is_resource($data) ? stream_get_contents($data) : (string)$data
);
// 2) Update metadata with CurrentUser
$this->updateMetadata();
// 3) Flush to client fast
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
return null; // no ETag
}
public function getSize(): int {
return filesize($this->path);
}
public function getETag(): string {
return '"' . md5($this->getLastModified() . $this->getSize()) . '"';
}
public function getContentType(): ?string {
return mime_content_type($this->path) ?: null;
}
// ── Metadata helper ───────────────────────────────────
private function updateMetadata(): void {
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
$rel = substr($this->path, strlen($base));
$parts = explode(DIRECTORY_SEPARATOR, $rel);
$fileName = array_pop($parts);
$folder = empty($parts) ? 'root' : $parts[0];
$metaFile = META_DIR
. ($folder === 'root'
? 'root_metadata.json'
: str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json');
$metadata = [];
if (file_exists($metaFile)) {
$decoded = json_decode(file_get_contents($metaFile), true);
if (is_array($decoded)) {
$metadata = $decoded;
}
}
$now = date(DATE_TIME_FORMAT);
$uploaded = $metadata[$fileName]['uploaded'] ?? $now;
$uploader = CurrentUser::get();
$metadata[$fileName] = [
'uploaded' => $uploaded,
'modified' => $now,
'uploader' => $uploader,
];
file_put_contents($metaFile, json_encode($metadata, JSON_PRETTY_PRINT));
}
}