chore(release): v1.5.0 - ACL hardening, Folder Access & WebDAV permissions (closes #31, closes #55)

This commit is contained in:
Ryan
2025-10-17 03:14:00 -04:00
committed by GitHub
parent 25ce6a76be
commit b6d86b7896
21 changed files with 4280 additions and 4070 deletions

View File

@@ -1,9 +1,9 @@
<?php
namespace FileRise\WebDAV;
// Bootstrap constants and models
require_once __DIR__ . '/../../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT
require_once __DIR__ . '/../../config/config.php'; // constants + loadUserPermissions()
require_once __DIR__ . '/../../vendor/autoload.php'; // SabreDAV
require_once __DIR__ . '/../../src/lib/ACL.php';
require_once __DIR__ . '/../../src/models/FolderModel.php';
require_once __DIR__ . '/../../src/models/FileModel.php';
require_once __DIR__ . '/FileRiseFile.php';
@@ -12,24 +12,27 @@ 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;
private bool $isAdmin;
private array $perms;
/** cache of folder => metadata array */
private array $metaCache = [];
/**
* @param string $path Absolute filesystem path (no trailing slash)
* @param string $user Authenticated username
* @param bool $folderOnly If true, nonadmins only see $path/{user}
* @param string $path Absolute filesystem path (no trailing slash)
* @param string $user Authenticated username
* @param bool $isAdmin
* @param array $perms user-permissions map (readOnly, disableUpload, bypassOwnership, etc.)
*/
public function __construct(string $path, string $user, bool $folderOnly) {
$this->path = rtrim($path, '/\\');
$this->user = $user;
$this->folderOnly = $folderOnly;
public function __construct(string $path, string $user, bool $isAdmin, array $perms) {
$this->path = rtrim($path, '/\\');
$this->user = $user;
$this->isAdmin = $isAdmin;
$this->perms = $perms;
}
// ── INode ───────────────────────────────────────────
@@ -39,72 +42,185 @@ class FileRiseDirectory implements ICollection, INode {
}
public function getLastModified(): int {
return filemtime($this->path);
return @filemtime($this->path) ?: time();
}
public function delete(): void {
throw new Forbidden('Cannot delete this node');
throw new Forbidden('Cannot delete directories via WebDAV');
}
public function setName($name): void {
throw new Forbidden('Renaming not supported');
throw new Forbidden('Renaming directories is not supported');
}
// ── ICollection ────────────────────────────────────
public function getChildren(): array {
// Determine “folder key” relative to UPLOAD_DIR for ACL checks
$folderKey = $this->folderKeyForPath($this->path);
// Check view permission on *this* directory
$canFull = \ACL::canRead($this->user, $this->perms, $folderKey);
$canOwn = \ACL::hasGrant($this->user, $folderKey, 'read_own');
if (!$this->isAdmin && !$canFull && !$canOwn) {
throw new Forbidden('No view access to this folder');
}
$nodes = [];
$hide = ['trash','profile_pics']; // internal dirs to hide
foreach (new \DirectoryIterator($this->path) as $item) {
if ($item->isDot()) continue;
$name = $item->getFilename();
if (in_array(strtolower($name), $hide, true)) continue;
$full = $item->getPathname();
if ($item->isDir()) {
$nodes[] = new self($full, $this->user, $this->folderOnly);
} else {
$nodes[] = new FileRiseFile($full, $this->user);
// Decide if the *child folder* should be visible
$childKey = $this->folderKeyForPath($full);
$canChild = $this->isAdmin
|| \ACL::canRead($this->user, $this->perms, $childKey)
|| \ACL::hasGrant($this->user, $childKey, 'read_own');
if ($canChild) {
$nodes[] = new self($full, $this->user, $this->isAdmin, $this->perms);
}
continue;
}
// File in this directory: only list if full-view OR (own-only AND owner)
if ($canFull || $this->fileIsOwnedByUser($folderKey, $name)) {
$nodes[] = new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
}
}
// 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);
$full = $this->path . DIRECTORY_SEPARATOR . $name;
if (!file_exists($full)) return false;
$folderKey = $this->folderKeyForPath($this->path);
$isDir = is_dir($full);
if ($isDir) {
$childKey = $this->folderKeyForPath($full);
return $this->isAdmin
|| \ACL::canRead($this->user, $this->perms, $childKey)
|| \ACL::hasGrant($this->user, $childKey, 'read_own');
}
// file
$canFull = $this->isAdmin || \ACL::canRead($this->user, $this->perms, $folderKey);
if ($canFull) return true;
return \ACL::hasGrant($this->user, $folderKey, 'read_own')
&& $this->fileIsOwnedByUser($folderKey, $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);
$folderKey = $this->folderKeyForPath($this->path);
if (is_dir($full)) {
$childKey = $this->folderKeyForPath($full);
$canDir = $this->isAdmin
|| \ACL::canRead($this->user, $this->perms, $childKey)
|| \ACL::hasGrant($this->user, $childKey, 'read_own');
if (!$canDir) throw new Forbidden('No view access to requested folder');
return new self($full, $this->user, $this->isAdmin, $this->perms);
}
// file
$canFull = $this->isAdmin || \ACL::canRead($this->user, $this->perms, $folderKey);
if (!$canFull) {
if (!\ACL::hasGrant($this->user, $folderKey, 'read_own') || !$this->fileIsOwnedByUser($folderKey, $name)) {
throw new Forbidden('No view access to requested file');
}
}
return new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
}
public function createFile($name, $data = null): INode {
$folderKey = $this->folderKeyForPath($this->path);
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
throw new Forbidden('No write access to this folder');
}
if (!empty($this->perms['disableUpload']) && !$this->isAdmin) {
throw new Forbidden('Uploads are disabled for your account');
}
// Write directly to FS, then ensure metadata via FileRiseFile::put()
$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);
// Let FileRiseFile handle metadata & overwrite semantics
$fileNode = new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
$fileNode->put($content);
FileModel::saveFile($folder, $filename, $content, $this->user);
return new FileRiseFile($full, $this->user);
return $fileNode;
}
public function createDirectory($name): INode {
$full = $this->path . DIRECTORY_SEPARATOR . $name;
$rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1);
$parentKey = $this->folderKeyForPath($this->path);
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $parentKey)) {
throw new Forbidden('No permission to create subfolders here');
}
$full = $this->path . DIRECTORY_SEPARATOR . $name;
if (!is_dir($full)) {
@mkdir($full, 0755, true);
}
// FileRise folder bookkeeping (owner = creator)
$rel = $this->relFromUploads($full);
$parent = dirname(str_replace('\\','/',$rel));
if ($parent === '.' || $parent === '/') $parent = '';
FolderModel::createFolder($name, $parent, $this->user);
return new self($full, $this->user, $this->folderOnly);
\FolderModel::createFolder($name, $parent, $this->user);
return new self($full, $this->user, $this->isAdmin, $this->perms);
}
// ── helpers ──────────────────────────────────────────────────────────────
private function folderKeyForPath(string $absPath): string {
$base = rtrim(UPLOAD_DIR, '/\\');
$realBase = realpath($base) ?: $base;
$real = realpath($absPath) ?: $absPath;
if (stripos($real, $realBase) !== 0) return 'root';
$rel = ltrim(str_replace('\\','/', substr($real, strlen($realBase))), '/');
return ($rel === '' ? 'root' : $rel);
}
private function relFromUploads(string $absPath): string {
$base = rtrim(UPLOAD_DIR, '/\\');
return ltrim(str_replace('\\','/', substr($absPath, strlen($base))), '/');
}
private function loadMeta(string $folderKey): array {
if (isset($this->metaCache[$folderKey])) return $this->metaCache[$folderKey];
$metaFile = META_DIR . (
$folderKey === 'root'
? 'root_metadata.json'
: str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json'
);
$data = [];
if (is_file($metaFile)) {
$decoded = json_decode(@file_get_contents($metaFile), true);
if (is_array($decoded)) $data = $decoded;
}
return $this->metaCache[$folderKey] = $data;
}
private function fileIsOwnedByUser(string $folderKey, string $fileName): bool {
$meta = $this->loadMeta($folderKey);
return isset($meta[$fileName]['uploader'])
&& strcasecmp((string)$meta[$fileName]['uploader'], $this->user) === 0;
}
}

