Files
FileRise/src/controllers/FolderController.php
Ryan f5e42a2e81 release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more”
- Lazy folder tree via /api/folder/listChildren.php with cursor pagination
- ACL-safe chevrons using hasSubfolders from server; no file-count leaks
- BFS smart initial folder selection + respect lastOpenedFolder
- Locked nodes are expandable but not selectable
- “Load more” UX (light & dark) for huge directories

Closes #66
2025-11-13 05:06:24 -05:00

1490 lines
57 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// src/controllers/FolderController.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/FolderMeta.php';
require_once PROJECT_ROOT . '/src/lib/FS.php';
class FolderController
{
/* -------------------- Session / Header helpers -------------------- */
private static function ensureSession(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
}
private static function getHeadersLower(): array
{
if (function_exists('getallheaders')) {
$h = getallheaders();
if (is_array($h)) return array_change_key_case($h, CASE_LOWER);
}
$headers = [];
foreach ($_SERVER as $k => $v) {
if (strpos($k, 'HTTP_') === 0) {
$name = strtolower(str_replace('_', '-', substr($k, 5)));
$headers[$name] = $v;
}
}
return $headers;
}
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500): array {
return FolderModel::listChildren($folder, $user, $perms, $cursor, $limit);
}
/** Stats for a folder (currently: empty/non-empty via folders/files counts). */
public static function stats(string $folder, string $user, array $perms): array
{
// Normalize inside model; this is a thin action
return FolderModel::countVisible($folder, $user, $perms);
}
/** Capabilities for UI buttons/menus (unchanged semantics; just centralized). */
public static function capabilities(string $folder, string $username): array
{
$folder = ACL::normalizeFolder($folder);
$perms = self::loadPermsFor($username);
$isAdmin = ACL::isAdmin($perms);
$folderOnly = self::boolFrom($perms, 'folderOnly','userFolderOnly','UserFolderOnly');
$readOnly = !empty($perms['readOnly']);
$disableUpload = !empty($perms['disableUpload']);
$isOwner = ACL::isOwner($username, $perms, $folder);
$inScope = self::inUserFolderScope($folder, $username, $perms, $isAdmin, $folderOnly);
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
$canView = $canViewBase && $inScope;
$canUpload = $gUploadBase && !$readOnly && !$disableUpload && $inScope;
$canCreate = $gCreateBase && !$readOnly && $inScope;
$canRename = $gRenameBase && !$readOnly && $inScope;
$canDelete = $gDeleteBase && !$readOnly && $inScope;
$canDeleteFile = $gDeleteBase && !$readOnly && $inScope;
$canDeleteFolder = !$readOnly && $inScope && (
$isAdmin ||
$isOwner ||
ACL::canManage($username, $perms, $folder) ||
$gDeleteBase // if your ACL::canDelete should also allow folder deletes
);
$canReceive = ($gUploadBase || $gCreateBase || $isAdmin) && !$readOnly && !$disableUpload && $inScope;
$canMoveIn = $canReceive;
$canEdit = $gEditBase && !$readOnly && $inScope;
$canCopy = $gCopyBase && !$readOnly && $inScope;
$canExtract = $gExtractBase && !$readOnly && $inScope;
$canShareEff = $canShareBase && $inScope;
$canShareFile = $gShareFile && $inScope;
$canShareFold = $gShareFolder && $inScope;
$isRoot = ($folder === 'root');
$canMoveFolder = false;
if ($isRoot) {
$canRename = false;
$canDelete = false;
$canShareFold = false;
} else {
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
&& !$readOnly;
}
$owner = null;
try { if (class_exists('FolderModel') && method_exists('FolderModel','getOwnerFor')) $owner = FolderModel::getOwnerFor($folder); } catch (\Throwable $e) {}
return [
'user' => $username,
'folder' => $folder,
'isAdmin' => $isAdmin,
'flags' => [
'folderOnly' => $folderOnly,
'readOnly' => $readOnly,
'disableUpload' => $disableUpload,
],
'owner' => $owner,
'canView' => $canView,
'canViewOwn' => $canViewOwn,
'canUpload' => $canUpload,
'canCreate' => $canCreate,
'canRename' => $canRename,
'canDelete' => $canDeleteFile,
'canDeleteFolder' => $canDeleteFolder,
'canMoveIn' => $canMoveIn,
'canMove' => $canMoveIn, // legacy alias
'canMoveFolder' => $canMoveFolder,
'canEdit' => $canEdit,
'canCopy' => $canCopy,
'canExtract' => $canExtract,
'canShare' => $canShareEff, // legacy umbrella
'canShareFile' => $canShareFile,
'canShareFolder' => $canShareFold,
];
}
/* ---------------------------
Private helpers (caps)
----------------------------*/
private static function loadPermsFor(string $u): array {
try {
if (function_exists('loadUserPermissions')) {
$p = loadUserPermissions($u);
return is_array($p) ? $p : [];
}
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
$all = userModel::getUserPermissions();
if (is_array($all)) {
if (isset($all[$u])) return (array)$all[$u];
$lk = strtolower($u);
if (isset($all[$lk])) return (array)$all[$lk];
}
}
} catch (\Throwable $e) {}
return [];
}
private static function boolFrom(array $a, string ...$keys): bool {
foreach ($keys as $k) if (!empty($a[$k])) return true;
return false;
}
private static function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
$f = ACL::normalizeFolder($folder);
if (ACL::isOwner($user, $perms, $f)) return true;
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
$pos = strrpos($f, '/');
if ($pos === false) break;
$f = substr($f, 0, $pos);
if ($f === '' || strcasecmp($f, 'root') === 0) break;
if (ACL::isOwner($user, $perms, $f)) return true;
}
return false;
}
private static function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin, bool $folderOnly): bool {
if ($isAdmin) return true;
if (!$folderOnly) return true; // normal users: global scope
$f = ACL::normalizeFolder($folder);
if ($f === 'root' || $f === '') {
return self::isOwnerOrAncestorOwner($u, $perms, $f);
}
if ($f === $u || str_starts_with($f, $u . '/')) return true;
return self::isOwnerOrAncestorOwner($u, $perms, $f);
}
private static function requireCsrf(): void
{
self::ensureSession();
$headers = self::getHeadersLower();
$received = trim($headers['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid CSRF token']);
exit;
}
}
private static function requireAuth(): void
{
self::ensureSession();
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Unauthorized']);
exit;
}
}
/* -------------------- Permissions helpers -------------------- */
private static function loadPerms(string $username): array
{
try {
if (function_exists('loadUserPermissions')) {
$p = loadUserPermissions($username);
return is_array($p) ? $p : [];
}
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
$all = userModel::getUserPermissions();
if (is_array($all)) {
if (isset($all[$username])) return (array)$all[$username];
$lk = strtolower($username);
if (isset($all[$lk])) return (array)$all[$lk];
}
}
} catch (\Throwable $e) { /* ignore */
}
return [];
}
private static function migrateFolderColors(string $source, string $target): array
{
// PHP 8 polyfill
if (!function_exists('str_starts_with')) {
function str_starts_with(string $haystack, string $needle): bool
{
return $needle === '' || strncmp($haystack, $needle, strlen($needle)) === 0;
}
}
$metaDir = rtrim(META_DIR, '/\\');
$file = $metaDir . '/folder_colors.json';
// Read current map (treat unreadable/invalid as empty)
$raw = @file_get_contents($file);
$map = is_string($raw) ? json_decode($raw, true) : [];
if (!is_array($map)) $map = [];
// Nothing to do fast-path
$prefixSrc = $source;
$prefixNeed = $source . '/';
$changed = false;
$new = $map;
$movedCount = 0;
foreach ($map as $key => $hex) {
if ($key === $prefixSrc || str_starts_with($key . '/', $prefixNeed)) {
unset($new[$key]);
$suffix = substr($key, strlen($prefixSrc)); // '' or '/sub/...'
$newKey = ($target === 'root') ? ltrim($suffix, '/\\') : rtrim($target, '/\\') . $suffix;
$new[$newKey] = $hex;
$changed = true;
$movedCount++;
}
}
if ($changed) {
// Write back (atomic-ish). Ignore failures (dont block the move).
$json = json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (is_string($json)) {
@file_put_contents($file, $json, LOCK_EX);
@chmod($file, 0664);
}
}
return ['changed' => $changed, 'moved' => $movedCount];
}
private static function getPerms(): array
{
self::ensureSession();
$u = $_SESSION['username'] ?? '';
return $u ? self::loadPerms($u) : [];
}
private static function isAdmin(array $perms = []): bool
{
self::ensureSession();
if (!empty($_SESSION['isAdmin'])) return true;
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
// Fallback: role from users.txt (role "1" means admin)
$u = $_SESSION['username'] ?? '';
if ($u && class_exists('userModel') && method_exists('userModel', 'getUserRole')) {
$roleStr = userModel::getUserRole($u);
if ($roleStr === '1') return true;
}
return false;
}
private static function isFolderOnly(array $perms): bool
{
return !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
}
private static function requireNotReadOnly(): void
{
$perms = self::getPerms();
if (!empty($perms['readOnly'])) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Read-only users are not allowed to perform this action.']);
exit;
}
}
private static function requireAdmin(): void
{
$perms = self::getPerms();
if (!self::isAdmin($perms)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Admin privileges required.']);
exit;
}
}
private static function formatBytes(int $bytes): string
{
if ($bytes < 1024) return $bytes . " B";
if ($bytes < 1048576) return round($bytes / 1024, 2) . " KB";
if ($bytes < 1073741824) return round($bytes / 1048576, 2) . " MB";
return round($bytes / 1073741824, 2) . " GB";
}
/** Return true if user is explicit owner of the folder or any of its ancestors (admins also true). */
private static function ownsFolderOrAncestor(string $folder, string $username, array $perms): bool
{
if (self::isAdmin($perms)) return true;
$folder = ACL::normalizeFolder($folder);
$f = $folder;
while ($f !== '' && strtolower($f) !== 'root') {
if (ACL::isOwner($username, $perms, $f)) return true;
$pos = strrpos($f, '/');
$f = ($pos === false) ? '' : substr($f, 0, $pos);
}
return false;
}
/**
* Enforce per-folder scope for folder-only accounts.
* $need: 'read' | 'write' | 'manage' | 'share' | 'read_own' (default 'read')
* Returns null if allowed, or an error string if forbidden.
*/
// In FolderController.php
private static function enforceFolderScope(
string $folder,
string $username,
array $perms,
string $need = 'read'
): ?string {
// Admins bypass scope
if (self::isAdmin($perms)) return null;
// If this account isn't folder-scoped, don't gate here
if (!self::isFolderOnly($perms)) return null;
$folder = ACL::normalizeFolder($folder);
// If user owns folder or an ancestor, allow
$f = $folder;
while ($f !== '' && strtolower($f) !== 'root') {
if (ACL::isOwner($username, $perms, $f)) return null;
$pos = strrpos($f, '/');
$f = ($pos === false) ? '' : substr($f, 0, $pos);
}
// Normalize aliases so callers can pass either camelCase or snake_case
switch ($need) {
case 'manage':
$ok = ACL::canManage($username, $perms, $folder);
break;
// legacy:
case 'write':
$ok = ACL::canWrite($username, $perms, $folder);
break;
case 'share':
$ok = ACL::canShare($username, $perms, $folder);
break;
// read flavors:
case 'read_own':
$ok = ACL::canReadOwn($username, $perms, $folder);
break;
case 'read':
$ok = ACL::canRead($username, $perms, $folder);
break;
// granular write-ish:
case 'create':
$ok = ACL::canCreate($username, $perms, $folder);
break;
case 'upload':
$ok = ACL::canUpload($username, $perms, $folder);
break;
case 'edit':
$ok = ACL::canEdit($username, $perms, $folder);
break;
case 'rename':
$ok = ACL::canRename($username, $perms, $folder);
break;
case 'copy':
$ok = ACL::canCopy($username, $perms, $folder);
break;
case 'move':
$ok = ACL::canMove($username, $perms, $folder);
break;
case 'delete':
$ok = ACL::canDelete($username, $perms, $folder);
break;
case 'extract':
$ok = ACL::canExtract($username, $perms, $folder);
break;
// granular share (support both key styles)
case 'shareFile':
case 'share_file':
$ok = ACL::canShareFile($username, $perms, $folder);
break;
case 'shareFolder':
case 'share_folder':
$ok = ACL::canShareFolder($username, $perms, $folder);
break;
default:
// Default to full read if unknown need was passed
$ok = ACL::canRead($username, $perms, $folder);
}
return $ok ? null : "Forbidden: folder scope violation.";
}
/** Returns true if caller can ignore ownership (admin or bypassOwnership/default). */
private static function canBypassOwnership(array $perms): bool
{
if (self::isAdmin($perms)) return true;
return (bool)($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
}
/** ACL-aware folder owner check (explicit). */
private static function isFolderOwner(string $folder, string $username, array $perms): bool
{
return ACL::isOwner($username, $perms, $folder);
}
/* -------------------- API: Create Folder -------------------- */
public function createFolder(): void
{
header('Content-Type: application/json');
self::requireAuth();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed.']);
return;
}
self::requireCsrf();
self::requireNotReadOnly();
try {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
if (!isset($input['folderName'])) {
http_response_code(400);
echo json_encode(['error' => 'Folder name not provided.']);
return;
}
$folderName = trim((string)$input['folderName']);
$parentIn = isset($input['parent']) ? trim((string)$input['parent']) : 'root';
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder name.']);
return;
}
if ($parentIn !== '' && strcasecmp($parentIn, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parentIn)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid parent folder name.']);
return;
}
$parent = ($parentIn === '' ? 'root' : $parentIn);
$username = $_SESSION['username'] ?? '';
$perms = self::getPerms();
// Need create on parent OR ownership on parent/ancestor
if (!(ACL::canCreateFolder($username, $perms, $parent) || self::ownsFolderOrAncestor($parent, $username, $perms))) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: manager/owner required on parent.']);
exit;
}
// Folder-scope gate for folder-only accounts (need create on parent)
if ($msg = self::enforceFolderScope($parent, $username, $perms, 'manage')) {
http_response_code(403);
echo json_encode(['error' => $msg]);
return;
}
$result = FolderModel::createFolder($folderName, $parent, $username);
if (empty($result['success'])) {
http_response_code(400);
echo json_encode($result);
return;
}
echo json_encode($result);
} catch (Throwable $e) {
error_log('createFolder fatal: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
http_response_code(500);
echo json_encode(['error' => 'Internal error creating folder.']);
}
}
/* -------------------- API: Delete Folder -------------------- */
public function deleteFolder(): void
{
header('Content-Type: application/json');
self::requireAuth();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(["error" => "Method not allowed."]);
exit;
}
self::requireCsrf();
self::requireNotReadOnly();
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['folder'])) {
http_response_code(400);
echo json_encode(["error" => "Folder name not provided."]);
exit;
}
$folder = trim((string)$input['folder']);
if (strcasecmp($folder, 'root') === 0) {
http_response_code(400);
echo json_encode(["error" => "Cannot delete root folder."]);
exit;
}
if (!preg_match(REGEX_FOLDER_NAME, $folder)) {
http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
$username = $_SESSION['username'] ?? '';
$perms = self::getPerms();
// Folder-scope: need manage (owner) OR explicit manage grant
if ($msg = self::enforceFolderScope($folder, $username, $perms, 'manage')) {
http_response_code(403);
echo json_encode(["error" => $msg]);
exit;
}
// Require either manage permission or ancestor ownership (strong gate)
$canManage = ACL::canManage($username, $perms, $folder) || self::ownsFolderOrAncestor($folder, $username, $perms);
if (!$canManage) {
http_response_code(403);
echo json_encode(["error" => "Forbidden: you lack manage rights for this folder."]);
exit;
}
// If not bypassing ownership, require ownership (direct or ancestor) as an extra safeguard
if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($folder, $username, $perms)) {
http_response_code(403);
echo json_encode(["error" => "Forbidden: you are not the folder owner."]);
exit;
}
$result = FolderModel::deleteFolder($folder);
echo json_encode($result);
exit;
}
/* -------------------- API: Rename Folder -------------------- */
public function renameFolder(): void
{
header('Content-Type: application/json');
self::requireAuth();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed.']);
exit;
}
self::requireCsrf();
self::requireNotReadOnly();
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['oldFolder']) || !isset($input['newFolder'])) {
http_response_code(400);
echo json_encode(['error' => 'Required folder names not provided.']);
exit;
}
$oldFolder = trim((string)$input['oldFolder']);
$newFolder = trim((string)$input['newFolder']);
if (!preg_match(REGEX_FOLDER_NAME, $oldFolder) || !preg_match(REGEX_FOLDER_NAME, $newFolder)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder name(s).']);
exit;
}
$username = $_SESSION['username'] ?? '';
$perms = self::getPerms();
// Must be allowed to manage the old folder
if ($msg = self::enforceFolderScope($oldFolder, $username, $perms, 'manage')) {
http_response_code(403);
echo json_encode(["error" => $msg]);
exit;
}
// For the new folder path, require write scope (we're "creating" a path)
if ($msg = self::enforceFolderScope($newFolder, $username, $perms, 'manage')) {
http_response_code(403);
echo json_encode(["error" => "New path not allowed: " . $msg]);
exit;
}
// Strong gates: need manage on old OR ancestor owner; need manage on new parent OR ancestor owner
$canManageOld = ACL::canManage($username, $perms, $oldFolder) || self::ownsFolderOrAncestor($oldFolder, $username, $perms);
if (!$canManageOld) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: you lack manage rights on the source folder.']);
exit;
}
// If not bypassing ownership, require ownership (direct or ancestor) on the old folder
if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($oldFolder, $username, $perms)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: you are not the folder owner.']);
exit;
}
$result = FolderModel::renameFolder($oldFolder, $newFolder);
echo json_encode($result);
exit;
}
/* -------------------- API: Get Folder List -------------------- */
public function getFolderList(): void
{
header('Content-Type: application/json');
self::requireAuth();
// Optional "folder" filter (supports nested like "team/reports")
$parent = $_GET['folder'] ?? null;
if ($parent !== null && $parent !== '' && strcasecmp($parent, 'root') !== 0) {
$parts = array_filter(explode('/', trim($parent, "/\\ ")), fn($p) => $p !== '');
if (empty($parts)) {
http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
}
$parent = implode('/', $parts);
}
$username = $_SESSION['username'] ?? '';
$perms = self::getPerms();
$isAdmin = self::isAdmin($perms);
// 1) Full list from model
$all = FolderModel::getFolderList(); // each row: ["folder","fileCount","metadataFile"]
if (!is_array($all)) {
echo json_encode([]);
exit;
}
// 2) Filter by view rights
if (!$isAdmin) {
$all = array_values(array_filter($all, function ($row) use ($username, $perms) {
$f = $row['folder'] ?? '';
if ($f === '') return false;
// Full view if canRead OR owns ancestor; otherwise allow if read_own granted
$fullView = ACL::canRead($username, $perms, $f) || FolderController::ownsFolderOrAncestor($f, $username, $perms);
$ownOnly = ACL::hasGrant($username, $f, 'read_own');
return $fullView || $ownOnly;
}));
}
// 3) Optional parent filter (applies to both admin and non-admin)
if ($parent && strcasecmp($parent, 'root') !== 0) {
$pref = $parent . '/';
$all = array_values(array_filter($all, function ($row) use ($parent, $pref) {
$f = $row['folder'] ?? '';
return ($f === $parent) || (strpos($f, $pref) === 0);
}));
}
echo json_encode($all);
exit;
}
/* -------------------- Public Shared Folder HTML -------------------- */
public function shareFolder(): void
{
$token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING);
$providedPass = filter_input(INPUT_GET, 'pass', FILTER_SANITIZE_STRING);
$page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT);
if ($page === false || $page < 1) $page = 1;
if (empty($token)) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(["error" => "Missing token."]);
exit;
}
$data = FolderModel::getSharedFolderData($token, $providedPass, $page);
if (isset($data['needs_password']) && $data['needs_password'] === true) {
header("Content-Type: text/html; charset=utf-8"); ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Enter Password</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
background: #f7f7f7
}
.container {
max-width: 400px;
margin: 80px auto;
background: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, .1)
}
input[type=password],
button {
width: 100%;
padding: 10px;
margin: 10px 0;
font-size: 1rem
}
button {
background: #007BFF;
border: none;
color: #fff;
cursor: pointer
}
button:hover {
background: #0056b3
}
</style>
</head>
<body>
<div class="container">
<h2>Folder Protected</h2>
<p>This folder is protected by a password. Please enter the password to view its contents.</p>
<form method="get" action="/api/folder/shareFolder.php"><input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>"><label for="pass">Password:</label><input type="password" name="pass" id="pass" required><button type="submit">Submit</button></form>
</div>
</body>
</html>
<?php exit;
}
if (isset($data['error'])) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(["error" => $data['error']]);
exit;
}
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
$adminConfig = AdminModel::getConfig();
$sharedMaxUploadSize = (isset($adminConfig['sharedMaxUploadSize']) && is_numeric($adminConfig['sharedMaxUploadSize']))
? (int)$adminConfig['sharedMaxUploadSize'] : null;
$folderName = $data['folder'];
$files = $data['files'];
$currentPage = $data['currentPage'];
$totalPages = $data['totalPages'];
header("Content-Type: text/html; charset=utf-8"); ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shared Folder: <?php echo htmlspecialchars($folderName, ENT_QUOTES, 'UTF-8'); ?></title>
<style>
body {
background: #f2f2f2;
font-family: Arial, sans-serif;
padding: 0 20px 20px;
color: #333
}
.header {
text-align: center;
margin: 0 0 30px
}
.container {
max-width: 800px;
margin: 0 auto;
background: #fff;
border-radius: 4px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, .1)
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px
}
th,
td {
padding: 12px;
border-bottom: 1px solid #ddd;
text-align: left
}
th {
background: #007BFF;
color: #fff
}
.pagination {
text-align: center;
margin-top: 20px
}
.pagination a,
.pagination span {
margin: 0 5px;
padding: 8px 12px;
background: #007BFF;
color: #fff;
border-radius: 4px;
text-decoration: none
}
.pagination span.current {
background: #0056b3
}
.shared-gallery-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
padding: 10px 0
}
.shared-gallery-card {
border: 1px solid #ccc;
padding: 5px;
text-align: center
}
.shared-gallery-card img {
max-width: 100%;
display: block;
margin: 0 auto
}
.upload-container {
margin-top: 30px;
text-align: center
}
.upload-container h3 {
font-size: 1.4rem;
margin-bottom: 10px
}
.upload-container form {
display: inline-block;
margin-top: 10px
}
.upload-container button {
background-color: #28a745;
border: none;
color: #fff;
padding: 10px 20px;
font-size: 1rem;
border-radius: 4px;
cursor: pointer
}
.upload-container button:hover {
background-color: #218838
}
.footer {
text-align: center;
margin-top: 40px;
font-size: .9rem;
color: #777
}
.toggle-btn {
background-color: #007BFF;
color: #fff;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 1rem;
cursor: pointer
}
.toggle-btn:hover {
background-color: #0056b3
}
.pagination a:hover {
background-color: #0056b3
}
.pagination span {
cursor: default
}
</style>
</head>
<body>
<div class="header">
<h1>Shared Folder: <?php echo htmlspecialchars($folderName, ENT_QUOTES, 'UTF-8'); ?></h1>
</div>
<div class="container">
<button id="toggleBtn" class="toggle-btn">Switch to Gallery View</button>
<div id="listViewContainer">
<?php if (empty($files)): ?>
<p style="text-align:center;">This folder is empty.</p>
<?php else: ?>
<table>
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<?php foreach ($files as $file):
$safeName = htmlspecialchars($file, ENT_QUOTES, 'UTF-8');
$filePath = $data['realFolderPath'] . DIRECTORY_SEPARATOR . $file;
$sizeString = (is_file($filePath) ? self::formatBytes((int)@filesize($filePath)) : "Unknown");
$downloadLink = "/api/folder/downloadSharedFile.php?token=" . urlencode($token) . "&file=" . urlencode($file);
?>
<tr>
<td><a href="<?php echo htmlspecialchars($downloadLink, ENT_QUOTES, 'UTF-8'); ?>"><?php echo $safeName; ?> <span class="download-icon">&#x21E9;</span></a></td>
<td><?php echo $sizeString; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div id="galleryViewContainer" style="display:none;"></div>
<div class="pagination">
<?php if ($currentPage > 1): ?>
<a href="/api/folder/shareFolder.php?token=<?php echo urlencode($token); ?>&page=<?php echo $currentPage - 1; ?><?php echo !empty($providedPass) ? "&pass=" . urlencode($providedPass) : ""; ?>">Prev</a>
<?php else: ?><span>Prev</span><?php endif; ?>
<?php $startPage = max(1, $currentPage - 2);
$endPage = min($totalPages, $currentPage + 2);
for ($i = $startPage; $i <= $endPage; $i++): ?>
<?php if ($i == $currentPage): ?><span class="current"><?php echo $i; ?></span>
<?php else: ?><a href="/api/folder/shareFolder.php?token=<?php echo urlencode($token); ?>&page=<?php echo $i; ?><?php echo !empty($providedPass) ? "&pass=" . urlencode($providedPass) : ""; ?>"><?php echo $i; ?></a>
<?php endif;
endfor; ?>
<?php if ($currentPage < $totalPages): ?>
<a href="/api/folder/shareFolder.php?token=<?php echo urlencode($token); ?>&page=<?php echo $currentPage + 1; ?><?php echo !empty($providedPass) ? "&pass=" . urlencode($providedPass) : ""; ?>">Next</a>
<?php else: ?><span>Next</span><?php endif; ?>
</div>
<?php if (isset($data['record']['allowUpload']) && (int)$data['record']['allowUpload'] === 1): ?>
<div class="upload-container">
<h3>Upload File <?php if ($sharedMaxUploadSize !== null): ?>(<?php echo self::formatBytes($sharedMaxUploadSize); ?> max size)<?php endif; ?></h3>
<form action="/api/folder/uploadToSharedFolder.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
<input type="file" name="fileToUpload" required><br><br><button type="submit">Upload</button>
</form>
</div>
<?php endif; ?>
</div>
<div class="footer">&copy; <?php echo date("Y"); ?> FileRise. All rights reserved.</div>
<script type="application/json" id="shared-data">
{
"token": <?php echo json_encode($token, JSON_HEX_TAG); ?>,
"files": <?php echo json_encode($files, JSON_HEX_TAG); ?>
}
</script>
<script src="/js/sharedFolderView.js" defer></script>
</body>
</html>
<?php
exit;
}
/* -------------------- API: Create Share Folder Link -------------------- */
public function createShareFolderLink(): void
{
header('Content-Type: application/json');
self::requireAuth();
self::requireCsrf();
self::requireNotReadOnly();
$in = json_decode(file_get_contents("php://input"), true);
if (!$in || !isset($in['folder'])) {
http_response_code(400);
echo json_encode(["error" => "Invalid input."]);
exit;
}
$folder = trim((string)$in['folder']);
$value = isset($in['expirationValue']) ? intval($in['expirationValue']) : 60;
$unit = $in['expirationUnit'] ?? 'minutes';
$password = (string)($in['password'] ?? '');
$allowUpload = intval($in['allowUpload'] ?? 0);
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
http_response_code(400);
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
$username = $_SESSION['username'] ?? '';
$perms = self::getPerms();
$isAdmin = self::isAdmin($perms);
// Must have share on this folder OR be ancestor owner
if (!(ACL::canShare($username, $perms, $folder) || self::ownsFolderOrAncestor($folder, $username, $perms))) {
http_response_code(403);
echo json_encode(["error" => "Sharing is not permitted for your account."]);
exit;
}
// Folder-scope: need share capability within scope
if ($msg = self::enforceFolderScope($folder, $username, $perms, 'share')) {
http_response_code(403);
echo json_encode(["error" => $msg]);
exit;
}
// Ownership requirement unless bypassed (allow ancestor owners)
if (!self::canBypassOwnership($perms) && !self::ownsFolderOrAncestor($folder, $username, $perms)) {
http_response_code(403);
echo json_encode(["error" => "Forbidden: you are not the owner of this folder."]);
exit;
}
if ($allowUpload === 1 && !empty($perms['disableUpload']) && !$isAdmin) {
http_response_code(403);
echo json_encode(["error" => "You cannot enable uploads on shared folders."]);
exit;
}
if ($value < 1) $value = 1;
switch ($unit) {
case 'seconds':
$seconds = $value;
break;
case 'hours':
$seconds = $value * 3600;
break;
case 'days':
$seconds = $value * 86400;
break;
case 'minutes':
default:
$seconds = $value * 60;
break;
}
$seconds = min($seconds, 31536000);
$res = FolderModel::createShareFolderLink($folder, $seconds, $password, $allowUpload);
echo json_encode($res);
exit;
}
/* -------------------- API: Download Shared File -------------------- */
public function downloadSharedFile(): void
{
$token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_STRING);
$file = filter_input(INPUT_GET, 'file', FILTER_SANITIZE_STRING);
if (empty($token) || empty($file)) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(["error" => "Missing token or file parameter."]);
exit;
}
$basename = basename($file);
if (!preg_match(REGEX_FILE_NAME, $basename)) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(["error" => "Invalid file name."]);
exit;
}
$result = FolderModel::getSharedFileInfo($token, $basename);
if (isset($result['error'])) {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(["error" => $result['error']]);
exit;
}
$realFilePath = $result['realFilePath'];
$mimeType = $result['mimeType'];
header('X-Content-Type-Options: nosniff');
header("Content-Type: " . $mimeType);
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico'])) {
header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"');
} else {
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
}
$size = @filesize($realFilePath);
if (is_int($size)) header('Content-Length: ' . $size);
readfile($realFilePath);
exit;
}
/* -------------------- API: Upload to Shared Folder -------------------- */
public function uploadToSharedFolder(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Content-Type: application/json');
echo json_encode(["error" => "Method not allowed."]);
exit;
}
if (empty($_POST['token'])) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(["error" => "Missing share token."]);
exit;
}
$token = trim($_POST['token']);
if (!isset($_FILES['fileToUpload'])) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(["error" => "No file was uploaded."]);
exit;
}
$fileUpload = $_FILES['fileToUpload'];
if (!empty($fileUpload['error']) && $fileUpload['error'] !== UPLOAD_ERR_OK) {
$map = [
UPLOAD_ERR_INI_SIZE => 'The uploaded file exceeds the upload_max_filesize directive.',
UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the MAX_FILE_SIZE directive.',
UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded.',
UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder.',
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.',
UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload.'
];
$msg = $map[$fileUpload['error']] ?? 'Upload error.';
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['error' => $msg]);
exit;
}
$result = FolderModel::uploadToSharedFolder($token, $fileUpload);
if (isset($result['error'])) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode($result);
exit;
}
$_SESSION['upload_message'] = "File uploaded successfully.";
$redirectUrl = "/api/folder/shareFolder.php?token=" . urlencode($token);
header("Location: " . $redirectUrl);
exit;
}
/* -------------------- Admin: List/Delete Share Folder Links -------------------- */
public function getAllShareFolderLinks(): void
{
header('Content-Type: application/json');
self::requireAuth();
self::requireAdmin(); // exposing all share folder links is an admin operation
$shareFile = META_DIR . 'share_folder_links.json';
$links = file_exists($shareFile) ? json_decode(file_get_contents($shareFile), true) ?? [] : [];
$now = time();
$cleaned = [];
foreach ($links as $token => $record) {
if (!empty($record['expires']) && $record['expires'] < $now) continue;
$cleaned[$token] = $record;
}
if (count($cleaned) !== count($links)) {
file_put_contents($shareFile, json_encode($cleaned, JSON_PRETTY_PRINT));
}
echo json_encode($cleaned);
}
public function deleteShareFolderLink()
{
header('Content-Type: application/json');
self::requireAuth();
self::requireAdmin();
self::requireCsrf();
$token = $_POST['token'] ?? '';
if (!$token) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No token provided']);
return;
}
$deleted = FolderModel::deleteShareFolderLink($token);
if ($deleted) {
echo json_encode(['success' => true]);
} else {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Not found']);
}
}
public function getFolderColors(): void
{
header('Content-Type: application/json; charset=utf-8');
self::requireAuth();
$user = $_SESSION['username'] ?? '';
$perms = $this->loadPerms($user);
$map = FolderMeta::getMap();
$out = [];
foreach ($map as $folder => $hex) {
$folder = FolderMeta::normalizeFolder((string)$folder);
if ($folder === 'root') continue; // dont bother exposing root
if (ACL::canRead($user, $perms, $folder) || ACL::canReadOwn($user, $perms, $folder)) {
$out[$folder] = $hex;
}
}
echo json_encode($out, JSON_UNESCAPED_SLASHES);
}
public function saveFolderColor(): void
{
header('Content-Type: application/json; charset=utf-8');
self::requireAuth();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
return;
}
// CSRF
$hdr = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
$tok = $_SESSION['csrf_token'] ?? '';
if (!$hdr || !$tok || !hash_equals((string)$tok, (string)$hdr)) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token']);
return;
}
$user = $_SESSION['username'] ?? '';
$perms = $this->loadPerms($user);
$body = json_decode(file_get_contents('php://input') ?: "{}", true) ?: [];
$folder = FolderMeta::normalizeFolder((string)($body['folder'] ?? 'root'));
$raw = array_key_exists('color', $body) ? (string)$body['color'] : '';
if ($folder === 'root') {
http_response_code(400);
echo json_encode(['error' => 'Cannot set color on root']);
return;
}
// >>> Require canEdit (not canRename) <<<
if (!ACL::canEdit($user, $perms, $folder) && !ACL::isAdmin($perms)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
return;
}
try {
// empty string clears; non-empty must be valid #RGB or #RRGGBB
$hex = ($raw === '') ? null : FolderMeta::normalizeHex($raw);
$res = FolderMeta::setColor($folder, $hex);
echo json_encode(['success' => true] + $res, JSON_UNESCAPED_SLASHES);
} catch (\InvalidArgumentException $e) {
http_response_code(400);
echo json_encode(['error' => 'Invalid color']);
}
}
/* -------------------- API: Move Folder -------------------- */
public function moveFolder(): void
{
header('Content-Type: application/json; charset=utf-8');
self::requireAuth();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
return;
}
// CSRF: accept header or form field
$hdr = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
$tok = $_SESSION['csrf_token'] ?? '';
if (!$hdr || !$tok || !hash_equals((string)$tok, (string)$hdr)) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token']);
return;
}
$raw = file_get_contents('php://input');
$input = json_decode($raw ?: "{}", true);
$source = trim((string)($input['source'] ?? ''));
$destination = trim((string)($input['destination'] ?? ''));
if ($source === '' || strcasecmp($source, 'root') === 0) {
http_response_code(400);
echo json_encode(['error' => 'Invalid source folder']);
return;
}
if ($destination === '') $destination = 'root';
// basic segment validation
foreach ([$source, $destination] as $f) {
if ($f === 'root') continue;
$parts = array_filter(explode('/', trim($f, "/\\ ")), fn($p) => $p !== '');
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder segment']);
return;
}
}
}
$srcNorm = trim($source, "/\\ ");
$dstNorm = $destination === 'root' ? '' : trim($destination, "/\\ ");
// prevent move into self/descendant
if ($dstNorm !== '' && (strcasecmp($dstNorm, $srcNorm) === 0 || strpos($dstNorm . '/', $srcNorm . '/') === 0)) {
http_response_code(400);
echo json_encode(['error' => 'Destination cannot be the source or its descendant']);
return;
}
$username = $_SESSION['username'] ?? '';
$perms = $this->loadPerms($username);
// enforce scopes (source manage-ish, dest write-ish)
if ($msg = self::enforceFolderScope($source, $username, $perms, 'manage')) {
http_response_code(403);
echo json_encode(['error' => $msg]);
return;
}
if ($msg = self::enforceFolderScope($destination, $username, $perms, 'write')) {
http_response_code(403);
echo json_encode(['error' => $msg]);
return;
}
// Check capabilities using ACL helpers
$canManageSource = ACL::canManage($username, $perms, $source) || ACL::isOwner($username, $perms, $source);
$canMoveIntoDest = ACL::canMove($username, $perms, $destination) || ($destination === 'root' ? self::isAdmin($perms) : ACL::isOwner($username, $perms, $destination));
if (!$canManageSource) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: manage rights required on source']);
return;
}
if (!$canMoveIntoDest) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: move rights required on destination']);
return;
}
// Non-admin: enforce same owner between source and destination tree (if any)
$isAdmin = self::isAdmin($perms);
if (!$isAdmin) {
try {
$ownerSrc = FolderModel::getOwnerFor($source) ?? '';
$ownerDst = $destination === 'root' ? '' : (FolderModel::getOwnerFor($destination) ?? '');
if ($ownerSrc !== $ownerDst) {
http_response_code(403);
echo json_encode(['error' => 'Source and destination must have the same owner']);
return;
}
} catch (\Throwable $e) { /* ignore fall through */
}
}
// Compute final target "destination/basename(source)"
$baseName = basename(str_replace('\\', '/', $srcNorm));
$target = $destination === 'root' ? $baseName : rtrim($destination, "/\\ ") . '/' . $baseName;
try {
$result = FolderModel::renameFolder($source, $target);
$result = FolderModel::renameFolder($source, $target);
// migrate ACL subtree (best-effort; never block the move)
$aclStats = [];
try {
$aclStats = ACL::migrateSubtree($source, $target);
} catch (\Throwable $e) {
error_log('moveFolder ACL-migration warning: ' . $e->getMessage());
}
// If you already added color migration, just append this too:
$resultArr = is_array($result) ? $result : ['success' => true, 'target' => $target];
$resultArr['aclMigration'] = $aclStats + ['changed' => false, 'moved' => 0];
echo json_encode($resultArr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// If the move succeeded, migrate folder color mappings server-side
$colorStats = [];
if (is_array($result) && (!isset($result['success']) || $result['success'])) {
try {
$colorStats = self::migrateFolderColors($source, $target);
} catch (\Throwable $e) {
error_log('moveFolder color-migration warning: ' . $e->getMessage());
}
}
// merge stats into response (non-breaking)
if (is_array($result)) {
$result['colorMigration'] = $colorStats + ['changed' => false, 'moved' => 0];
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} else {
echo json_encode(['success' => true, 'target' => $target, 'colorMigration' => $colorStats + ['changed' => false, 'moved' => 0]], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
} catch (\Throwable $e) {
error_log('moveFolder error: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'Internal error moving folder']);
}
}
}