View File

@@ -5,18 +5,25 @@ namespace FileRise\WebDAV;
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/../../src/lib/ACL.php';
require_once __DIR__ . '/../../src/models/FileModel.php';
require_once __DIR__ . '/CurrentUser.php';
use Sabre\DAV\IFile;
use Sabre\DAV\INode;
use Sabre\DAV\Exception\Forbidden;
use FileModel;
class FileRiseFile implements IFile, INode {
private string $path;
private string $user;
private bool $isAdmin;
private array $perms;
public function __construct(string $path) {
$this->path = $path;
public function __construct(string $path, string $user, bool $isAdmin, array $perms) {
$this->path = $path;
$this->user = $user;
$this->isAdmin = $isAdmin;
$this->perms = $perms;
}
// ── INode ───────────────────────────────────────────
@@ -26,90 +33,134 @@ class FileRiseFile implements IFile, INode {
}
public function getLastModified(): int {
return filemtime($this->path);
return @filemtime($this->path) ?: time();
}
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]);
[$folderKey, $fileName] = $this->split();
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
throw new Forbidden('No write access to delete this file');
}
if (!$this->canTouchOwnership($folderKey, $fileName)) {
throw new Forbidden('You do not own this file');
}
\FileModel::deleteFiles($folderKey, [$fileName]);
}
public function setName($newName): void {
throw new Forbidden('Renaming files not supported');
throw new Forbidden('Renaming files via WebDAV is not supported');
}
// ── IFile ───────────────────────────────────────────
public function get() {
[$folderKey, $fileName] = $this->split();
$canFull = $this->isAdmin || \ACL::canRead($this->user, $this->perms, $folderKey);
if (!$canFull) {
// own-only?
if (!\ACL::hasGrant($this->user, $folderKey, 'read_own') || !$this->isOwner($folderKey, $fileName)) {
throw new Forbidden('No view access to this file');
}
}
return fopen($this->path, 'rb');
}
public function put($data): ?string {
// 1) Save incoming data
[$folderKey, $fileName] = $this->split();
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
throw new Forbidden('No write access to this folder');
}
if (!empty($this->perms['disableUpload']) && !$this->isAdmin) {
throw new Forbidden('Uploads are disabled for your account');
}
// If overwriting existing file, enforce ownership for non-admin unless bypassOwnership
$exists = is_file($this->path);
$bypass = !empty($this->perms['bypassOwnership']);
if ($exists && !$this->isAdmin && !$bypass && !$this->isOwner($folderKey, $fileName)) {
throw new Forbidden('You do not own the target file');
}
// Write data
file_put_contents(
$this->path,
is_resource($data) ? stream_get_contents($data) : (string)$data
);
// 2) Update metadata with CurrentUser
$this->updateMetadata();
// Update metadata (uploader on first write; modified every write)
$this->updateMetadata($folderKey, $fileName);
// 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);
return @filesize($this->path) ?: 0;
}
public function getETag(): string {
return '"' . md5($this->getLastModified() . $this->getSize()) . '"';
return '"' . md5(($this->getLastModified() ?: 0) . ':' . ($this->getSize() ?: 0)) . '"';
}
public function getContentType(): ?string {
return mime_content_type($this->path) ?: null;
return @mime_content_type($this->path) ?: null;
}
// ── Metadata helper ───────────────────────────────────
// ── helpers ──────────────────────────────────────────────────────────────
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];
private function split(): array {
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
$rel = ltrim(str_replace('\\','/', substr($this->path, strlen($base))), '/');
$parts = explode('/', $rel);
$file = array_pop($parts);
$folder = empty($parts) ? 'root' : implode('/', $parts);
return [$folder, $file];
}
$metaFile = META_DIR
. ($folder === 'root'
? 'root_metadata.json'
: str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json');
private function metaFile(string $folderKey): string {
return META_DIR . (
$folderKey === 'root'
? 'root_metadata.json'
: str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json'
);
}
$metadata = [];
if (file_exists($metaFile)) {
$decoded = json_decode(file_get_contents($metaFile), true);
if (is_array($decoded)) {
$metadata = $decoded;
}
}
private function loadMeta(string $folderKey): array {
$mf = $this->metaFile($folderKey);
if (!is_file($mf)) return [];
$d = json_decode(@file_get_contents($mf), true);
return is_array($d) ? $d : [];
}
private function saveMeta(string $folderKey, array $meta): void {
@file_put_contents($this->metaFile($folderKey), json_encode($meta, JSON_PRETTY_PRINT));
}
private function isOwner(string $folderKey, string $fileName): bool {
$meta = $this->loadMeta($folderKey);
return isset($meta[$fileName]['uploader']) &&
strcasecmp((string)$meta[$fileName]['uploader'], $this->user) === 0;
}
private function canTouchOwnership(string $folderKey, string $fileName): bool {
if ($this->isAdmin || !empty($this->perms['bypassOwnership'])) return true;
return $this->isOwner($folderKey, $fileName);
}
private function updateMetadata(string $folderKey, string $fileName): void {
$meta = $this->loadMeta($folderKey);
$now = date(DATE_TIME_FORMAT);
$uploaded = $metadata[$fileName]['uploaded'] ?? $now;
$uploader = CurrentUser::get();
$uploaded = $meta[$fileName]['uploaded'] ?? $now;
$uploader = CurrentUser::get() ?: $this->user;
$metadata[$fileName] = [
'uploaded' => $uploaded,
'modified' => $now,
'uploader' => $uploader,
$meta[$fileName] = [
'uploaded' => $uploaded,
'modified' => $now,
'uploader' => $uploader,
];
file_put_contents($metaFile, json_encode($metadata, JSON_PRETTY_PRINT));
$this->saveMeta($folderKey, $meta);
}
}