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,5 +1,69 @@
# Changelog # Changelog
## Changes 10/17/2025 (v1.5.0)
Security and permission model overhaul. Tightens access controls with explicit, serverside ACL checks across controllers and WebDAV. Introduces `read_own` for ownonly visibility and separates view from write so uploaders cant automatically see others files. Fixes session warnings and aligns the admin UI with the new capabilities.
> **Security note**
> This release contains security hardening based on a private report (tracked via a GitHub Security Advisory, CVE pending). For responsible disclosure, details will be published alongside the advisory once available. Users should upgrade promptly.
### Highlights
- **ACL**
- New `read_own` bucket (ownonly visibility) alongside `owners`, `read`, `write`, `share`.
- **Semantic change:** `write` no longer implies `read`.
- `ACL::applyUserGrantsAtomic()` to atomically set perfolder grants (`view`, `viewOwn`, `upload`, `manage`, `share`).
- `ACL::purgeUser($username)` to remove a user from all buckets (used when deleting a user).
- Autoheal `folder_acl.json` (ensure `root` exists; add missing buckets; dedupe; normalize types).
- More robust admin detection (role flag or session/admin user).
- **Controllers**
- `FileController`: ACL + ownership enforcement for list, download, zip download, extract, move, copy, rename, create, save, tag edit, and sharelink creation. `getFileList()` now filters to the callers uploads when they only have `read_own` (no `read`).
- `UploadController`: requires `ACL::canWrite()` for the target folder; CSRF refresh path improved; admin bypass intact.
- `FolderController`: listing filtered by `ACL::canRead()`; optional parent filter preserved; removed namebased ownership assumptions.
- **Admin UI**
- Folder Access grid now includes **View (own)**; bulk toolbar actions; column alignment fixes; more space for folder names; darkmode polish.
- **WebDAV**
- WebDAV now enforces ACL consistently: listing requires `read` (or `read_own` ⇒ shows only callers files); writes require `write`.
- Removed legacy “folderOnly” behavior — ACL is the single source of truth.
- Metadata/uploader is preserved through existing models.
### Behavior changes (⚠️ Breaking)
- **`write` no longer implies `read`.**
- If you want uploaders to see all files in a folder, also grant **View (all)** (`read`).
- If you want uploaders to see only their own files, grant **View (own)** (`read_own`).
- **Removed:** legacy `folderOnly` view logic in favor of ACLbased access.
### Upgrade checklist
1. Review **Folder Access** in the admin UI and grant **View (all)** or **View (own)** where appropriate.
2. For users who previously had “upload but not view,” confirm they now have **Upload** + **View (own)** (or add **View (all)** if intended).
3. Verify WebDAV behavior for representative users:
- `read` shows full listings; `read_own` lists only the callers files.
- Writes only succeed where `write` is granted.
4. Confirm admin can upload/move/zip across all folders (regression tested).
### Affected areas
- `config/config.php` — session/cookie initialization ordering; proxy header handling.
- `src/lib/ACL.php` — new bucket, semantics, healing, purge, admin detection.
- `src/controllers/FileController.php` — ACL + ownership gates across operations.
- `src/controllers/UploadController.php` — write checks + CSRF refresh handling.
- `src/controllers/FolderController.php` — ACLfiltered listing and parent scoping.
- `public/api/admin/acl/*.php` — includes `viewOwn` roundtrip and sanitization.
- `public/js/*` & CSS — folder access grid alignment and layout fixes.
- `src/webdav/*` & `public/webdav.php` — ACLaware WebDAV server.
### Credits
- Security report acknowledged privately and will be credited in the published advisory.
---
## Changes 10/15/2025 (v1.4.0) ## Changes 10/15/2025 (v1.4.0)
feat(permissions)!: granular ACL (bypassOwnership/canShare/canZip/viewOwnOnly), admin panel v1.4.0 UI, and broad hardening across controllers/models/frontend feat(permissions)!: granular ACL (bypassOwnership/canShare/canZip/viewOwnOnly), admin panel v1.4.0 UI, and broad hardening across controllers/models/frontend

View File

@@ -6,8 +6,8 @@ We provide security fixes for the latest minor release line.
| Version | Supported | | Version | Supported |
|------------|-----------| |------------|-----------|
| v1.4.x | ✅ | | v1.5.x | ✅ |
| < v1.4.0 | | | < v1.5.0 | |
## Reporting a Vulnerability ## Reporting a Vulnerability

View File

@@ -35,13 +35,11 @@ define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
date_default_timezone_set(TIMEZONE); date_default_timezone_set(TIMEZONE);
if (!defined('DEFAULT_BYPASS_OWNERSHIP')) define('DEFAULT_BYPASS_OWNERSHIP', false); if (!defined('DEFAULT_BYPASS_OWNERSHIP')) define('DEFAULT_BYPASS_OWNERSHIP', false);
if (!defined('DEFAULT_CAN_SHARE')) define('DEFAULT_CAN_SHARE', true); if (!defined('DEFAULT_CAN_SHARE')) define('DEFAULT_CAN_SHARE', true);
if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true); if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false); if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
// Encryption helpers // Encryption helpers
function encryptData($data, $encryptionKey) function encryptData($data, $encryptionKey)
@@ -77,18 +75,29 @@ function loadUserPermissions($username)
{ {
global $encryptionKey; global $encryptionKey;
$permissionsFile = USERS_DIR . 'userPermissions.json'; $permissionsFile = USERS_DIR . 'userPermissions.json';
if (file_exists($permissionsFile)) { if (!file_exists($permissionsFile)) {
return false;
}
$content = file_get_contents($permissionsFile); $content = file_get_contents($permissionsFile);
$decrypted = decryptData($content, $encryptionKey); $decrypted = decryptData($content, $encryptionKey);
$json = ($decrypted !== false) ? $decrypted : $content; $json = ($decrypted !== false) ? $decrypted : $content;
$perms = json_decode($json, true); $permsAll = json_decode($json, true);
if (is_array($perms) && isset($perms[$username])) {
return !empty($perms[$username]) ? $perms[$username] : false; if (!is_array($permsAll)) {
}
}
return false; return false;
} }
// Try exact match first, then lowercase (since we store keys lowercase elsewhere)
$uExact = (string)$username;
$uLower = strtolower($uExact);
$row = $permsAll[$uExact] ?? $permsAll[$uLower] ?? null;
// Normalize: always return an array when found, else false (to preserve current callers behavior)
return is_array($row) ? $row : false;
}
// Determine HTTPS usage // Determine HTTPS usage
$envSecure = getenv('SECURE'); $envSecure = getenv('SECURE');
$secure = ($envSecure !== false) $secure = ($envSecure !== false)
@@ -98,11 +107,14 @@ $secure = ($envSecure !== false)
// Choose session lifetime based on "remember me" cookie // Choose session lifetime based on "remember me" cookie
$defaultSession = 7200; // 2 hours $defaultSession = 7200; // 2 hours
$persistentDays = 30 * 24 * 60 * 60; // 30 days $persistentDays = 30 * 24 * 60 * 60; // 30 days
$sessionLifetime = isset($_COOKIE['remember_me_token']) $sessionLifetime = isset($_COOKIE['remember_me_token']) ? $persistentDays : $defaultSession;
? $persistentDays
: $defaultSession;
// Configure PHP session cookie and GC /**
* Start session idempotently:
* - If no session: set cookie params + gc_maxlifetime, then session_start().
* - If session already active: DO NOT change ini/cookie params; optionally refresh cookie expiry.
*/
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params([ session_set_cookie_params([
'lifetime' => $sessionLifetime, 'lifetime' => $sessionLifetime,
'path' => '/', 'path' => '/',
@@ -112,9 +124,20 @@ session_set_cookie_params([
'samesite' => 'Lax' 'samesite' => 'Lax'
]); ]);
ini_set('session.gc_maxlifetime', (string)$sessionLifetime); ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
if (session_status() === PHP_SESSION_NONE) {
session_start(); session_start();
} else {
// Optionally refresh the session cookie expiry to keep the user alive
$params = session_get_cookie_params();
if ($sessionLifetime > 0) {
setcookie(session_name(), session_id(), [
'expires' => time() + $sessionLifetime,
'path' => $params['path'] ?: '/',
'domain' => $params['domain'] ?? '',
'secure' => $secure,
'httponly' => true,
'samesite' => $params['samesite'] ?? 'Lax',
]);
}
} }
// CSRF token // CSRF token
@@ -122,8 +145,7 @@ if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
} }
// Auto-login via persistent token
// Autologin via persistent token
if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) { if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) {
$tokFile = USERS_DIR . 'persistent_tokens.json'; $tokFile = USERS_DIR . 'persistent_tokens.json';
$tokens = []; $tokens = [];

View File

@@ -0,0 +1,79 @@
<?php
// public/api/admin/acl/getGrants.php
declare(strict_types=1);
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json');
// Admin only
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit;
}
$user = trim((string)($_GET['user'] ?? ''));
if ($user === '' || !preg_match(REGEX_USER, $user)) {
http_response_code(400); echo json_encode(['error'=>'Invalid user']); exit;
}
// Build the folder list (admin sees all)
$folders = [];
try {
$rows = FolderModel::getFolderList();
if (is_array($rows)) {
foreach ($rows as $r) {
$f = is_array($r) ? ($r['folder'] ?? '') : (string)$r;
if ($f !== '') $folders[$f] = true;
}
}
} catch (Throwable $e) { /* ignore */ }
if (empty($folders)) {
$aclPath = META_DIR . 'folder_acl.json';
if (is_file($aclPath)) {
$data = json_decode((string)@file_get_contents($aclPath), true);
if (is_array($data['folders'] ?? null)) {
foreach ($data['folders'] as $name => $_) $folders[$name] = true;
}
}
}
$folderList = array_keys($folders);
if (!in_array('root', $folderList, true)) array_unshift($folderList, 'root');
$has = function(array $arr, string $u): bool {
foreach ($arr as $x) if (strcasecmp((string)$x, $u) === 0) return true;
return false;
};
$out = [];
foreach ($folderList as $f) {
$rec = ACL::explicit($f); // owners, read, write, share, read_own
$isOwner = $has($rec['owners'], $user);
$canUpload = $isOwner || $has($rec['write'], $user);
// IMPORTANT: full view only if owner or explicit read
$canViewAll = $isOwner || $has($rec['read'], $user);
// own-only view reflects explicit read_own (we keep it separate even if they have full view)
$canViewOwn = $has($rec['read_own'], $user);
// Share only if owner or explicit share
$canShare = $isOwner || $has($rec['share'], $user);
if ($canViewAll || $canViewOwn || $canUpload || $isOwner || $canShare) {
$out[$f] = [
'view' => $canViewAll,
'viewOwn' => $canViewOwn,
'upload' => $canUpload,
'manage' => $isOwner,
'share' => $canShare,
];
}
}
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);

View File

@@ -0,0 +1,105 @@
<?php
// public/api/admin/acl/saveGrants.php
declare(strict_types=1);
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json');
// ---- Auth + CSRF -----------------------------------------------------------
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
$headers = function_exists('getallheaders') ? array_change_key_case(getallheaders(), CASE_LOWER) : [];
$csrf = trim($headers['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token']);
exit;
}
// ---- Helpers ---------------------------------------------------------------
/**
* Sanitize a grants map to allowed flags only:
* view | viewOwn | upload | manage | share
*/
function sanitize_grants_map(array $grants): array {
$allowed = ['view','viewOwn','upload','manage','share'];
$out = [];
foreach ($grants as $folder => $caps) {
if (!is_string($folder)) $folder = (string)$folder;
if (!is_array($caps)) $caps = [];
$row = [];
foreach ($allowed as $k) {
$row[$k] = !empty($caps[$k]);
}
// include folder even if all false (signals "remove all for this user on this folder")
$out[$folder] = $row;
}
return $out;
}
function valid_user(string $u): bool {
return ($u !== '' && preg_match(REGEX_USER, $u));
}
// ---- Read JSON body --------------------------------------------------------
$raw = file_get_contents('php://input');
$in = json_decode((string)$raw, true);
if (!is_array($in)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON']);
exit;
}
// ---- Single user mode: { user, grants } ------------------------------------
if (isset($in['user']) && isset($in['grants']) && is_array($in['grants'])) {
$user = trim((string)$in['user']);
if (!valid_user($user)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid user']);
exit;
}
$grants = sanitize_grants_map($in['grants']);
try {
$res = ACL::applyUserGrantsAtomic($user, $grants);
echo json_encode($res, JSON_UNESCAPED_SLASHES);
exit;
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]);
exit;
}
}
// ---- Batch mode: { changes: [ { user, grants }, ... ] } --------------------
if (isset($in['changes']) && is_array($in['changes'])) {
$updated = [];
foreach ($in['changes'] as $chg) {
if (!is_array($chg)) continue;
$user = trim((string)($chg['user'] ?? ''));
$gr = $chg['grants'] ?? null;
if (!valid_user($user) || !is_array($gr)) continue;
try {
$res = ACL::applyUserGrantsAtomic($user, sanitize_grants_map($gr));
$updated[$user] = $res['updated'] ?? [];
} catch (Throwable $e) {
$updated[$user] = ['error' => $e->getMessage()];
}
}
echo json_encode(['ok' => true, 'updated' => $updated], JSON_UNESCAPED_SLASHES);
exit;
}
// ---- Fallback --------------------------------------------------------------
http_response_code(400);
echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']);

View File

@@ -0,0 +1,120 @@
<?php
// public/api/folder/capabilities.php
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
header('Content-Type: application/json');
// --- auth ---
$username = $_SESSION['username'] ?? '';
if ($username === '') {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
// --- helpers ---
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 [];
}
function isAdminUser(string $u, array $perms): bool {
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
if (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) return true;
$role = $_SESSION['role'] ?? null;
if ($role === 'admin' || $role === '1' || $role === 1) return true;
if ($u) {
$r = userModel::getUserRole($u);
if ($r === '1') return true;
}
return false;
}
function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool {
if ($isAdmin) return true;
$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
if (!$folderOnly) return true;
$f = trim($folder);
if ($f === '' || strcasecmp($f, 'root') === 0) return false; // non-admin folderOnly: not root
return ($f === $u) || (strpos($f, $u . '/') === 0);
}
// --- inputs ---
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
// validate folder path: allow "root" or nested segments matching REGEX_FOLDER_NAME
if ($folder !== 'root') {
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
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;
}
}
$folder = implode('/', $parts);
}
$perms = loadPermsFor($username);
$isAdmin = isAdminUser($username, $perms);
// base permissions via ACL
$canRead = $isAdmin || ACL::canRead($username, $perms, $folder);
$canWrite = $isAdmin || ACL::canWrite($username, $perms, $folder);
$canShare = $isAdmin || ACL::canShare($username, $perms, $folder);
// scope + flags
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
$readOnly = !empty($perms['readOnly']);
$disableUpload = !empty($perms['disableUpload']);
$canUpload = $canWrite && !$readOnly && !$disableUpload && $inScope;
$canCreateFolder = $canWrite && !$readOnly && $inScope;
$canRename = $canWrite && !$readOnly && $inScope;
$canDelete = $canWrite && !$readOnly && $inScope;
$canMoveIn = $canWrite && !$readOnly && $inScope;
// (optional) owner info if you need it client-side
$owner = FolderModel::getOwnerFor($folder);
// output
echo json_encode([
'user' => $username,
'folder' => $folder,
'isAdmin' => $isAdmin,
'flags' => [
'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
'readOnly' => $readOnly,
'disableUpload' => $disableUpload,
],
'owner' => $owner,
'canView' => $canRead,
'canUpload' => $canUpload,
'canCreate' => $canCreateFolder,
'canRename' => $canRename,
'canDelete' => $canDelete,
'canMoveIn' => $canMoveIn,
'canShare' => $canShare,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

View File

@@ -1,9 +1,10 @@
// adminPanel.js
import { t } from './i18n.js'; import { t } from './i18n.js';
import { loadAdminConfigFunc } from './auth.js'; import { loadAdminConfigFunc } from './auth.js';
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js'; import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
import { sendRequest } from './networkUtils.js'; import { sendRequest } from './networkUtils.js';
const version = "v1.4.0"; const version = "v1.5.0";
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`; const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
// Translate with fallback: if t(key) just echos the key, use a readable string. // Translate with fallback: if t(key) just echos the key, use a readable string.
@@ -12,6 +13,23 @@ const tf = (key, fallback) => {
return (v && v !== key) ? v : fallback; return (v && v !== key) ? v : fallback;
}; };
// --- tiny robust JSON helper ---
async function safeJson(res) {
const text = await res.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { /* ignore */ }
if (!res.ok) {
const msg =
(body && (body.error || body.message)) ||
(text && text.trim()) ||
`HTTP ${res.status}`;
const err = new Error(msg);
err.status = res.status;
throw err;
}
return body ?? {};
}
// ————— Inject updated styles ————— // ————— Inject updated styles —————
(function () { (function () {
if (document.getElementById('adminPanelStyles')) return; if (document.getElementById('adminPanelStyles')) return;
@@ -22,8 +40,10 @@ const tf = (key, fallback) => {
#adminPanelModal .modal-content { #adminPanelModal .modal-content {
max-width: 1100px; max-width: 1100px;
width: 50%; width: 50%;
background: #fff !important;
color: #000 !important;
border: 1px solid #ccc !important;
} }
/* Small phones: 90% width */ /* Small phones: 90% width */
@media (max-width: 900px) { @media (max-width: 900px) {
#adminPanelModal .modal-content { #adminPanelModal .modal-content {
@@ -31,91 +51,96 @@ const tf = (key, fallback) => {
max-width: none !important; max-width: none !important;
} }
} }
/* Dark-mode fixes */ /* Dark-mode fixes */
body.dark-mode #adminPanelModal .modal-content { body.dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; }
border-color: #555 !important; body.dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; }
}
/* enforce lightmode styling */
#adminPanelModal .modal-content {
max-width: 1100px;
width: 50%;
background: #fff !important;
color: #000 !important;
border: 1px solid #ccc !important;
}
/* enforce darkmode styling */
body.dark-mode #adminPanelModal .modal-content {
background: #2c2c2c !important;
color: #e0e0e0 !important;
border-color: #555 !important;
}
/* form controls in dark */
body.dark-mode .form-control {
background-color: #333;
border-color: #555;
color: #eee;
}
body.dark-mode .form-control::placeholder { color:#888; } body.dark-mode .form-control::placeholder { color:#888; }
/* Section headers */ /* Section headers */
.section-header { .section-header {
background: #f5f5f5; background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:4px; font-weight:bold;
padding: 10px 15px; display:flex; align-items:center; justify-content:space-between; margin-top:16px;
cursor: pointer;
border-radius: 4px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
} }
.section-header:first-of-type { margin-top:0; } .section-header:first-of-type { margin-top:0; }
.section-header.collapsed .material-icons { transform:rotate(-90deg); } .section-header.collapsed .material-icons { transform:rotate(-90deg); }
.section-header .material-icons { transition:transform .3s; color:#444; } .section-header .material-icons { transition:transform .3s; color:#444; }
body.dark-mode .section-header { background:#3a3a3a; color:#eee; }
body.dark-mode .section-header {
background: #3a3a3a;
color: #eee;
}
body.dark-mode .section-header .material-icons { color:#ccc; } body.dark-mode .section-header .material-icons { color:#ccc; }
/* Hidden by default */ /* Hidden by default */
.section-content { .section-content { display:none; margin-left:20px; margin-top:8px; margin-bottom:8px; }
display: none;
margin-left: 20px;
margin-top: 8px;
margin-bottom: 8px;
}
/* Close button */ /* Close button */
#adminPanelModal .editor-close-btn { #adminPanelModal .editor-close-btn {
position: absolute; top:10px; right:10px; position:absolute; top:10px; right:10px; display:flex; align-items:center; justify-content:center;
display:flex; align-items:center; justify-content:center; font-size:20px; font-weight:bold; cursor:pointer; z-index:1000; width:32px; height:32px; border-radius:50%;
font-size:20px; font-weight:bold; cursor:pointer; text-align:center; line-height:30px; color:#ff4d4d; background:rgba(255,255,255,0.9);
z-index:1000; width:32px; height:32px; border-radius:50%;
text-align:center; line-height:30px;
color:#ff4d4d; background:rgba(255,255,255,0.9);
border:2px solid transparent; transition:all .3s; border:2px solid transparent; transition:all .3s;
} }
#adminPanelModal .editor-close-btn:hover { #adminPanelModal .editor-close-btn:hover { color:#fff; background:#ff4d4d; box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05); }
color:white; background:#ff4d4d; body.dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; }
box-shadow:0 0 6px rgba(255,77,77,.8);
transform:scale(1.05);
}
body.dark-mode #adminPanelModal .editor-close-btn {
background:rgba(0,0,0,0.6);
color:#ff4d4d;
}
/* Action-row */ /* Action-row */
.action-row { .action-row { display:flex; justify-content:space-between; margin-top:15px; }
display:flex;
justify-content:space-between; /* ---------- Folder access editor ---------- */
margin-top:15px; .folder-access-toolbar {
display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0 6px;
}
/* Scroll area (header lives inside, sticky) */
.folder-access-list {
--col-perm: 84px; /* width of each permission column */
--col-folder-min: 340px; /* min width for folder names */
max-height: 320px;
overflow: auto;
border: 1px solid #ccc;
border-radius: 6px;
padding: 0; /* no inner padding to keep grid aligned */
}
body.dark-mode .folder-access-list { border-color:#555; }
/* Shared grid for header + rows (MUST match) */
.folder-access-header,
.folder-access-row {
display: grid;
grid-template-columns: minmax(var(--col-folder-min), 1fr) repeat(5, var(--col-perm));
gap: 8px;
align-items: center;
padding: 8px 10px;
}
/* Sticky header so it always aligns with the rows under the same scrollbar */
.folder-access-header {
position: sticky;
top: 0;
z-index: 2;
background: #fff;
font-weight: 700;
border-bottom: 1px solid rgba(0,0,0,0.12);
}
body.dark-mode .folder-access-header { background:#2c2c2c; }
/* Rows */
.folder-access-row { border-bottom: 1px solid rgba(0,0,0,0.06); }
.folder-access-row:last-child { border-bottom: none; }
/* Columns */
.perm-col { text-align:center; white-space:nowrap; }
.folder-access-header > div { white-space: nowrap; }
/* Folder label: show more of the path, ellipsis if needed */
.folder-badge {
display:inline-flex; align-items:center; gap:6px;
font-weight:600; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
min-width: 0; /* allow ellipsis in grid */
}
.muted { opacity:.65; font-size:.9em; }
/* Tighter on small screens */
@media (max-width: 900px) {
.folder-access-list { --col-perm: 72px; --col-folder-min: 240px; }
} }
`; `;
document.head.appendChild(style); document.head.appendChild(style);
@@ -179,7 +204,6 @@ function toggleSection(id) {
const hdr = document.getElementById(id + "Header"); const hdr = document.getElementById(id + "Header");
const cnt = document.getElementById(id + "Content"); const cnt = document.getElementById(id + "Content");
const isCollapsedNow = hdr.classList.toggle("collapsed"); const isCollapsedNow = hdr.classList.toggle("collapsed");
// collapsed class present => hide; absent => show
cnt.style.display = isCollapsedNow ? "none" : "block"; cnt.style.display = isCollapsedNow ? "none" : "block";
if (!isCollapsedNow && id === "shareLinks") { if (!isCollapsedNow && id === "shareLinks") {
loadShareLinksSection(); loadShareLinksSection();
@@ -190,23 +214,12 @@ function loadShareLinksSection() {
const container = document.getElementById("shareLinksContent"); const container = document.getElementById("shareLinksContent");
container.textContent = t("loading") + "..."; container.textContent = t("loading") + "...";
// helper: fetch one metadata file, but never throw —
// on non-2xx (including 404) or network error, resolve to {}
function fetchMeta(fileName) { function fetchMeta(fileName) {
return fetch(`/api/admin/readMetadata.php?file=${encodeURIComponent(fileName)}`, { return fetch(`/api/admin/readMetadata.php?file=${encodeURIComponent(fileName)}`, {
credentials: "include" credentials: "include"
}) })
.then(resp => { .then(resp => resp.ok ? resp.json() : {})
if (!resp.ok) { .catch(() => ({}));
// 404 or any other non-OK → treat as empty
return {};
}
return resp.json();
})
.catch(() => {
// network failure, parse error, etc → also empty
return {};
});
} }
Promise.all([ Promise.all([
@@ -214,7 +227,6 @@ function loadShareLinksSection() {
fetchMeta("share_links.json") fetchMeta("share_links.json")
]) ])
.then(([folders, files]) => { .then(([folders, files]) => {
// if *both* are empty, show "no shared links"
const hasAny = Object.keys(folders).length || Object.keys(files).length; const hasAny = Object.keys(folders).length || Object.keys(files).length;
if (!hasAny) { if (!hasAny) {
container.innerHTML = `<p>${t("no_shared_links_available")}</p>`; container.innerHTML = `<p>${t("no_shared_links_available")}</p>`;
@@ -252,7 +264,6 @@ function loadShareLinksSection() {
container.innerHTML = html; container.innerHTML = html;
// wire up delete buttons
container.querySelectorAll(".delete-share").forEach(btn => { container.querySelectorAll(".delete-share").forEach(btn => {
btn.addEventListener("click", evt => { btn.addEventListener("click", evt => {
evt.preventDefault(); evt.preventDefault();
@@ -268,10 +279,7 @@ function loadShareLinksSection() {
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token }) body: new URLSearchParams({ token })
}) })
.then(res => { .then(res => res.ok ? res.json() : Promise.reject(res))
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(json => { .then(json => {
if (json.success) { if (json.success) {
showToast(t("share_deleted_successfully")); showToast(t("share_deleted_successfully"));
@@ -293,12 +301,10 @@ function loadShareLinksSection() {
}); });
} }
export function openAdminPanel() { export function openAdminPanel() {
fetch("/api/admin/getConfig.php", { credentials: "include" }) fetch("/api/admin/getConfig.php", { credentials: "include" })
.then(r => r.json()) .then(r => r.json())
.then(config => { .then(config => {
// apply header title + globals
if (config.header_title) { if (config.header_title) {
document.querySelector(".header-title h1").textContent = config.header_title; document.querySelector(".header-title h1").textContent = config.header_title;
window.headerTitle = config.header_title; window.headerTitle = config.header_title;
@@ -333,8 +339,6 @@ export function openAdminPanel() {
<div class="editor-close-btn" id="closeAdminPanel">&times;</div> <div class="editor-close-btn" id="closeAdminPanel">&times;</div>
<h3>${adminTitle}</h3> <h3>${adminTitle}</h3>
<form id="adminPanelForm"> <form id="adminPanelForm">
<!-- each section: header + content -->
${[ ${[
{ id: "userManagement", label: t("user_management") }, { id: "userManagement", label: t("user_management") },
{ id: "headerSettings", label: t("header_settings") }, { id: "headerSettings", label: t("header_settings") },
@@ -372,13 +376,15 @@ export function openAdminPanel() {
.addEventListener("click", () => toggleSection(id)); .addEventListener("click", () => toggleSection(id));
}); });
// Populate each sections CONTENT:
// — User Mgmt — // — User Mgmt —
document.getElementById("userManagementContent").innerHTML = ` document.getElementById("userManagementContent").innerHTML = `
<button type="button" id="adminOpenAddUser" class="btn btn-success me-2">${t("add_user")}</button> <button type="button" id="adminOpenAddUser" class="btn btn-success me-2">${t("add_user")}</button>
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger me-2">${t("remove_user")}</button> <button type="button" id="adminOpenRemoveUser" class="btn btn-danger me-2">${t("remove_user")}</button>
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">${t("user_permissions")}</button> <button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">${tf("folder_access", "Folder Access")}</button>
<button type="button" id="adminOpenUserFlags" class="btn btn-secondary">${tf("user_permissions", "User Permissions")}</button>
`; `;
document.getElementById("adminOpenAddUser") document.getElementById("adminOpenAddUser")
.addEventListener("click", () => { .addEventListener("click", () => {
toggleVisibility("addUserModal", true); toggleVisibility("addUserModal", true);
@@ -458,7 +464,6 @@ export function openAdminPanel() {
} }
}); });
}); });
// If authBypass is checked, clear the other three
document.getElementById("authBypass").addEventListener("change", e => { document.getElementById("authBypass").addEventListener("change", e => {
if (e.target.checked) { if (e.target.checked) {
["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"] ["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"]
@@ -466,6 +471,27 @@ export function openAdminPanel() {
} }
}); });
// after you set #userManagementContent.innerHTML (right after those three buttons are inserted)
const userMgmt = document.getElementById("userManagementContent");
// defensive: remove any old listener first
userMgmt?.removeEventListener("click", window.__userMgmtDelegatedClick);
window.__userMgmtDelegatedClick = (e) => {
const flagsBtn = e.target.closest("#adminOpenUserFlags");
if (flagsBtn) {
e.preventDefault();
openUserFlagsModal();
}
const folderBtn = e.target.closest("#adminOpenUserPermissions");
if (folderBtn) {
e.preventDefault();
openUserPermissionsModal();
}
};
userMgmt?.addEventListener("click", window.__userMgmtDelegatedClick);
// Initialize inputs from config + capture // Initialize inputs from config + capture
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
@@ -479,7 +505,6 @@ export function openAdminPanel() {
} else { } else {
// modal already exists → just refresh values & re-show // modal already exists → just refresh values & re-show
mdl.style.display = "flex"; mdl.style.display = "flex";
// update dark/light as above...
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true; document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
@@ -533,9 +558,7 @@ function handleSave() {
enableWebDAV: eWD, enableWebDAV: eWD,
sharedMaxUploadSize: sMax, sharedMaxUploadSize: sMax,
globalOtpauthUrl: gURL globalOtpauthUrl: gURL
}, { }, { "X-CSRF-Token": window.csrfToken })
"X-CSRF-Token": window.csrfToken
})
.then(res => { .then(res => {
if (res.success) { if (res.success) {
showToast(t("settings_updated_successfully"), "success"); showToast(t("settings_updated_successfully"), "success");
@@ -556,7 +579,223 @@ export async function closeAdminPanel() {
document.getElementById("adminPanelModal").style.display = "none"; document.getElementById("adminPanelModal").style.display = "none";
} }
// --- New: User Permissions Modal --- /* ===========================
New: Folder Access (ACL) UI
=========================== */
let __allFoldersCache = null; // array of folder strings
async function getAllFolders() {
if (__allFoldersCache) return __allFoldersCache.slice();
const res = await fetch('/api/folder/getFolderList.php', { credentials: 'include' });
const data = await safeJson(res).catch(() => []);
const list = Array.isArray(data)
? data.map(x => (typeof x === 'string' ? x : x.folder)).filter(Boolean)
: [];
// Keep "root" first, hide special internal ones
const hidden = new Set(["profile_pics", "trash"]);
const cleaned = list
.filter(f => f && !hidden.has(f.toLowerCase()))
.sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b)));
__allFoldersCache = cleaned;
return cleaned.slice();
}
async function getUserGrants(username) {
const res = await fetch(`/api/admin/acl/getGrants.php?user=${encodeURIComponent(username)}`, {
credentials: 'include'
});
const data = await safeJson(res).catch(() => ({}));
// expected: { grants: { "folder/name": {view,upload,manage,share}, ... } }
return (data && data.grants) ? data.grants : {};
}
function renderFolderGrantsUI(username, container, folders, grants) {
container.innerHTML = "";
// toolbar
const toolbar = document.createElement('div');
toolbar.className = 'folder-access-toolbar';
toolbar.innerHTML = `
<input type="text" class="form-control" style="max-width:220px;" placeholder="${tf('search_folders', 'Search folders')}" />
<label class="muted"><input type="checkbox" data-bulk="view" /> ${tf('view_all','View (all)')}</label>
<label class="muted"><input type="checkbox" data-bulk="viewOwn" /> ${tf('view_own','View (own)')}</label>
<label class="muted"><input type="checkbox" data-bulk="upload" /> ${tf('upload','Upload')}</label>
<label class="muted"><input type="checkbox" data-bulk="manage" /> ${tf('manage','Manage')}</label>
<label class="muted"><input type="checkbox" data-bulk="share" /> ${tf('share','Share')}</label>
<span class="muted">(${tf('applies_to_filtered','applies to filtered list')})</span>
`;
container.appendChild(toolbar);
// list (will contain sticky header + rows)
const list = document.createElement('div');
list.className = 'folder-access-list';
container.appendChild(list);
const headerHtml = `
<div class="folder-access-header">
<div>${tf('folder', 'Folder')}</div>
<div class="perm-col">${tf('view_all','View (all)')}</div>
<div class="perm-col">${tf('view_own','View (own)')}</div>
<div class="perm-col">${tf('upload','Upload')}</div>
<div class="perm-col">${tf('manage','Manage')}</div>
<div class="perm-col">${tf('share','Share')}</div>
</div>
`;
function rowHtml(folder) {
const g = grants[folder] || {};
const name = folder === 'root' ? '(Root)' : folder;
return `
<div class="folder-access-row" data-folder="${folder}">
<div class="folder-badge"><i class="material-icons" style="font-size:18px;">folder</i>${name}</div>
<div class="perm-col"><input type="checkbox" data-cap="view" ${g.view ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="viewOwn" ${g.viewOwn ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="upload" ${g.upload ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="manage" ${g.manage ? 'checked' : ''}></div>
<div class="perm-col"><input type="checkbox" data-cap="share" ${g.share ? 'checked' : ''}></div>
</div>
`;
}
// Dependencies
function applyDeps(row) {
const cbView = row.querySelector('input[data-cap="view"]');
const cbViewOwn = row.querySelector('input[data-cap="viewOwn"]');
const cbUpload = row.querySelector('input[data-cap="upload"]');
const cbManage = row.querySelector('input[data-cap="manage"]');
const cbShare = row.querySelector('input[data-cap="share"]');
// Manage ⇒ full view + upload + share
if (cbManage.checked) {
cbView.checked = true;
cbUpload.checked = true;
cbShare.checked = true;
}
// Share ⇒ full view
if (cbShare.checked) cbView.checked = true;
// Upload ⇒ at least own view
if (cbUpload.checked && !cbView.checked && !cbViewOwn.checked) {
cbViewOwn.checked = true;
}
// Full view supersedes own-only
if (cbView.checked || cbManage.checked) {
cbViewOwn.checked = false;
cbViewOwn.disabled = true;
cbViewOwn.title = tf('full_view_supersedes_own','Full view supersedes own-only');
} else {
cbViewOwn.disabled = false;
cbViewOwn.removeAttribute('title');
}
// Owners can always share (UI hint only)
if (cbManage.checked) {
cbShare.disabled = true;
cbShare.title = tf('owners_can_always_share', 'Owners can always share');
} else {
cbShare.disabled = false;
cbShare.removeAttribute('title');
}
}
function wireRow(row) {
const cbView = row.querySelector('input[data-cap="view"]');
const cbViewOwn = row.querySelector('input[data-cap="viewOwn"]');
const cbUpload = row.querySelector('input[data-cap="upload"]');
const cbManage = row.querySelector('input[data-cap="manage"]');
const cbShare = row.querySelector('input[data-cap="share"]');
cbUpload.addEventListener('change', () => applyDeps(row));
cbShare .addEventListener('change', () => applyDeps(row));
cbManage.addEventListener('change', () => applyDeps(row));
cbView.addEventListener('change', () => {
if (!cbView.checked) { cbManage.checked = false; cbShare.checked = false; }
applyDeps(row);
});
cbViewOwn.addEventListener('change', () => applyDeps(row));
applyDeps(row);
}
function render(filter = "") {
const f = filter.trim().toLowerCase();
const rowsHtml = folders
.filter(x => !f || x.toLowerCase().includes(f))
.map(rowHtml)
.join("");
list.innerHTML = headerHtml + rowsHtml;
list.querySelectorAll('.folder-access-row').forEach(wireRow);
}
// initial render + filter wire-up
render();
const filterInput = toolbar.querySelector('input[type="text"]');
filterInput.addEventListener('input', () => render(filterInput.value));
// bulk toggles
toolbar.querySelectorAll('input[type="checkbox"][data-bulk]').forEach(bulk => {
bulk.addEventListener('change', () => {
const which = bulk.dataset.bulk;
const f = (filterInput.value || "").trim().toLowerCase();
list.querySelectorAll('.folder-access-row').forEach(row => {
const folder = row.dataset.folder || "";
if (f && !folder.toLowerCase().includes(f)) return;
const target = row.querySelector(`input[data-cap="${which}"]`);
if (!target) return;
target.checked = bulk.checked;
// simple implications for bulk; detailed state handled by applyDeps
if (which === 'manage' && bulk.checked) {
row.querySelector('input[data-cap="view"]').checked = true;
row.querySelector('input[data-cap="upload"]').checked = true;
row.querySelector('input[data-cap="share"]').checked = true;
}
if (which === 'share' && bulk.checked) {
row.querySelector('input[data-cap="view"]').checked = true;
}
if (which === 'upload' && bulk.checked) {
const v = row.querySelector('input[data-cap="view"]');
const vo = row.querySelector('input[data-cap="viewOwn"]');
if (!v.checked && !vo.checked) vo.checked = true;
}
if (which === 'view' && !bulk.checked) {
row.querySelector('input[data-cap="manage"]').checked = false;
row.querySelector('input[data-cap="share"]').checked = false;
}
applyDeps(row);
});
});
});
}
// Collect grants from a user's UI
function collectGrantsFrom(container) {
const out = {};
container.querySelectorAll('.folder-access-row').forEach(row => {
const folder = row.dataset.folder;
if (!folder) return;
const g = {
view: row.querySelector('input[data-cap="view"]').checked,
viewOwn: row.querySelector('input[data-cap="viewOwn"]').checked,
upload: row.querySelector('input[data-cap="upload"]').checked,
manage: row.querySelector('input[data-cap="manage"]').checked,
share: row.querySelector('input[data-cap="share"]').checked
};
if (g.view || g.viewOwn || g.upload || g.manage || g.share) out[folder] = g;
});
return out;
}
// --- New: User Permissions (Folder Access) Modal ---
export function openUserPermissionsModal() { export function openUserPermissionsModal() {
let userPermissionsModal = document.getElementById("userPermissionsModal"); let userPermissionsModal = document.getElementById("userPermissionsModal");
const isDarkMode = document.body.classList.contains("dark-mode"); const isDarkMode = document.body.classList.contains("dark-mode");
@@ -565,8 +804,8 @@ export function openUserPermissionsModal() {
background: ${isDarkMode ? "#2c2c2c" : "#fff"}; background: ${isDarkMode ? "#2c2c2c" : "#fff"};
color: ${isDarkMode ? "#e0e0e0" : "#000"}; color: ${isDarkMode ? "#e0e0e0" : "#000"};
padding: 20px; padding: 20px;
max-width: 500px; max-width: 780px;
width: 90%; width: 95%;
border-radius: 8px; border-radius: 8px;
position: relative; position: relative;
`; `;
@@ -576,22 +815,20 @@ export function openUserPermissionsModal() {
userPermissionsModal.id = "userPermissionsModal"; userPermissionsModal.id = "userPermissionsModal";
userPermissionsModal.style.cssText = ` userPermissionsModal.style.cssText = `
position: fixed; position: fixed;
top: 0; top: 0; left: 0; width: 100vw; height: 100vh;
left: 0;
width: 100vw;
height: 100vh;
background-color: ${overlayBackground}; background-color: ${overlayBackground};
display: flex; display: flex; justify-content: center; align-items: center;
justify-content: center;
align-items: center;
z-index: 3500; z-index: 3500;
`; `;
userPermissionsModal.innerHTML = ` userPermissionsModal.innerHTML = `
<div class="modal-content" style="${modalContentStyles}"> <div class="modal-content" style="${modalContentStyles}">
<span id="closeUserPermissionsModal" class="editor-close-btn">&times;</span> <span id="closeUserPermissionsModal" class="editor-close-btn">&times;</span>
<h3>${t("user_permissions")}</h3> <h3>${tf("folder_access", "Folder Access")}</h3>
<div id="userPermissionsList" style="max-height: 300px; overflow-y: auto; margin-bottom: 15px;"> <div class="muted" style="margin:-4px 0 10px;">
<!-- User rows will be loaded here --> ${tf("grant_folders_help", "Grant per-folder capabilities to each user. 'Upload/Manage/Share' imply 'View'.")}
</div>
<div id="userPermissionsList" style="max-height: 60vh; overflow-y: auto; margin-bottom: 15px;">
<!-- User rows will load here -->
</div> </div>
<div style="display: flex; justify-content: flex-end; gap: 10px;"> <div style="display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" id="cancelUserPermissionsBtn" class="btn btn-secondary">${t("cancel")}</button> <button type="button" id="cancelUserPermissionsBtn" class="btn btn-secondary">${t("cancel")}</button>
@@ -606,97 +843,205 @@ export function openUserPermissionsModal() {
document.getElementById("cancelUserPermissionsBtn").addEventListener("click", () => { document.getElementById("cancelUserPermissionsBtn").addEventListener("click", () => {
userPermissionsModal.style.display = "none"; userPermissionsModal.style.display = "none";
}); });
document.getElementById("saveUserPermissionsBtn").addEventListener("click", () => { document.getElementById("saveUserPermissionsBtn").addEventListener("click", async () => {
// Collect permissions data from each user row. // Collect grants for every expanded user (or all rows that have a grants list)
const rows = userPermissionsModal.querySelectorAll(".user-permission-row"); const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
const permissionsData = []; let saves = [];
rows.forEach(row => { rows.forEach(row => {
const g = k => row.querySelector(`input[data-permission='${k}']`)?.checked ?? false; const username = row.getAttribute("data-username");
permissionsData.push({ const grantsBox = row.querySelector(".folder-grants-box");
username: row.getAttribute("data-username"), if (!username || !grantsBox) return;
folderOnly: g("folderOnly"), const grants = collectGrantsFrom(grantsBox);
readOnly: g("readOnly"), saves.push({ user: username, grants });
disableUpload: g("disableUpload"),
bypassOwnership: g("bypassOwnership"),
canShare: g("canShare"),
canZip: g("canZip"),
viewOwnOnly: g("viewOwnOnly"),
}); });
});
// Send the permissionsData to the server. try {
sendRequest("/api/updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken }) if (saves.length === 0) {
.then(response => { showToast(tf("nothing_to_save", "Nothing to save"));
if (response.success) { return;
showToast(t("user_permissions_updated_successfully")); }
userPermissionsModal.style.display = "none"; for (const payload of saves) {
} else { await sendRequest("/api/admin/acl/saveGrants.php", "POST", payload, { "X-CSRF-Token": window.csrfToken });
showToast(t("error_updating_permissions") + ": " + (response.error || t("unknown_error"))); }
showToast(tf("user_permissions_updated_successfully", "User permissions updated successfully"));
userPermissionsModal.style.display = "none";
} catch (err) {
console.error(err);
showToast(tf("error_updating_permissions", "Error updating permissions"), "error");
} }
})
.catch(() => {
showToast(t("error_updating_permissions"));
});
}); });
} else { } else {
userPermissionsModal.style.display = "flex"; userPermissionsModal.style.display = "flex";
} }
// Load the list of users into the modal.
loadUserPermissionsList(); loadUserPermissionsList();
} }
function loadUserPermissionsList() { async function fetchAllUsers() {
const r = await fetch("/api/getUsers.php", { credentials: "include" });
return await r.json(); // array of { username, role }
}
// Returns a map of { username: { readOnly, folderOnly, disableUpload, canShare, bypassOwnership } }
async function fetchAllUserFlags() {
const r = await fetch("/api/getUserPermissions.php", { credentials: "include" });
const data = await r.json();
// remove deprecated flag if present, so UI never shows it
if (data && typeof data === "object") {
const map = data.allPermissions || data.permissions || data;
if (map && typeof map === "object") {
Object.values(map).forEach(u => { if (u && typeof u === "object") delete u.folderOnly; });
}
}
// Accept both shapes: {users:[...]} or a plain object map
if (Array.isArray(data)) {
// unlikely, but normalize
const out = {};
data.forEach(u => { if (u.username) out[u.username] = u; });
return out;
}
if (data && data.allPermissions) return data.allPermissions;
if (data && data.permissions) return data.permissions;
return data || {};
}
function flagRow(u, flags) {
const f = flags[u.username] || {};
const isAdmin = String(u.role) === "1" || u.username.toLowerCase() === "admin";
if (isAdmin) return ""; // skip admins here
return `
<tr data-username="${u.username}">
<td><strong>${u.username}</strong></td>
<td style="text-align:center;"><input type="checkbox" data-flag="readOnly" ${f.readOnly ? "checked":""}></td>
<td style="text-align:center;"><input type="checkbox" data-flag="disableUpload" ${f.disableUpload ? "checked":""}></td>
<td style="text-align:center;"><input type="checkbox" data-flag="canShare" ${f.canShare ? "checked":""}></td>
<td style="text-align:center;"><input type="checkbox" data-flag="bypassOwnership" ${f.bypassOwnership ? "checked":""}></td>
</tr>
`;
}
export async function openUserFlagsModal() {
let modal = document.getElementById("userFlagsModal");
if (!modal) {
modal = document.createElement("div");
modal.id = "userFlagsModal";
modal.style.cssText = `
position:fixed; inset:0; background:rgba(0,0,0,.5);
display:flex; align-items:center; justify-content:center; z-index:3600;
`;
modal.innerHTML = `
<div class="modal-content" style="background:#fff; color:#000; padding:16px; max-width:900px; width:95%; border-radius:8px; position:relative;">
<span id="closeUserFlagsModal" class="editor-close-btn" style="right:8px; top:8px;">&times;</span>
<h3>${tf("user_permissions", "User Permissions")}</h3>
<p class="muted" style="margin-top:-6px;">
${tf("user_flags_help", "Account-level switches. These are NOT per-folder grants.")}
</p>
<div id="userFlagsBody" style="max-height:60vh; overflow:auto; margin:8px 0;">
${t("loading")}
</div>
<div style="display:flex; justify-content:flex-end; gap:8px;">
<button type="button" id="cancelUserFlags" class="btn btn-secondary">${t("cancel")}</button>
<button type="button" id="saveUserFlags" class="btn btn-primary">${t("save_permissions")}</button>
</div>
</div>
`;
document.body.appendChild(modal);
document.getElementById("closeUserFlagsModal").onclick = () => modal.style.display = "none";
document.getElementById("cancelUserFlags").onclick = () => modal.style.display = "none";
document.getElementById("saveUserFlags").onclick = saveUserFlags;
}
modal.style.display = "flex";
loadUserFlagsList();
}
async function loadUserFlagsList() {
const body = document.getElementById("userFlagsBody");
body.textContent = `${t("loading")}`;
try {
const users = await fetchAllUsers(); // [{username, role}]
const flagsMap = await fetchAllUserFlags(); // { username: {…} }
const rows = users.map(u => flagRow(u, flagsMap)).filter(Boolean).join("");
body.innerHTML = `
<table class="table table-sm" style="width:100%;">
<thead>
<tr>
<th>${t("user")}</th>
<th>${t("read_only")}</th>
<th>${t("disable_upload")}</th>
<th>${t("can_share")}</th>
<th>bypassOwnership</th>
</tr>
</thead>
<tbody>${rows || `<tr><td colspan="6">${t("no_users_found")}</td></tr>`}</tbody>
</table>
`;
} catch (e) {
console.error(e);
body.innerHTML = `<div class="muted">${t("error_loading_users")}</div>`;
}
}
async function saveUserFlags() {
const body = document.getElementById("userFlagsBody");
const rows = body.querySelectorAll("tbody tr[data-username]");
const permissions = [];
rows.forEach(tr => {
const username = tr.getAttribute("data-username");
const get = k => tr.querySelector(`input[data-flag="${k}"]`).checked;
permissions.push({
username,
readOnly: get("readOnly"),
disableUpload: get("disableUpload"),
canShare: get("canShare"),
bypassOwnership: get("bypassOwnership")
});
});
try {
// reuse your existing endpoint
const res = await sendRequest("/api/updateUserPermissions.php", "PUT",
{ permissions },
{ "X-CSRF-Token": window.csrfToken }
);
if (res && res.success) {
showToast(tf("user_permissions_updated_successfully", "User permissions updated successfully"));
document.getElementById("userFlagsModal").style.display = "none";
} else {
showToast(tf("error_updating_permissions", "Error updating permissions"), "error");
}
} catch (e) {
console.error(e);
showToast(tf("error_updating_permissions", "Error updating permissions"), "error");
}
}
async function loadUserPermissionsList() {
const listContainer = document.getElementById("userPermissionsList"); const listContainer = document.getElementById("userPermissionsList");
if (!listContainer) return; if (!listContainer) return;
listContainer.innerHTML = ""; listContainer.innerHTML = `<p>${t("loading")}…</p>`;
// First, fetch the current permissions from the server. try {
fetch("/api/getUserPermissions.php", { credentials: "include" }) const usersRes = await fetch("/api/getUsers.php", { credentials: "include" });
.then(response => response.json()) const usersData = await safeJson(usersRes);
.then(permissionsData => {
// Then, fetch the list of users.
return fetch("/api/getUsers.php", { credentials: "include" })
.then(response => response.json())
.then(usersData => {
const users = Array.isArray(usersData) ? usersData : (usersData.users || []); const users = Array.isArray(usersData) ? usersData : (usersData.users || []);
if (users.length === 0) { if (!users.length) {
listContainer.innerHTML = "<p>" + t("no_users_found") + "</p>"; listContainer.innerHTML = "<p>" + t("no_users_found") + "</p>";
return; return;
} }
// Preload folders once (admin should see all)
const folders = await getAllFolders();
listContainer.innerHTML = ""; // clear
users.forEach(user => { users.forEach(user => {
// Skip admin users. // Skip admins
if ((user.role && user.role === "1") || user.username.toLowerCase() === "admin") return; if ((user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin") return;
// Use stored permissions if available; otherwise fall back to defaults.
const defaultPerm = {
folderOnly: false,
readOnly: false,
disableUpload: false,
bypassOwnership: false,
canShare: false,
canZip: false,
viewOwnOnly: false,
};
// Normalize the username key to match server storage (e.g., lowercase)
const usernameKey = user.username.toLowerCase();
const toBool = v => v === true || v === 1 || v === "1";
const userPerm = (permissionsData && typeof permissionsData === "object" && (usernameKey in permissionsData))
? permissionsData[usernameKey]
: defaultPerm;
// Create a row for the user (collapsed by default)
const row = document.createElement("div"); const row = document.createElement("div");
row.classList.add("user-permission-row"); row.classList.add("user-permission-row");
row.setAttribute("data-username", user.username); row.setAttribute("data-username", user.username);
row.style.padding = "6px 0"; row.style.padding = "6px 0";
// helper for checkbox checked state
const checked = key => (userPerm && userPerm[key]) ? "checked" : "";
// header + caret
row.innerHTML = ` row.innerHTML = `
<div class="user-perm-header" <div class="user-perm-header"
role="button" role="button"
@@ -709,33 +1054,38 @@ row.innerHTML = `
<i class="material-icons perm-caret" style="transition:transform .2s; transform:rotate(-90deg);">expand_more</i> <i class="material-icons perm-caret" style="transition:transform .2s; transform:rotate(-90deg);">expand_more</i>
</div> </div>
<div class="user-perm-details" <div class="user-perm-details" style="display:none;margin:8px 4px 2px 10px;">
style="display:none;margin:8px 4px 2px 10px; <div class="folder-grants-box">
display:none;gap:8px; <div class="muted">${t("loading")}…</div>
grid-template-columns: 1fr 1fr;"> </div>
<label><input type="checkbox" data-permission="folderOnly" ${checked("folderOnly")}/> ${t("user_folder_only")}</label>
<label><input type="checkbox" data-permission="readOnly" ${checked("readOnly")}/> ${t("read_only")}</label>
<label><input type="checkbox" data-permission="disableUpload" ${checked("disableUpload")}/> ${t("disable_upload")}</label>
<label><input type="checkbox" data-permission="bypassOwnership" ${checked("bypassOwnership")}/> Bypass ownership</label>
<label><input type="checkbox" data-permission="canShare" ${checked("canShare")}/> Can share</label>
<label><input type="checkbox" data-permission="canZip" ${checked("canZip")}/> Can zip</label>
<label><input type="checkbox" data-permission="viewOwnOnly" ${checked("viewOwnOnly")}/> View own files only</label>
</div> </div>
<hr style="margin:8px 0 4px;border:0;border-bottom:1px solid #ccc;"> <hr style="margin:8px 0 4px;border:0;border-bottom:1px solid #ccc;">
`; `;
// toggle open/closed on click + Enter/Space
const header = row.querySelector(".user-perm-header"); const header = row.querySelector(".user-perm-header");
const details = row.querySelector(".user-perm-details"); const details = row.querySelector(".user-perm-details");
const caret = row.querySelector(".perm-caret"); const caret = row.querySelector(".perm-caret");
const grantsBox = row.querySelector(".folder-grants-box");
async function ensureLoaded() {
if (grantsBox.dataset.loaded === "1") return;
try {
const grants = await getUserGrants(user.username);
renderFolderGrantsUI(user.username, grantsBox, ["root", ...folders.filter(f => f !== "root")], grants);
grantsBox.dataset.loaded = "1";
} catch (e) {
console.error(e);
grantsBox.innerHTML = `<div class="muted">${tf("error_loading_user_grants", "Error loading user grants")}</div>`;
}
}
function toggleOpen() { function toggleOpen() {
const willShow = details.style.display === "none"; const willShow = details.style.display === "none";
details.style.display = willShow ? "grid" : "none"; details.style.display = willShow ? "block" : "none";
header.setAttribute("aria-expanded", willShow ? "true" : "false"); header.setAttribute("aria-expanded", willShow ? "true" : "false");
caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)"; caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)";
if (willShow) ensureLoaded();
} }
header.addEventListener("click", toggleOpen); header.addEventListener("click", toggleOpen);
@@ -743,12 +1093,10 @@ header.addEventListener("keydown", e => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); } if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); }
}); });
listContainer.appendChild(row);
listContainer.appendChild(row); listContainer.appendChild(row);
}); });
}); } catch (err) {
}) console.error(err);
.catch(() => {
listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>"; listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>";
}); }
} }

View File

@@ -51,20 +51,39 @@ window.viewMode = localStorage.getItem("viewMode") || "table";
// Global flag for advanced search mode. // Global flag for advanced search mode.
window.advancedSearchEnabled = false; window.advancedSearchEnabled = false;
/** /* ===========================================================
* --- Helper Functions --- SECURITY: build file URLs only via the API (no /uploads)
*/ =========================================================== */
function apiFileUrl(folder, name, inline = false) {
const f = folder && folder !== "root" ? folder : "root";
const q = new URLSearchParams({
folder: f,
file: name,
inline: inline ? "1" : "0",
t: String(Date.now()) // cache-bust
});
return `/api/file/download.php?${q.toString()}`;
}
// Safely parse JSON; if server returned HTML/text, throw it as a readable error. /* -----------------------------
Helper: robust JSON handling
----------------------------- */
// Parse JSON if possible; throw on non-2xx with useful message & status
async function safeJson(res) { async function safeJson(res) {
const text = await res.text(); const text = await res.text();
try { let body = null;
return JSON.parse(text); try { body = text ? JSON.parse(text) : null; } catch { /* ignore */ }
} catch {
// Common cases: PHP notice/HTML, "Access forbidden.", etc. if (!res.ok) {
const msg = (text || '').toString().trim(); const msg =
throw new Error(msg || `Unexpected ${res.status} ${res.statusText} from ${res.url || 'request'}`); (body && (body.error || body.message)) ||
(text && text.trim()) ||
`HTTP ${res.status}`;
const err = new Error(msg);
err.status = res.status;
throw err;
} }
return body ?? {};
} }
/** /**
@@ -113,8 +132,7 @@ function buildFolderSummary(filteredFiles) {
} }
/** /**
* --- Advanced Search Toggle --- * Advanced Search toggle
* Toggles advanced search mode. When enabled, the search will include additional keys (e.g. "content").
*/ */
function toggleAdvancedSearch() { function toggleAdvancedSearch() {
window.advancedSearchEnabled = !window.advancedSearchEnabled; window.advancedSearchEnabled = !window.advancedSearchEnabled;
@@ -122,27 +140,21 @@ function toggleAdvancedSearch() {
if (advancedBtn) { if (advancedBtn) {
advancedBtn.textContent = window.advancedSearchEnabled ? "Basic Search" : "Advanced Search"; advancedBtn.textContent = window.advancedSearchEnabled ? "Basic Search" : "Advanced Search";
} }
// Re-run the file table rendering with updated search settings.
renderFileTable(window.currentFolder); renderFileTable(window.currentFolder);
} }
window.imageCache = window.imageCache || {}; window.imageCache = window.imageCache || {};
function cacheImage(imgElem, key) { function cacheImage(imgElem, key) {
// Save the current src for future renders.
window.imageCache[key] = imgElem.src; window.imageCache[key] = imgElem.src;
} }
window.cacheImage = cacheImage; window.cacheImage = cacheImage;
/** /**
* --- Fuse.js Search Helper --- * Fuse.js fuzzy search helper
* Uses Fuse.js to perform a fuzzy search on fileData.
* By default, searches over file name, uploader, and tag names.
* When advanced search is enabled, it also includes the 'content' property.
*/ */
function searchFiles(searchTerm) { function searchFiles(searchTerm) {
if (!searchTerm) return fileData; if (!searchTerm) return fileData;
// Define search keys.
let keys = [ let keys = [
{ name: 'name', weight: 0.1 }, { name: 'name', weight: 0.1 },
{ name: 'uploader', weight: 0.1 }, { name: 'uploader', weight: 0.1 },
@@ -165,7 +177,7 @@ function searchFiles(searchTerm) {
} }
/** /**
* --- VIEW MODE TOGGLE BUTTON & Helpers --- * View mode toggle
*/ */
export function createViewToggleButton() { export function createViewToggleButton() {
let toggleBtn = document.getElementById("toggleViewBtn"); let toggleBtn = document.getElementById("toggleViewBtn");
@@ -174,7 +186,6 @@ export function createViewToggleButton() {
toggleBtn.id = "toggleViewBtn"; toggleBtn.id = "toggleViewBtn";
toggleBtn.classList.add("btn", "btn-toggleview"); toggleBtn.classList.add("btn", "btn-toggleview");
// Set initial icon and tooltip based on current view mode.
if (window.viewMode === "gallery") { if (window.viewMode === "gallery") {
toggleBtn.innerHTML = '<i class="material-icons">view_list</i>'; toggleBtn.innerHTML = '<i class="material-icons">view_list</i>';
toggleBtn.title = t("switch_to_table_view"); toggleBtn.title = t("switch_to_table_view");
@@ -183,7 +194,6 @@ export function createViewToggleButton() {
toggleBtn.title = t("switch_to_gallery_view"); toggleBtn.title = t("switch_to_gallery_view");
} }
// Insert the button before the last button in the header.
const headerButtons = document.querySelector(".header-buttons"); const headerButtons = document.querySelector(".header-buttons");
if (headerButtons && headerButtons.lastElementChild) { if (headerButtons && headerButtons.lastElementChild) {
headerButtons.insertBefore(toggleBtn, headerButtons.lastElementChild); headerButtons.insertBefore(toggleBtn, headerButtons.lastElementChild);
@@ -230,7 +240,7 @@ export async function loadFileList(folderParam) {
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>"; fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
try { try {
// Kick off both in parallel, but we'll render as soon as FILES are ready // Kick off both in parallel, but render as soon as FILES are ready
const filesPromise = fetch( const filesPromise = fetch(
`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`, `/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`,
{ credentials: 'include' } { credentials: 'include' }
@@ -244,9 +254,19 @@ export async function loadFileList(folderParam) {
const filesRes = await filesPromise; const filesRes = await filesPromise;
if (filesRes.status === 401) { if (filesRes.status === 401) {
// session expired — bounce to logout
window.location.href = "/api/auth/logout.php"; window.location.href = "/api/auth/logout.php";
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }
if (filesRes.status === 403) {
// forbidden — friendly message, keep UI responsive
fileListContainer.innerHTML = `
<div class="empty-state">
${t("no_access_to_resource") || "You don't have access to this folder."}
</div>`;
showToast(t("no_access_to_resource") || "You don't have access to this folder.", "error");
return [];
}
const data = await safeJson(filesRes); const data = await safeJson(filesRes);
if (data.error) { if (data.error) {
@@ -256,27 +276,31 @@ export async function loadFileList(folderParam) {
// If another loadFileList ran after this one, bail before touching the DOM // If another loadFileList ran after this one, bail before touching the DOM
if (reqId !== __fileListReqSeq) return []; if (reqId !== __fileListReqSeq) return [];
// 3) clear loader (still only if this request is the latest) // 3) clear loader
fileListContainer.innerHTML = ""; fileListContainer.innerHTML = "";
// 4) handle “no files” case // 4) handle “no files” case
if (!data.files || Object.keys(data.files).length === 0) { if (!data.files || Object.keys(data.files).length === 0) {
if (reqId !== __fileListReqSeq) return []; if (reqId !== __fileListReqSeq) return [];
fileListContainer.textContent = t("no_files_found"); fileListContainer.innerHTML = `
<div class="empty-state">
${t("no_files_found")}
<div style="margin-top:6px;font-size:.9em;color:#777">
${t("no_folder_access_yet") || "No folder access has been assigned to your account yet."}
</div>
</div>`;
// hide summary + slider
const summaryElem = document.getElementById("fileSummary"); const summaryElem = document.getElementById("fileSummary");
if (summaryElem) summaryElem.style.display = "none"; if (summaryElem) summaryElem.style.display = "none";
const sliderContainer = document.getElementById("viewSliderContainer"); const sliderContainer = document.getElementById("viewSliderContainer");
if (sliderContainer) sliderContainer.style.display = "none"; if (sliderContainer) sliderContainer.style.display = "none";
// hide folder strip for now; well re-show it after folders load (below)
const strip = document.getElementById("folderStripContainer"); const strip = document.getElementById("folderStripContainer");
if (strip) strip.style.display = "none"; if (strip) strip.style.display = "none";
updateFileActionButtons(); updateFileActionButtons();
fileListContainer.style.visibility = "visible"; fileListContainer.style.visibility = "visible";
return []; // We still try to populate the folder strip below
} }
// 5) normalize files array // 5) normalize files array
@@ -290,43 +314,19 @@ export async function loadFileList(folderParam) {
data.files = data.files.map(f => { data.files = data.files.map(f => {
f.fullName = (f.path || f.name).trim().toLowerCase(); f.fullName = (f.path || f.name).trim().toLowerCase();
// Prefer numeric size if your API provides it; otherwise parse the "1.2 MB" string // Prefer numeric size if API provides it; otherwise parse the "1.2 MB" string
let bytes = Number.isFinite(f.sizeBytes) let bytes = Number.isFinite(f.sizeBytes)
? f.sizeBytes ? f.sizeBytes
: parseSizeToBytes(String(f.size || "")); : parseSizeToBytes(String(f.size || ""));
if (!Number.isFinite(bytes)) bytes = Infinity; if (!Number.isFinite(bytes)) bytes = Infinity;
// extension policy + size policy
f.editable = canEditFile(f.name) && (bytes <= MAX_EDIT_BYTES); f.editable = canEditFile(f.name) && (bytes <= MAX_EDIT_BYTES);
f.folder = folder; f.folder = folder;
return f; return f;
}); });
fileData = data.files; fileData = data.files;
// Decide editability BEFORE render to avoid any post-render “blink”
data.files = data.files.map(f => {
f.fullName = (f.path || f.name).trim().toLowerCase();
// extension policy
const extOk = canEditFile(f.name);
// prefer numeric byte size if API provides it; otherwise parse "12.3 MB" strings
let bytes = Infinity;
if (Number.isFinite(f.sizeBytes)) {
bytes = f.sizeBytes;
} else if (f.size != null && String(f.size).trim() !== "") {
bytes = parseSizeToBytes(String(f.size));
}
f.editable = extOk && (bytes <= MAX_EDIT_BYTES);
f.folder = folder;
return f;
});
fileData = data.files;
// If stale, stop before any DOM updates
if (reqId !== __fileListReqSeq) return []; if (reqId !== __fileListReqSeq) return [];
// 6) inject summary + slider // 6) inject summary + slider
@@ -410,7 +410,7 @@ export async function loadFileList(folderParam) {
} }
} }
// 7) render files (only if still latest) // 7) render files
if (reqId !== __fileListReqSeq) return []; if (reqId !== __fileListReqSeq) return [];
if (window.viewMode === "gallery") { if (window.viewMode === "gallery") {
@@ -424,7 +424,14 @@ export async function loadFileList(folderParam) {
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) ----- // ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
try { try {
const foldersRes = await foldersPromise; const foldersRes = await foldersPromise;
const folderRaw = await safeJson(foldersRes).catch(() => []); // don't block file render on folder strip issues // If folders API forbids, just skip the strip; keep file rows rendered
if (foldersRes.status === 403) {
const strip = document.getElementById("folderStripContainer");
if (strip) strip.style.display = "none";
return data.files;
}
const folderRaw = await safeJson(foldersRes).catch(() => []); // don't block file render on strip issues
if (reqId !== __fileListReqSeq) return data.files; if (reqId !== __fileListReqSeq) return data.files;
// --- build ONLY the *direct* children of current folder --- // --- build ONLY the *direct* children of current folder ---
@@ -443,7 +450,6 @@ export async function loadFileList(folderParam) {
} }
subfolders = subfolders.filter(sf => !hidden.has(sf.name)); subfolders = subfolders.filter(sf => !hidden.has(sf.name));
// inject folder strip below actions, above file list
let strip = document.getElementById("folderStripContainer"); let strip = document.getElementById("folderStripContainer");
if (!strip) { if (!strip) {
strip = document.createElement("div"); strip = document.createElement("div");
@@ -461,7 +467,6 @@ export async function loadFileList(folderParam) {
`).join(""); `).join("");
strip.style.display = "flex"; strip.style.display = "flex";
// wire up each foldertile
strip.querySelectorAll(".folder-item").forEach(el => { strip.querySelectorAll(".folder-item").forEach(el => {
// 1) click to navigate // 1) click to navigate
el.addEventListener("click", () => { el.addEventListener("click", () => {
@@ -488,11 +493,9 @@ export async function loadFileList(folderParam) {
window.currentFolder = dest; window.currentFolder = dest;
localStorage.setItem("lastOpenedFolder", dest); localStorage.setItem("lastOpenedFolder", dest);
// highlight the strip tile
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected")); strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
el.classList.add("selected"); el.classList.add("selected");
// reuse folderManager menu
const menuItems = [ const menuItems = [
{ {
label: t("create_folder"), label: t("create_folder"),
@@ -515,7 +518,6 @@ export async function loadFileList(folderParam) {
}); });
}); });
// one global click to hide any open context menu
document.addEventListener("click", hideFolderManagerContextMenu); document.addEventListener("click", hideFolderManagerContextMenu);
} else { } else {
@@ -529,12 +531,16 @@ export async function loadFileList(folderParam) {
} catch (err) { } catch (err) {
console.error("Error loading file list:", err); console.error("Error loading file list:", err);
if (err.message !== "Unauthorized") { if (err.status === 403) {
fileListContainer.textContent = "Error loading files."; showToast(t("no_access_to_resource") || "You don't have access to this folder.", "error");
const fileListContainer = document.getElementById("fileList");
if (fileListContainer) fileListContainer.textContent = t("no_access_to_resource") || "You don't have access to this folder.";
} else if (err.message !== "Unauthorized") {
const fileListContainer = document.getElementById("fileList");
if (fileListContainer) fileListContainer.textContent = "Error loading files.";
} }
return []; return [];
} finally { } finally {
// Only the latest call should restore visibility
if (reqId === __fileListReqSeq) { if (reqId === __fileListReqSeq) {
fileListContainer.style.visibility = "visible"; fileListContainer.style.visibility = "visible";
} }
@@ -542,7 +548,7 @@ export async function loadFileList(folderParam) {
} }
/** /**
* Update renderFileTable so it writes its content into the provided container. * Render table view
*/ */
export function renderFileTable(folder, container, subfolders) { export function renderFileTable(folder, container, subfolders) {
const fileListContent = container || document.getElementById("fileList"); const fileListContent = container || document.getElementById("fileList");
@@ -550,7 +556,6 @@ export function renderFileTable(folder, container, subfolders) {
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10); const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
let currentPage = window.currentPage || 1; let currentPage = window.currentPage || 1;
// Use Fuse.js search via our helper function.
const filteredFiles = searchFiles(searchTerm); const filteredFiles = searchFiles(searchTerm);
const totalFiles = filteredFiles.length; const totalFiles = filteredFiles.length;
@@ -559,11 +564,11 @@ export function renderFileTable(folder, container, subfolders) {
currentPage = totalPages > 0 ? totalPages : 1; currentPage = totalPages > 0 ? totalPages : 1;
window.currentPage = currentPage; window.currentPage = currentPage;
} }
const folderPath = folder === "root"
? "uploads/"
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
// Build the top controls and append the advanced search toggle button. // We pass a harmless "base" string to keep buildFileTableRow happy,
// then we will FIX the preview/thumbnail URLs to the API below.
const fakeBase = "#/";
const topControlsHTML = buildSearchAndPaginationControls({ const topControlsHTML = buildSearchAndPaginationControls({
currentPage, currentPage,
totalPages, totalPages,
@@ -578,7 +583,9 @@ export function renderFileTable(folder, container, subfolders) {
let rowsHTML = "<tbody>"; let rowsHTML = "<tbody>";
if (totalFiles > 0) { if (totalFiles > 0) {
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => { filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
let rowHTML = buildFileTableRow(file, folderPath); // Build row with a neutral base, then correct the links/preview below.
let rowHTML = buildFileTableRow(file, fakeBase);
// Give the row an ID so we can patch attributes safely
rowHTML = rowHTML.replace("<tr", `<tr id="file-row-${encodeURIComponent(file.name)}-${startIndex + idx}"`); rowHTML = rowHTML.replace("<tr", `<tr id="file-row-${encodeURIComponent(file.name)}-${startIndex + idx}"`);
let tagBadgesHTML = ""; let tagBadgesHTML = "";
@@ -589,10 +596,9 @@ export function renderFileTable(folder, container, subfolders) {
}); });
tagBadgesHTML += "</div>"; tagBadgesHTML += "</div>";
} }
rowHTML = rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => { rowsHTML += rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
return p1 + p2 + tagBadgesHTML + p3; return p1 + p2 + tagBadgesHTML + p3;
}); });
rowsHTML += rowHTML;
}); });
} else { } else {
rowsHTML += `<tr><td colspan="8">No files found.</td></tr>`; rowsHTML += `<tr><td colspan="8">No files found.</td></tr>`;
@@ -602,6 +608,37 @@ export function renderFileTable(folder, container, subfolders) {
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML; fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
// PATCH each row's preview/thumb to use the secure API URLs
if (totalFiles > 0) {
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
const rowEl = document.getElementById(`file-row-${encodeURIComponent(file.name)}-${startIndex + idx}`);
if (!rowEl) return;
const previewUrl = apiFileUrl(file.folder || folder, file.name, true);
// Preview button dataset
const previewBtn = rowEl.querySelector(".preview-btn");
if (previewBtn) {
previewBtn.dataset.previewUrl = previewUrl;
previewBtn.dataset.previewName = file.name;
}
// Thumbnail (if present)
const thumbImg = rowEl.querySelector("img");
if (thumbImg) {
thumbImg.src = previewUrl;
thumbImg.setAttribute("data-cache-key", previewUrl);
}
// Any anchor that might have been built to point at a file path
rowEl.querySelectorAll('a[href]').forEach(a => {
// Only rewrite obvious file anchors (ignore actions with '#', 'javascript:', etc.)
if (/^#|^javascript:/i.test(a.getAttribute('href') || '')) return;
a.href = previewUrl;
});
});
}
fileListContent.querySelectorAll('.folder-item').forEach(el => { fileListContent.querySelectorAll('.folder-item').forEach(el => {
el.addEventListener('click', () => loadFileList(el.dataset.folder)); el.addEventListener('click', () => loadFileList(el.dataset.folder));
}); });
@@ -616,14 +653,13 @@ export function renderFileTable(folder, container, subfolders) {
}); });
const nextBtn = document.getElementById("nextPageBtn"); const nextBtn = document.getElementById("nextPageBtn");
if (nextBtn) nextBtn.addEventListener("click", () => { if (nextBtn) nextBtn.addEventListener("click", () => {
// totalPages is computed above in this scope
if (window.currentPage < totalPages) { if (window.currentPage < totalPages) {
window.currentPage++; window.currentPage++;
renderFileTable(folder, container); renderFileTable(folder, container);
} }
}); });
// ADD: advanced search toggle // advanced search toggle
const advToggle = document.getElementById("advancedSearchToggle"); const advToggle = document.getElementById("advancedSearchToggle");
if (advToggle) advToggle.addEventListener("click", () => { if (advToggle) advToggle.addEventListener("click", () => {
toggleAdvancedSearch(); toggleAdvancedSearch();
@@ -638,25 +674,16 @@ export function renderFileTable(folder, container, subfolders) {
renderFileTable(folder, container); renderFileTable(folder, container);
}); });
// hook up the master checkbox // Row-select
const selectAll = document.getElementById("selectAll");
if (selectAll) {
selectAll.addEventListener("change", () => {
toggleAllCheckboxes(selectAll);
});
}
// 1) Row-click selects the row
fileListContent.querySelectorAll("tbody tr").forEach(row => { fileListContent.querySelectorAll("tbody tr").forEach(row => {
row.addEventListener("click", e => { row.addEventListener("click", e => {
// grab the underlying checkbox value
const cb = row.querySelector(".file-checkbox"); const cb = row.querySelector(".file-checkbox");
if (!cb) return; if (!cb) return;
toggleRowSelection(e, cb.value); toggleRowSelection(e, cb.value);
}); });
}); });
// 2) Download buttons // Download buttons
fileListContent.querySelectorAll(".download-btn").forEach(btn => { fileListContent.querySelectorAll(".download-btn").forEach(btn => {
btn.addEventListener("click", e => { btn.addEventListener("click", e => {
e.stopPropagation(); e.stopPropagation();
@@ -664,33 +691,36 @@ export function renderFileTable(folder, container, subfolders) {
}); });
}); });
// 3) Edit buttons // Edit buttons
fileListContent.querySelectorAll(".edit-btn").forEach(btn => { fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
btn.addEventListener("click", e => { btn.addEventListener("click", async e => {
e.stopPropagation(); e.stopPropagation();
editFile(btn.dataset.editName, btn.dataset.editFolder); const m = await import('./fileEditor.js');
m.editFile(btn.dataset.editName, btn.dataset.editFolder);
}); });
}); });
// 4) Rename buttons // Rename buttons
fileListContent.querySelectorAll(".rename-btn").forEach(btn => { fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
btn.addEventListener("click", e => { btn.addEventListener("click", async e => {
e.stopPropagation(); e.stopPropagation();
renameFile(btn.dataset.renameName, btn.dataset.renameFolder); const m = await import('./fileActions.js');
m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
}); });
}); });
// 5) Preview buttons // Preview buttons
fileListContent.querySelectorAll(".preview-btn").forEach(btn => { fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
btn.addEventListener("click", e => { btn.addEventListener("click", async e => {
e.stopPropagation(); e.stopPropagation();
previewFile(btn.dataset.previewUrl, btn.dataset.previewName); const m = await import('./filePreview.js');
m.previewFile(btn.dataset.previewUrl, btn.dataset.previewName);
}); });
}); });
createViewToggleButton(); createViewToggleButton();
// Setup event listeners. // search input
const newSearchInput = document.getElementById("searchInput"); const newSearchInput = document.getElementById("searchInput");
if (newSearchInput) { if (newSearchInput) {
newSearchInput.addEventListener("input", debounce(function () { newSearchInput.addEventListener("input", debounce(function () {
@@ -707,6 +737,7 @@ export function renderFileTable(folder, container, subfolders) {
}, 0); }, 0);
}, 300)); }, 300));
} }
const slider = document.getElementById('rowHeightSlider'); const slider = document.getElementById('rowHeightSlider');
const valueDisplay = document.getElementById('rowHeightValue'); const valueDisplay = document.getElementById('rowHeightValue');
if (slider) { if (slider) {
@@ -758,16 +789,16 @@ export function renderFileTable(folder, container, subfolders) {
// A helper to compute the max image height based on the current column count. // A helper to compute the max image height based on the current column count.
function getMaxImageHeight() { function getMaxImageHeight() {
const columns = parseInt(window.galleryColumns || 3, 10); const columns = parseInt(window.galleryColumns || 3, 10);
return 150 * (7 - columns); // adjust the multiplier as needed. return 150 * (7 - columns);
} }
export function renderGalleryView(folder, container) { export function renderGalleryView(folder, container) {
const fileListContent = container || document.getElementById("fileList"); const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase(); const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const filteredFiles = searchFiles(searchTerm); const filteredFiles = searchFiles(searchTerm);
const folderPath = folder === "root"
? "uploads/" // API preview base (well build per-file URLs)
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/"; const apiBase = `/api/file/download.php?folder=${encodeURIComponent(folder)}&file=`;
// pagination settings // pagination settings
const itemsPerPage = window.itemsPerPage; const itemsPerPage = window.itemsPerPage;
@@ -794,7 +825,6 @@ export function renderGalleryView(folder, container) {
window.currentSearchTerm = searchInput.value; window.currentSearchTerm = searchInput.value;
window.currentPage = 1; window.currentPage = 1;
renderGalleryView(folder); renderGalleryView(folder);
// keep caret at end
setTimeout(() => { setTimeout(() => {
const f = document.getElementById("searchInput"); const f = document.getElementById("searchInput");
if (f) { if (f) {
@@ -807,15 +837,12 @@ export function renderGalleryView(folder, container) {
} }
}, 0); }, 0);
// --- Column slider with responsive max --- // determine column max by screen size
const numColumns = window.galleryColumns || 3; const numColumns = window.galleryColumns || 3;
// clamp slider max to 1 on small (<600px), 2 on medium (<900px), else up to 6
const w = window.innerWidth; const w = window.innerWidth;
let maxCols = 6; let maxCols = 6;
if (w < 600) maxCols = 1; if (w < 600) maxCols = 1;
else if (w < 900) maxCols = 2; else if (w < 900) maxCols = 2;
// ensure current value doesnt exceed the new max
const startCols = Math.min(numColumns, maxCols); const startCols = Math.min(numColumns, maxCols);
window.galleryColumns = startCols; window.galleryColumns = startCols;
@@ -834,11 +861,14 @@ export function renderGalleryView(folder, container) {
pageFiles.forEach((file, idx) => { pageFiles.forEach((file, idx) => {
const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx); const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx);
const cacheKey = folderPath + encodeURIComponent(file.name);
// build preview URL from API (cache-busted)
const previewURL = `${apiBase}${encodeURIComponent(file.name)}&t=${Date.now()}`;
// thumbnail // thumbnail
let thumbnail; let thumbnail;
if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) { if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
const cacheKey = previewURL; // include folder & file
if (window.imageCache && window.imageCache[cacheKey]) { if (window.imageCache && window.imageCache[cacheKey]) {
thumbnail = `<img thumbnail = `<img
src="${window.imageCache[cacheKey]}" src="${window.imageCache[cacheKey]}"
@@ -847,9 +877,8 @@ export function renderGalleryView(folder, container) {
alt="${escapeHTML(file.name)}" alt="${escapeHTML(file.name)}"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`; style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
} else { } else {
const imageUrl = folderPath + encodeURIComponent(file.name) + "?t=" + Date.now();
thumbnail = `<img thumbnail = `<img
src="${imageUrl}" src="${previewURL}"
class="gallery-thumbnail" class="gallery-thumbnail"
data-cache-key="${cacheKey}" data-cache-key="${cacheKey}"
alt="${escapeHTML(file.name)}" alt="${escapeHTML(file.name)}"
@@ -891,7 +920,7 @@ export function renderGalleryView(folder, container) {
style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label> style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label>
<div class="gallery-preview" style="cursor:pointer;" <div class="gallery-preview" style="cursor:pointer;"
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}" data-preview-url="${previewURL}"
data-preview-name="${file.name}"> data-preview-name="${file.name}">
${thumbnail} ${thumbnail}
</div> </div>
@@ -903,12 +932,7 @@ export function renderGalleryView(folder, container) {
</span> </span>
${tagBadgesHTML} ${tagBadgesHTML}
<div <div class="btn-group btn-group-sm btn-group-hover" role="group" aria-label="File actions" style="margin-top:5px;">
class="btn-group btn-group-sm btn-group-hover"
role="group"
aria-label="File actions"
style="margin-top:5px;"
>
<button <button
type="button" type="button"
class="btn btn-success py-1 download-btn" class="btn btn-success py-1 download-btn"
@@ -949,7 +973,6 @@ export function renderGalleryView(folder, container) {
<i class="material-icons">share</i> <i class="material-icons">share</i>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
`; `;
@@ -963,9 +986,7 @@ export function renderGalleryView(folder, container) {
// render // render
fileListContent.innerHTML = galleryHTML; fileListContent.innerHTML = galleryHTML;
// --- Now wire up all behaviors without inline handlers --- // pagination buttons for gallery
// ADD: pagination buttons for gallery
const prevBtn = document.getElementById("prevPageBtn"); const prevBtn = document.getElementById("prevPageBtn");
if (prevBtn) prevBtn.addEventListener("click", () => { if (prevBtn) prevBtn.addEventListener("click", () => {
if (window.currentPage > 1) { if (window.currentPage > 1) {
@@ -981,16 +1002,16 @@ export function renderGalleryView(folder, container) {
} }
}); });
// ←— ADD: advanced search toggle // advanced search toggle
const advToggle = document.getElementById("advancedSearchToggle"); const advToggle = document.getElementById("advancedSearchToggle");
if (advToggle) advToggle.addEventListener("click", () => { if (advToggle) advToggle.addEventListener("click", () => {
toggleAdvancedSearch(); toggleAdvancedSearch();
}); });
// ←— ADD: wire up context-menu in gallery // context menu in gallery
bindFileListContextMenu(); bindFileListContextMenu();
// ADD: items-per-page selector for gallery // items-per-page selector for gallery
const itemsSelect = document.getElementById("itemsPerPageSelect"); const itemsSelect = document.getElementById("itemsPerPageSelect");
if (itemsSelect) itemsSelect.addEventListener("change", e => { if (itemsSelect) itemsSelect.addEventListener("change", e => {
window.itemsPerPage = parseInt(e.target.value, 10); window.itemsPerPage = parseInt(e.target.value, 10);
@@ -1005,10 +1026,11 @@ export function renderGalleryView(folder, container) {
img.addEventListener('load', () => cacheImage(img, key)); img.addEventListener('load', () => cacheImage(img, key));
}); });
// preview clicks // preview clicks (dynamic import to avoid global dependency)
fileListContent.querySelectorAll(".gallery-preview").forEach(el => { fileListContent.querySelectorAll(".gallery-preview").forEach(el => {
el.addEventListener("click", () => { el.addEventListener("click", async () => {
previewFile(el.dataset.previewUrl, el.dataset.previewName); const m = await import('./filePreview.js');
m.previewFile(el.dataset.previewUrl, el.dataset.previewName);
}); });
}); });
@@ -1022,17 +1044,19 @@ export function renderGalleryView(folder, container) {
// edit clicks // edit clicks
fileListContent.querySelectorAll(".edit-btn").forEach(btn => { fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
btn.addEventListener("click", e => { btn.addEventListener("click", async e => {
e.stopPropagation(); e.stopPropagation();
editFile(btn.dataset.editName, btn.dataset.editFolder); const m = await import('./fileEditor.js');
m.editFile(btn.dataset.editName, btn.dataset.editFolder);
}); });
}); });
// rename clicks // rename clicks
fileListContent.querySelectorAll(".rename-btn").forEach(btn => { fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
btn.addEventListener("click", e => { btn.addEventListener("click", async e => {
e.stopPropagation(); e.stopPropagation();
renameFile(btn.dataset.renameName, btn.dataset.renameFolder); const m = await import('./fileActions.js');
m.renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
}); });
}); });
@@ -1067,7 +1091,7 @@ export function renderGalleryView(folder, container) {
}); });
} }
// pagination functions // pagination helpers
window.changePage = newPage => { window.changePage = newPage => {
window.currentPage = newPage; window.currentPage = newPage;
if (window.viewMode === "gallery") renderGalleryView(folder); if (window.viewMode === "gallery") renderGalleryView(folder);
@@ -1082,7 +1106,6 @@ export function renderGalleryView(folder, container) {
else renderFileTable(folder); else renderFileTable(folder);
}; };
// update toolbar and toggle button
updateFileActionButtons(); updateFileActionButtons();
createViewToggleButton(); createViewToggleButton();
} }
@@ -1096,18 +1119,16 @@ function updateSliderConstraints() {
let min = 1; let min = 1;
let max; let max;
// Set maximum based on screen size. if (width < 600) {
if (width < 600) { // small devices (phones)
max = 1; max = 1;
} else if (width < 1024) { // medium devices } else if (width < 1024) {
max = 3; max = 3;
} else if (width < 1440) { // between medium and large devices } else if (width < 1440) {
max = 4; max = 4;
} else { // large devices and above } else {
max = 6; max = 6;
} }
// Adjust the slider's current value if needed
let currentVal = parseInt(slider.value, 10); let currentVal = parseInt(slider.value, 10);
if (currentVal > max) { if (currentVal > max) {
currentVal = max; currentVal = max;
@@ -1118,7 +1139,6 @@ function updateSliderConstraints() {
slider.max = max; slider.max = max;
document.getElementById("galleryColumnsValue").textContent = currentVal; document.getElementById("galleryColumnsValue").textContent = currentVal;
// Update the grid layout based on the current slider value.
const galleryContainer = document.querySelector(".gallery-container"); const galleryContainer = document.querySelector(".gallery-container");
if (galleryContainer) { if (galleryContainer) {
galleryContainer.style.gridTemplateColumns = `repeat(${currentVal}, 1fr)`; galleryContainer.style.gridTemplateColumns = `repeat(${currentVal}, 1fr)`;
@@ -1200,54 +1220,38 @@ export function canEditFile(fileName) {
const ext = fileName.slice(dot + 1).toLowerCase(); const ext = fileName.slice(dot + 1).toLowerCase();
// Text/code-only. Intentionally exclude php/phtml/phar/etc.
const allowedExtensions = [ const allowedExtensions = [
// Plain text & docs (text)
"txt", "text", "md", "markdown", "rst", "txt", "text", "md", "markdown", "rst",
// Web
"html", "htm", "xhtml", "shtml", "html", "htm", "xhtml", "shtml",
"css", "scss", "sass", "less", "css", "scss", "sass", "less",
// JS/TS
"js", "mjs", "cjs", "jsx", "js", "mjs", "cjs", "jsx",
"ts", "tsx", "ts", "tsx",
// Data & config formats
"json", "jsonc", "ndjson", "json", "jsonc", "ndjson",
"yml", "yaml", "toml", "xml", "plist", "yml", "yaml", "toml", "xml", "plist",
"ini", "conf", "config", "cfg", "cnf", "properties", "props", "rc", "ini", "conf", "config", "cfg", "cnf", "properties", "props", "rc",
"env", "dotenv", "env", "dotenv",
"csv", "tsv", "tab", "csv", "tsv", "tab",
"log", "log",
// Shell / scripts
"sh", "bash", "zsh", "ksh", "fish", "sh", "bash", "zsh", "ksh", "fish",
"bat", "cmd", "bat", "cmd",
"ps1", "psm1", "psd1", "ps1", "psm1", "psd1",
"py", "pyw",
// Languages "rb",
"py", "pyw", // Python "pl", "pm",
"rb", // Ruby "go",
"pl", "pm", // Perl "rs",
"go", // Go "java",
"rs", // Rust "kt", "kts",
"java", // Java "scala", "sc",
"kt", "kts", // Kotlin "groovy", "gradle",
"scala", "sc", // Scala "c", "h", "cpp", "cxx", "cc", "hpp", "hh", "hxx",
"groovy", "gradle", // Groovy/Gradle "m", "mm",
"c", "h", "cpp", "cxx", "cc", "hpp", "hh", "hxx", // C/C++ "swift",
"m", "mm", // Obj-C / Obj-C++ "cs", "fs", "fsx",
"swift", // Swift
"cs", "fs", "fsx", // C#, F#
"dart", "dart",
"lua", "lua",
"r", "rmd", "r", "rmd",
// SQL
"sql", "sql",
// Front-end SFC/templates
"vue", "svelte", "vue", "svelte",
"twig", "mustache", "hbs", "handlebars", "ejs", "pug", "jade" "twig", "mustache", "hbs", "handlebars", "ejs", "pug", "jade"
]; ];

View File

@@ -7,6 +7,28 @@ import { openFolderShareModal } from './folderShareModal.js';
import { fetchWithCsrf } from './auth.js'; import { fetchWithCsrf } from './auth.js';
import { loadCsrfToken } from './main.js'; import { loadCsrfToken } from './main.js';
/* ----------------------
Helpers: safe JSON + state
----------------------*/
// Robust JSON reader that surfaces server errors (with status)
async function safeJson(res) {
const text = await res.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { /* ignore */ }
if (!res.ok) {
const msg =
(body && (body.error || body.message)) ||
(text && text.trim()) ||
`HTTP ${res.status}`;
const err = new Error(msg);
err.status = res.status;
throw err;
}
return body ?? {};
}
/* ---------------------- /* ----------------------
Helper Functions (Data/State) Helper Functions (Data/State)
----------------------*/ ----------------------*/
@@ -15,7 +37,7 @@ import { loadCsrfToken } from './main.js';
export function formatFolderName(folder) { export function formatFolderName(folder) {
if (typeof folder !== "string") return ""; if (typeof folder !== "string") return "";
if (folder.indexOf("/") !== -1) { if (folder.indexOf("/") !== -1) {
let parts = folder.split("/"); const parts = folder.split("/");
let indent = ""; let indent = "";
for (let i = 1; i < parts.length; i++) { for (let i = 1; i < parts.length; i++) {
indent += "\u00A0\u00A0\u00A0\u00A0"; // 4 non-breaking spaces per level indent += "\u00A0\u00A0\u00A0\u00A0"; // 4 non-breaking spaces per level
@@ -34,9 +56,7 @@ function buildFolderTree(folders) {
const parts = folderPath.split('/'); const parts = folderPath.split('/');
let current = tree; let current = tree;
parts.forEach(part => { parts.forEach(part => {
if (!current[part]) { if (!current[part]) current[part] = {};
current[part] = {};
}
current = current[part]; current = current[part];
}); });
}); });
@@ -66,23 +86,29 @@ export function getParentFolder(folder) {
Breadcrumb Functions Breadcrumb Functions
----------------------*/ ----------------------*/
function renderBreadcrumb(normalizedFolder) { async function applyFolderCapabilities(folder) {
if (!normalizedFolder || normalizedFolder === "") return ""; try {
const parts = normalizedFolder.split("/"); const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' });
let breadcrumbItems = []; if (!res.ok) return;
// Use the first segment as the root. const caps = await res.json();
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="${parts[0]}">${escapeHTML(parts[0])}</span>`);
let cumulative = parts[0]; // top buttons
parts.slice(1).forEach(part => { const createBtn = document.getElementById('createFolderBtn');
cumulative += "/" + part; const renameBtn = document.getElementById('renameFolderBtn');
breadcrumbItems.push(`<span class="breadcrumb-separator"> / </span>`); const deleteBtn = document.getElementById('deleteFolderBtn');
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="${cumulative}">${escapeHTML(part)}</span>`); const shareBtn = document.getElementById('shareFolderBtn');
});
return breadcrumbItems.join(''); if (createBtn) createBtn.disabled = !caps.canCreate;
if (renameBtn) renameBtn.disabled = !caps.canRename || folder === 'root';
if (deleteBtn) deleteBtn.disabled = !caps.canDelete || folder === 'root';
if (shareBtn) shareBtn.disabled = !caps.canShare || folder === 'root';
// keep for later if you want context menu to reflect caps
window.currentFolderCaps = caps;
} catch {}
} }
// --- NEW: Breadcrumb Delegation Setup --- // --- Breadcrumb Delegation Setup ---
// bindBreadcrumbEvents(); removed in favor of delegation
export function setupBreadcrumbDelegation() { export function setupBreadcrumbDelegation() {
const container = document.getElementById("fileListTitle"); const container = document.getElementById("fileListTitle");
if (!container) { if (!container) {
@@ -104,7 +130,6 @@ export function setupBreadcrumbDelegation() {
// Click handler via delegation // Click handler via delegation
function breadcrumbClickHandler(e) { function breadcrumbClickHandler(e) {
// find the nearest .breadcrumb-link
const link = e.target.closest(".breadcrumb-link"); const link = e.target.closest(".breadcrumb-link");
if (!link) return; if (!link) return;
@@ -115,12 +140,10 @@ function breadcrumbClickHandler(e) {
window.currentFolder = folder; window.currentFolder = folder;
localStorage.setItem("lastOpenedFolder", folder); localStorage.setItem("lastOpenedFolder", folder);
// rebuild the title safely
updateBreadcrumbTitle(folder); updateBreadcrumbTitle(folder);
applyFolderCapabilities(folder);
expandTreePath(folder); expandTreePath(folder);
document.querySelectorAll(".folder-option").forEach(el => document.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected"));
el.classList.remove("selected")
);
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`); const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
if (target) target.classList.add("selected"); if (target) target.classList.add("selected");
@@ -158,20 +181,18 @@ function breadcrumbDropHandler(e) {
} }
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []); const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
if (filesToMove.length === 0) return; if (filesToMove.length === 0) return;
fetch("/api/file/moveFiles.php", {
fetchWithCsrf("/api/file/moveFiles.php", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
},
body: JSON.stringify({ body: JSON.stringify({
source: dragData.sourceFolder, source: dragData.sourceFolder,
files: filesToMove, files: filesToMove,
destination: dropFolder destination: dropFolder
}) })
}) })
.then(response => response.json()) .then(safeJson)
.then(data => { .then(data => {
if (data.success) { if (data.success) {
showToast(`File(s) moved successfully to ${dropFolder}!`); showToast(`File(s) moved successfully to ${dropFolder}!`);
@@ -186,47 +207,39 @@ function breadcrumbDropHandler(e) {
}); });
} }
/* ---------------------- /* ----------------------
Check Current User's Folder-Only Permission Check Current User's Folder-Only Permission
----------------------*/ ----------------------*/
// This function uses localStorage values (set during login) to determine if the current user is restricted. // Authoritatively determine from the server; still write to localStorage for UI,
// If folderOnly is "true", then the personal folder (i.e. username) is forced as the effective root. // but ignore any preexisting localStorage override for security.
function checkUserFolderPermission() { async function checkUserFolderPermission() {
const username = localStorage.getItem("username"); const username = localStorage.getItem("username") || "";
console.log("checkUserFolderPermission: username =", username); try {
if (!username) { const res = await fetchWithCsrf("/api/getUserPermissions.php", {
console.warn("No username in localStorage; skipping getUserPermissions fetch."); method: "GET",
return Promise.resolve(false); credentials: "include"
} });
if (localStorage.getItem("folderOnly") === "true") { const permissionsData = await safeJson(res);
window.userFolderOnly = true;
console.log("checkUserFolderPermission: using localStorage.folderOnly = true"); const isFolderOnly =
!!(permissionsData &&
permissionsData[username] &&
permissionsData[username].folderOnly);
window.userFolderOnly = isFolderOnly;
localStorage.setItem("folderOnly", isFolderOnly ? "true" : "false");
if (isFolderOnly && username) {
localStorage.setItem("lastOpenedFolder", username); localStorage.setItem("lastOpenedFolder", username);
window.currentFolder = username; window.currentFolder = username;
return Promise.resolve(true);
} }
return fetch("/api/getUserPermissions.php", { credentials: "include" }) return isFolderOnly;
.then(response => response.json()) } catch (err) {
.then(permissionsData => { console.error("Error fetching user permissions:", err);
console.log("checkUserFolderPermission: permissionsData =", permissionsData);
if (permissionsData && permissionsData[username] && permissionsData[username].folderOnly) {
window.userFolderOnly = true;
localStorage.setItem("folderOnly", "true");
localStorage.setItem("lastOpenedFolder", username);
window.currentFolder = username;
return true;
} else {
window.userFolderOnly = false; window.userFolderOnly = false;
localStorage.setItem("folderOnly", "false"); localStorage.setItem("folderOnly", "false");
return false; return false;
} }
})
.catch(err => {
console.error("Error fetching user permissions:", err);
window.userFolderOnly = false;
return false;
});
} }
/* ---------------------- /* ----------------------
@@ -273,7 +286,7 @@ function expandTreePath(path) {
const toggle = li.querySelector(".folder-toggle"); const toggle = li.querySelector(".folder-toggle");
if (toggle) { if (toggle) {
toggle.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]"; toggle.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
let state = loadFolderTreeState(); const state = loadFolderTreeState();
state[cumulative] = "block"; state[cumulative] = "block";
saveFolderTreeState(state); saveFolderTreeState(state);
} }
@@ -307,20 +320,18 @@ function folderDropHandler(event) {
} }
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []); const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
if (filesToMove.length === 0) return; if (filesToMove.length === 0) return;
fetch("/api/file/moveFiles.php", {
fetchWithCsrf("/api/file/moveFiles.php", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
},
body: JSON.stringify({ body: JSON.stringify({
source: dragData.sourceFolder, source: dragData.sourceFolder,
files: filesToMove, files: filesToMove,
destination: dropFolder destination: dropFolder
}) })
}) })
.then(response => response.json()) .then(safeJson)
.then(data => { .then(data => {
if (data.success) { if (data.success) {
showToast(`File(s) moved successfully to ${dropFolder}!`); showToast(`File(s) moved successfully to ${dropFolder}!`);
@@ -338,7 +349,7 @@ function folderDropHandler(event) {
/* ---------------------- /* ----------------------
Main Folder Tree Rendering and Event Binding Main Folder Tree Rendering and Event Binding
----------------------*/ ----------------------*/
// --- Helpers for safe breadcrumb rendering --- // Safe breadcrumb DOM builder
function renderBreadcrumbFragment(folderPath) { function renderBreadcrumbFragment(folderPath) {
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
const parts = folderPath.split("/"); const parts = folderPath.split("/");
@@ -363,49 +374,52 @@ function renderBreadcrumbFragment(folderPath) {
export function updateBreadcrumbTitle(folder) { export function updateBreadcrumbTitle(folder) {
const titleEl = document.getElementById("fileListTitle"); const titleEl = document.getElementById("fileListTitle");
if (!titleEl) return;
titleEl.textContent = ""; titleEl.textContent = "";
titleEl.appendChild(document.createTextNode(t("files_in") + " (")); titleEl.appendChild(document.createTextNode(t("files_in") + " ("));
titleEl.appendChild(renderBreadcrumbFragment(folder)); titleEl.appendChild(renderBreadcrumbFragment(folder));
titleEl.appendChild(document.createTextNode(")")); titleEl.appendChild(document.createTextNode(")"));
setupBreadcrumbDelegation(); setupBreadcrumbDelegation();
// Ensure context menu delegation is hooked to the dynamic breadcrumb container
bindFolderManagerContextMenu();
} }
export async function loadFolderTree(selectedFolder) { export async function loadFolderTree(selectedFolder) {
try { try {
// Check if the user has folder-only permission. // Check if the user has folder-only permission (server-authoritative).
await checkUserFolderPermission(); await checkUserFolderPermission();
// Determine effective root folder. // Determine effective root folder.
const username = localStorage.getItem("username") || "root"; const username = localStorage.getItem("username") || "root";
let effectiveRoot = "root"; let effectiveRoot = "root";
let effectiveLabel = "(Root)"; let effectiveLabel = "(Root)";
if (window.userFolderOnly) { if (window.userFolderOnly && username) {
effectiveRoot = username; // Use the username as the personal root. effectiveRoot = username; // personal root
effectiveLabel = `(Root)`; effectiveLabel = `(Root)`;
// Force override of any saved folder.
localStorage.setItem("lastOpenedFolder", username); localStorage.setItem("lastOpenedFolder", username);
window.currentFolder = username; window.currentFolder = username;
} else { } else {
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root"; window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
} }
// Build fetch URL. // Fetch folder list from the server (server enforces scope).
let fetchUrl = '/api/folder/getFolderList.php'; const res = await fetchWithCsrf('/api/folder/getFolderList.php', {
if (window.userFolderOnly) { method: 'GET',
fetchUrl += '?restricted=1'; credentials: 'include'
} });
console.log("Fetching folder list from:", fetchUrl);
// Fetch folder list from the server. if (res.status === 401) {
const response = await fetch(fetchUrl);
if (response.status === 401) {
console.error("Unauthorized: Please log in to view folders.");
showToast("Session expired. Please log in again."); showToast("Session expired. Please log in again.");
window.location.href = "/api/auth/logout.php"; window.location.href = "/api/auth/logout.php";
return; return;
} }
let folderData = await response.json(); if (res.status === 403) {
console.log("Folder data received:", folderData); showToast("You don't have permission to view folders.");
return;
}
const folderData = await safeJson(res);
let folders = []; let folders = [];
if (Array.isArray(folderData) && folderData.length && typeof folderData[0] === "object" && folderData[0].folder) { if (Array.isArray(folderData) && folderData.length && typeof folderData[0] === "object" && folderData[0].folder) {
folders = folderData.map(item => item.folder); folders = folderData.map(item => item.folder);
@@ -413,13 +427,12 @@ export async function loadFolderTree(selectedFolder) {
folders = folderData; folders = folderData;
} }
// Remove any global "root" entry. // Remove any global "root" entry (server shouldn't return it, but be safe).
folders = folders.filter(folder => folder.toLowerCase() !== "root"); folders = folders.filter(folder => folder.toLowerCase() !== "root");
// If restricted, filter folders: keep only those that start with effectiveRoot + "/" (do not include effectiveRoot itself). // If restricted, filter client-side view to subtree for UX (server still enforces).
if (window.userFolderOnly && effectiveRoot !== "root") { if (window.userFolderOnly && effectiveRoot !== "root") {
folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/")); folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/"));
// Force current folder to be the effective root.
localStorage.setItem("lastOpenedFolder", effectiveRoot); localStorage.setItem("lastOpenedFolder", effectiveRoot);
window.currentFolder = effectiveRoot; window.currentFolder = effectiveRoot;
} }
@@ -455,8 +468,9 @@ export async function loadFolderTree(selectedFolder) {
} }
localStorage.setItem("lastOpenedFolder", window.currentFolder); localStorage.setItem("lastOpenedFolder", window.currentFolder);
// Initial breadcrumb update // Initial breadcrumb + file list
updateBreadcrumbTitle(window.currentFolder); updateBreadcrumbTitle(window.currentFolder);
applyFolderCapabilities(window.currentFolder);
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
const folderState = loadFolderTreeState(); const folderState = loadFolderTreeState();
@@ -480,8 +494,8 @@ export async function loadFolderTree(selectedFolder) {
window.currentFolder = selected; window.currentFolder = selected;
localStorage.setItem("lastOpenedFolder", selected); localStorage.setItem("lastOpenedFolder", selected);
// Safe breadcrumb update
updateBreadcrumbTitle(selected); updateBreadcrumbTitle(selected);
applyFolderCapabilities(selected);
loadFileList(selected); loadFileList(selected);
}); });
}); });
@@ -493,7 +507,7 @@ export async function loadFolderTree(selectedFolder) {
e.stopPropagation(); e.stopPropagation();
const nestedUl = container.querySelector("#rootRow + ul"); const nestedUl = container.querySelector("#rootRow + ul");
if (nestedUl) { if (nestedUl) {
let state = loadFolderTreeState(); const state = loadFolderTreeState();
if (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded")) { if (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded")) {
nestedUl.classList.remove("collapsed"); nestedUl.classList.remove("collapsed");
nestedUl.classList.add("expanded"); nestedUl.classList.add("expanded");
@@ -516,7 +530,7 @@ export async function loadFolderTree(selectedFolder) {
e.stopPropagation(); e.stopPropagation();
const siblingUl = this.parentNode.querySelector("ul"); const siblingUl = this.parentNode.querySelector("ul");
const folderPath = this.getAttribute("data-folder"); const folderPath = this.getAttribute("data-folder");
let state = loadFolderTreeState(); const state = loadFolderTreeState();
if (siblingUl) { if (siblingUl) {
if (siblingUl.classList.contains("collapsed") || !siblingUl.classList.contains("expanded")) { if (siblingUl.classList.contains("collapsed") || !siblingUl.classList.contains("expanded")) {
siblingUl.classList.remove("collapsed"); siblingUl.classList.remove("collapsed");
@@ -536,9 +550,11 @@ export async function loadFolderTree(selectedFolder) {
} catch (error) { } catch (error) {
console.error("Error loading folder tree:", error); console.error("Error loading folder tree:", error);
if (error.status === 403) {
showToast("You don't have permission to view folders.");
}
} }
} }
// For backward compatibility. // For backward compatibility.
export function loadFolderList(selectedFolder) { export function loadFolderList(selectedFolder) {
@@ -548,8 +564,11 @@ export function loadFolderList(selectedFolder) {
/* ---------------------- /* ----------------------
Folder Management (Rename, Delete, Create) Folder Management (Rename, Delete, Create)
----------------------*/ ----------------------*/
document.getElementById("renameFolderBtn").addEventListener("click", openRenameFolderModal); const renameBtn = document.getElementById("renameFolderBtn");
document.getElementById("deleteFolderBtn").addEventListener("click", openDeleteFolderModal); if (renameBtn) renameBtn.addEventListener("click", openRenameFolderModal);
const deleteBtn = document.getElementById("deleteFolderBtn");
if (deleteBtn) deleteBtn.addEventListener("click", openDeleteFolderModal);
export function openRenameFolderModal() { export function openRenameFolderModal() {
const selectedFolder = window.currentFolder || "root"; const selectedFolder = window.currentFolder || "root";
@@ -558,45 +577,50 @@ export function openRenameFolderModal() {
return; return;
} }
const parts = selectedFolder.split("/"); const parts = selectedFolder.split("/");
document.getElementById("newRenameFolderName").value = parts[parts.length - 1];
document.getElementById("renameFolderModal").style.display = "block";
setTimeout(() => {
const input = document.getElementById("newRenameFolderName"); const input = document.getElementById("newRenameFolderName");
const modal = document.getElementById("renameFolderModal");
if (!input || !modal) return;
input.value = parts[parts.length - 1];
modal.style.display = "block";
setTimeout(() => {
input.focus(); input.focus();
input.select(); input.select();
}, 100); }, 100);
} }
document.getElementById("cancelRenameFolder").addEventListener("click", function () { const cancelRename = document.getElementById("cancelRenameFolder");
document.getElementById("renameFolderModal").style.display = "none"; if (cancelRename) {
document.getElementById("newRenameFolderName").value = ""; cancelRename.addEventListener("click", function () {
const modal = document.getElementById("renameFolderModal");
const input = document.getElementById("newRenameFolderName");
if (modal) modal.style.display = "none";
if (input) input.value = "";
}); });
}
attachEnterKeyListener("renameFolderModal", "submitRenameFolder"); attachEnterKeyListener("renameFolderModal", "submitRenameFolder");
document.getElementById("submitRenameFolder").addEventListener("click", function (event) {
const submitRename = document.getElementById("submitRenameFolder");
if (submitRename) {
submitRename.addEventListener("click", function (event) {
event.preventDefault(); event.preventDefault();
const selectedFolder = window.currentFolder || "root"; const selectedFolder = window.currentFolder || "root";
const newNameBasename = document.getElementById("newRenameFolderName").value.trim(); const input = document.getElementById("newRenameFolderName");
if (!input) return;
const newNameBasename = input.value.trim();
if (!newNameBasename || newNameBasename === selectedFolder.split("/").pop()) { if (!newNameBasename || newNameBasename === selectedFolder.split("/").pop()) {
showToast("Please enter a valid new folder name."); showToast("Please enter a valid new folder name.");
return; return;
} }
const parentPath = getParentFolder(selectedFolder); const parentPath = getParentFolder(selectedFolder);
const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename; const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename;
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
if (!csrfToken) { fetchWithCsrf("/api/folder/renameFolder.php", {
showToast("CSRF token not loaded yet! Please try again.");
return;
}
fetch("/api/folder/renameFolder.php", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({ oldFolder: window.currentFolder, newFolder: newFolderFull }) body: JSON.stringify({ oldFolder: window.currentFolder, newFolder: newFolderFull })
}) })
.then(response => response.json()) .then(safeJson)
.then(data => { .then(data => {
if (data.success) { if (data.success) {
showToast("Folder renamed successfully!"); showToast("Folder renamed successfully!");
@@ -609,10 +633,13 @@ document.getElementById("submitRenameFolder").addEventListener("click", function
}) })
.catch(error => console.error("Error renaming folder:", error)) .catch(error => console.error("Error renaming folder:", error))
.finally(() => { .finally(() => {
document.getElementById("renameFolderModal").style.display = "none"; const modal = document.getElementById("renameFolderModal");
document.getElementById("newRenameFolderName").value = ""; const input2 = document.getElementById("newRenameFolderName");
if (modal) modal.style.display = "none";
if (input2) input2.value = "";
}); });
}); });
}
export function openDeleteFolderModal() { export function openDeleteFolderModal() {
const selectedFolder = window.currentFolder || "root"; const selectedFolder = window.currentFolder || "root";
@@ -620,27 +647,34 @@ export function openDeleteFolderModal() {
showToast("Please select a valid folder to delete."); showToast("Please select a valid folder to delete.");
return; return;
} }
document.getElementById("deleteFolderMessage").textContent = const msgEl = document.getElementById("deleteFolderMessage");
"Are you sure you want to delete folder " + selectedFolder + "?"; const modal = document.getElementById("deleteFolderModal");
document.getElementById("deleteFolderModal").style.display = "block"; if (!msgEl || !modal) return;
msgEl.textContent = "Are you sure you want to delete folder " + selectedFolder + "?";
modal.style.display = "block";
} }
document.getElementById("cancelDeleteFolder").addEventListener("click", function () { const cancelDelete = document.getElementById("cancelDeleteFolder");
document.getElementById("deleteFolderModal").style.display = "none"; if (cancelDelete) {
cancelDelete.addEventListener("click", function () {
const modal = document.getElementById("deleteFolderModal");
if (modal) modal.style.display = "none";
}); });
}
attachEnterKeyListener("deleteFolderModal", "confirmDeleteFolder"); attachEnterKeyListener("deleteFolderModal", "confirmDeleteFolder");
document.getElementById("confirmDeleteFolder").addEventListener("click", function () {
const confirmDelete = document.getElementById("confirmDeleteFolder");
if (confirmDelete) {
confirmDelete.addEventListener("click", function () {
const selectedFolder = window.currentFolder || "root"; const selectedFolder = window.currentFolder || "root";
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
fetch("/api/folder/deleteFolder.php", { fetchWithCsrf("/api/folder/deleteFolder.php", {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json", credentials: "include",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({ folder: selectedFolder }) body: JSON.stringify({ folder: selectedFolder })
}) })
.then(response => response.json()) .then(safeJson)
.then(data => { .then(data => {
if (data.success) { if (data.success) {
showToast("Folder deleted successfully!"); showToast("Folder deleted successfully!");
@@ -653,22 +687,38 @@ document.getElementById("confirmDeleteFolder").addEventListener("click", functio
}) })
.catch(error => console.error("Error deleting folder:", error)) .catch(error => console.error("Error deleting folder:", error))
.finally(() => { .finally(() => {
document.getElementById("deleteFolderModal").style.display = "none"; const modal = document.getElementById("deleteFolderModal");
if (modal) modal.style.display = "none";
}); });
}); });
}
document.getElementById("createFolderBtn").addEventListener("click", function () { const createBtn = document.getElementById("createFolderBtn");
document.getElementById("createFolderModal").style.display = "block"; if (createBtn) {
document.getElementById("newFolderName").focus(); createBtn.addEventListener("click", function () {
const modal = document.getElementById("createFolderModal");
const input = document.getElementById("newFolderName");
if (modal) modal.style.display = "block";
if (input) input.focus();
}); });
}
document.getElementById("cancelCreateFolder").addEventListener("click", function () { const cancelCreate = document.getElementById("cancelCreateFolder");
document.getElementById("createFolderModal").style.display = "none"; if (cancelCreate) {
document.getElementById("newFolderName").value = ""; cancelCreate.addEventListener("click", function () {
const modal = document.getElementById("createFolderModal");
const input = document.getElementById("newFolderName");
if (modal) modal.style.display = "none";
if (input) input.value = "";
}); });
}
attachEnterKeyListener("createFolderModal", "submitCreateFolder"); attachEnterKeyListener("createFolderModal", "submitCreateFolder");
document.getElementById("submitCreateFolder").addEventListener("click", async () => {
const folderInput = document.getElementById("newFolderName").value.trim(); const submitCreate = document.getElementById("submitCreateFolder");
if (submitCreate) {
submitCreate.addEventListener("click", async () => {
const input = document.getElementById("newFolderName");
const folderInput = input ? input.value.trim() : "";
if (!folderInput) return showToast("Please enter a folder name."); if (!folderInput) return showToast("Please enter a folder name.");
const selectedFolder = window.currentFolder || "root"; const selectedFolder = window.currentFolder || "root";
@@ -685,23 +735,12 @@ document.getElementById("submitCreateFolder").addEventListener("click", async ()
fetchWithCsrf("/api/folder/createFolder.php", { fetchWithCsrf("/api/folder/createFolder.php", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ folderName: folderInput, parent }) body: JSON.stringify({ folderName: folderInput, parent })
}) })
.then(async res => { .then(safeJson)
if (!res.ok) {
// pull out a JSON error, or fallback to status text
let err;
try {
const j = await res.json();
err = j.error || j.message || res.statusText;
} catch {
err = res.statusText;
}
throw new Error(err);
}
return res.json();
})
.then(data => { .then(data => {
if (!data.success) throw new Error(data.error || "Server rejected the request");
showToast("Folder created!"); showToast("Folder created!");
const full = parent ? `${parent}/${folderInput}` : folderInput; const full = parent ? `${parent}/${folderInput}` : folderInput;
window.currentFolder = full; window.currentFolder = full;
@@ -712,10 +751,13 @@ document.getElementById("submitCreateFolder").addEventListener("click", async ()
showToast("Error creating folder: " + e.message); showToast("Error creating folder: " + e.message);
}) })
.finally(() => { .finally(() => {
document.getElementById("createFolderModal").style.display = "none"; const modal = document.getElementById("createFolderModal");
document.getElementById("newFolderName").value = ""; const input2 = document.getElementById("newFolderName");
if (modal) modal.style.display = "none";
if (input2) input2.value = "";
}); });
}); });
}
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ---------- // ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
export function showFolderManagerContextMenu(x, y, menuItems) { export function showFolderManagerContextMenu(x, y, menuItems) {
@@ -773,21 +815,28 @@ export function hideFolderManagerContextMenu() {
} }
function folderManagerContextMenuHandler(e) { function folderManagerContextMenuHandler(e) {
e.preventDefault();
e.stopPropagation();
const target = e.target.closest(".folder-option, .breadcrumb-link"); const target = e.target.closest(".folder-option, .breadcrumb-link");
if (!target) return; if (!target) return;
e.preventDefault();
e.stopPropagation();
const folder = target.getAttribute("data-folder"); const folder = target.getAttribute("data-folder");
if (!folder) return; if (!folder) return;
window.currentFolder = folder; window.currentFolder = folder;
// Visual selection
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected")); document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));
target.classList.add("selected"); target.classList.add("selected");
const menuItems = [ const menuItems = [
{ {
label: t("create_folder"), label: t("create_folder"),
action: () => { action: () => {
document.getElementById("createFolderModal").style.display = "block"; const modal = document.getElementById("createFolderModal");
document.getElementById("newFolderName").focus(); const input = document.getElementById("newFolderName");
if (modal) modal.style.display = "block";
if (input) input.focus();
} }
}, },
{ {
@@ -806,17 +855,34 @@ function folderManagerContextMenuHandler(e) {
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems); showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
} }
// Delegate contextmenu so it works with dynamically re-rendered breadcrumbs
function bindFolderManagerContextMenu() { function bindFolderManagerContextMenu() {
const container = document.getElementById("folderTreeContainer"); const tree = document.getElementById("folderTreeContainer");
if (container) { if (tree) {
container.removeEventListener("contextmenu", folderManagerContextMenuHandler); // remove old bound handler if present
container.addEventListener("contextmenu", folderManagerContextMenuHandler, false); if (tree._ctxHandler) {
tree.removeEventListener("contextmenu", tree._ctxHandler, false);
}
tree._ctxHandler = function (e) {
const onOption = e.target.closest(".folder-option");
if (!onOption) return;
folderManagerContextMenuHandler(e);
};
tree.addEventListener("contextmenu", tree._ctxHandler, false);
}
const title = document.getElementById("fileListTitle");
if (title) {
if (title._ctxHandler) {
title.removeEventListener("contextmenu", title._ctxHandler, false);
}
title._ctxHandler = function (e) {
const onCrumb = e.target.closest(".breadcrumb-link");
if (!onCrumb) return;
folderManagerContextMenuHandler(e);
};
title.addEventListener("contextmenu", title._ctxHandler, false);
} }
const breadcrumbNodes = document.querySelectorAll(".breadcrumb-link");
breadcrumbNodes.forEach(node => {
node.removeEventListener("contextmenu", folderManagerContextMenuHandler);
node.addEventListener("contextmenu", folderManagerContextMenuHandler, false);
});
} }
document.addEventListener("click", function () { document.addEventListener("click", function () {
@@ -825,8 +891,8 @@ document.addEventListener("click", function () {
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("keydown", function (e) { document.addEventListener("keydown", function (e) {
const tag = e.target.tagName.toLowerCase(); const tag = e.target.tagName ? e.target.tagName.toLowerCase() : "";
if (tag === "input" || tag === "textarea" || e.target.isContentEditable) { if (tag === "input" || tag === "textarea" || (e.target && e.target.isContentEditable)) {
return; return;
} }
if (e.key === "Delete" || e.key === "Backspace" || e.keyCode === 46 || e.keyCode === 8) { if (e.key === "Delete" || e.key === "Backspace" || e.keyCode === 46 || e.keyCode === 8) {
@@ -847,7 +913,6 @@ document.addEventListener("DOMContentLoaded", function () {
showToast("Please select a valid folder to share."); showToast("Please select a valid folder to share.");
return; return;
} }
// Call the folder share modal from the module.
openFolderShareModal(selectedFolder); openFolderShareModal(selectedFolder);
}); });
} else { } else {
@@ -855,4 +920,5 @@ document.addEventListener("DOMContentLoaded", function () {
} }
}); });
// Initial context menu delegation bind
bindFolderManagerContextMenu(); bindFolderManagerContextMenu();

View File

@@ -51,6 +51,52 @@ async function fetchWithCsrfAndRefresh(input, init = {}) {
// Replace global fetch with the wrapped version so *all* callers benefit. // Replace global fetch with the wrapped version so *all* callers benefit.
window.fetch = fetchWithCsrfAndRefresh; window.fetch = fetchWithCsrfAndRefresh;
/* =========================
SAFE API HELPERS
========================= */
export async function apiGETJSON(url, opts = {}) {
const res = await fetch(url, { credentials: "include", ...opts });
if (res.status === 401) throw new Error("auth");
if (res.status === 403) throw new Error("forbidden");
if (!res.ok) throw new Error(`http ${res.status}`);
try { return await res.json(); } catch { return {}; }
}
export async function apiPOSTJSON(url, body, opts = {}) {
const headers = {
"Content-Type": "application/json",
"X-CSRF-Token": getCsrfToken(),
...(opts.headers || {})
};
const res = await fetch(url, {
method: "POST",
credentials: "include",
headers,
body: JSON.stringify(body ?? {}),
...opts
});
if (res.status === 401) throw new Error("auth");
if (res.status === 403) throw new Error("forbidden");
if (!res.ok) throw new Error(`http ${res.status}`);
try { return await res.json(); } catch { return {}; }
}
// Optional: expose on window for legacy callers
window.apiGETJSON = apiGETJSON;
window.apiPOSTJSON = apiPOSTJSON;
// Global handler to keep UX friendly if something forgets to catch
window.addEventListener("unhandledrejection", (ev) => {
const msg = (ev?.reason && ev.reason.message) || "";
if (msg === "auth") {
showToast(t("please_sign_in_again") || "Please sign in again.", "error");
ev.preventDefault();
} else if (msg === "forbidden") {
showToast(t("no_access_to_resource") || "You dont have access to that.", "error");
ev.preventDefault();
}
});
/* ========================= /* =========================
APP INIT APP INIT
========================= */ ========================= */
@@ -94,7 +140,7 @@ export function initializeApp() {
initUpload(); initUpload();
loadFolderTree(); loadFolderTree();
setupTrashRestoreDelete(); setupTrashRestoreDelete();
loadAdminConfigFunc(); // NOTE: loadAdminConfigFunc() is called once in DOMContentLoaded; calling here would duplicate requests.
const helpBtn = document.getElementById("folderHelpBtn"); const helpBtn = document.getElementById("folderHelpBtn");
const helpTooltip = document.getElementById("folderHelpTooltip"); const helpTooltip = document.getElementById("folderHelpTooltip");
@@ -170,6 +216,7 @@ window.openDownloadModal = openDownloadModal;
window.currentFolder = "root"; window.currentFolder = "root";
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
// Load admin config once here; non-admins may get 403, which is fine.
loadAdminConfigFunc(); loadAdminConfigFunc();
// i18n // i18n

View File

@@ -13,10 +13,12 @@ if (
} }
// ─── 1) Bootstrap & load models ───────────────────────────────────────────── // ─── 1) Bootstrap & load models ─────────────────────────────────────────────
require_once __DIR__ . '/../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT require_once __DIR__ . '/../config/config.php'; // UPLOAD_DIR, META_DIR, loadUserPermissions(), etc.
require_once __DIR__ . '/../vendor/autoload.php'; // Composer & SabreDAV require_once __DIR__ . '/../vendor/autoload.php'; // Composer & SabreDAV
require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole(), loadFolderPermission() require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole()
require_once __DIR__ . '/../src/models/AdminModel.php';// AdminModel::getConfig() require_once __DIR__ . '/../src/models/AdminModel.php';// AdminModel::getConfig()
require_once __DIR__ . '/../src/lib/ACL.php'; // ACL checks
require_once __DIR__ . '/../src/webdav/CurrentUser.php';
// ─── 1.1) Global WebDAV feature toggle ────────────────────────────────────── // ─── 1.1) Global WebDAV feature toggle ──────────────────────────────────────
$adminConfig = AdminModel::getConfig(); $adminConfig = AdminModel::getConfig();
@@ -27,42 +29,46 @@ if (!$enableWebDAV) {
exit; exit;
} }
// ─── 2) Load WebDAV directory implementation ────────────────────────── // ─── 2) Load WebDAV directory implementation (ACL-aware) ────────────────────
require_once __DIR__ . '/../src/webdav/FileRiseDirectory.php'; require_once __DIR__ . '/../src/webdav/FileRiseDirectory.php';
use Sabre\DAV\Server; use Sabre\DAV\Server;
use Sabre\DAV\Auth\Backend\BasicCallBack; use Sabre\DAV\Auth\Backend\BasicCallBack;
use Sabre\DAV\Auth\Plugin as AuthPlugin; use Sabre\DAV\Auth\Plugin as AuthPlugin;
use Sabre\DAV\Locks\Plugin as LocksPlugin; use Sabre\DAV\Locks\Plugin as LocksPlugin;
use Sabre\DAV\Locks\Backend\File as LocksFileBackend; use Sabre\DAV\Locks\Backend\File as LocksFileBackend;
use FileRise\WebDAV\FileRiseDirectory; use FileRise\WebDAV\FileRiseDirectory;
use FileRise\WebDAV\CurrentUser;
// ─── 3) HTTPBasic backend ───────────────────────────────────────────────── // ─── 3) HTTP-Basic backend (delegates to your AuthModel) ────────────────────
$authBackend = new BasicCallBack(function(string $user, string $pass) { $authBackend = new BasicCallBack(function(string $user, string $pass) {
return \AuthModel::authenticate($user, $pass) !== false; return \AuthModel::authenticate($user, $pass) !== false;
}); });
$authPlugin = new AuthPlugin($authBackend, 'FileRise'); $authPlugin = new AuthPlugin($authBackend, 'FileRise');
// ─── 4) Determine user scope ──────────────────────────────────────────────── // ─── 4) Resolve authenticated user + perms ──────────────────────────────────
$user = $_SERVER['PHP_AUTH_USER'] ?? ''; $user = $_SERVER['PHP_AUTH_USER'] ?? '';
if ($user === '') {
header('HTTP/1.1 401 Unauthorized');
header('WWW-Authenticate: Basic realm="FileRise"');
echo 'Authentication required.';
exit;
}
$perms = is_callable('loadUserPermissions') ? (loadUserPermissions($user) ?: []) : [];
$isAdmin = (\AuthModel::getUserRole($user) === '1'); $isAdmin = (\AuthModel::getUserRole($user) === '1');
$folderOnly = (bool)\AuthModel::loadFolderPermission($user);
if ($isAdmin || !$folderOnly) { // set for metadata attribution in WebDAV writes
// Admins (or users without folder-only restriction) see the full /uploads CurrentUser::set($user);
// ─── 5) Mount the real uploads root; ACL filters everything at node level ───
$rootPath = rtrim(UPLOAD_DIR, '/\\'); $rootPath = rtrim(UPLOAD_DIR, '/\\');
} else {
// Folderonly users see only /uploads/{username}
$rootPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $user;
if (!is_dir($rootPath)) {
mkdir($rootPath, 0755, true);
}
}
// ─── 5) Spin up SabreDAV ────────────────────────────────────────────────────
$server = new Server([ $server = new Server([
new FileRiseDirectory($rootPath, $user, $folderOnly), new FileRiseDirectory($rootPath, $user, $isAdmin, $perms),
]); ]);
// Auth + Locks
$server->addPlugin($authPlugin); $server->addPlugin($authPlugin);
$server->addPlugin( $server->addPlugin(
new LocksPlugin( new LocksPlugin(
@@ -70,5 +76,8 @@ $server->addPlugin(
) )
); );
// Base URI (adjust if you serve from a subdir or rewrite rule)
$server->setBaseUri('/webdav.php/'); $server->setBaseUri('/webdav.php/');
// Execute
$server->exec(); $server->exec();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
// src/controllers/UploadController.php // src/controllers/UploadController.php
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/UploadModel.php'; require_once PROJECT_ROOT . '/src/models/UploadModel.php';
class UploadController { class UploadController {
@@ -73,68 +74,79 @@ class UploadController {
public function handleUpload(): void { public function handleUpload(): void {
header('Content-Type: application/json'); header('Content-Type: application/json');
// // ---- 1) CSRF (header or form field) ----
// 1) CSRF pull from header or POST fields $headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
//
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$received = ''; $received = '';
if (!empty($headersArr['x-csrf-token'])) { if (!empty($headersArr['x-csrf-token'])) {
$received = trim($headersArr['x-csrf-token']); $received = trim($headersArr['x-csrf-token']);
} elseif (!empty($_POST['csrf_token'])) { } elseif (!empty($_POST['csrf_token'])) {
$received = trim($_POST['csrf_token']); $received = trim($_POST['csrf_token']);
} elseif (!empty($_POST['upload_token'])) { } elseif (!empty($_POST['upload_token'])) {
// legacy alias
$received = trim($_POST['upload_token']); $received = trim($_POST['upload_token']);
} }
// 1a) If it doesnt match, soft-fail: send new token and let client retry
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) { if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
// regenerate // Soft-fail so client can retry with refreshed token
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
// tell client “please retry with this new token”
http_response_code(200); http_response_code(200);
echo json_encode([ echo json_encode([
'csrf_expired' => true, 'csrf_expired' => true,
'csrf_token' => $_SESSION['csrf_token'] 'csrf_token' => $_SESSION['csrf_token']
]); ]);
exit; return;
} }
// // ---- 2) Auth + account-level flags ----
// 2) Auth checks if (empty($_SESSION['authenticated'])) {
//
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401); http_response_code(401);
echo json_encode(["error" => "Unauthorized"]); echo json_encode(['error' => 'Unauthorized']);
exit; return;
}
$userPerms = loadUserPermissions($_SESSION['username']);
if (!empty($userPerms['disableUpload'])) {
http_response_code(403);
echo json_encode(["error" => "Upload disabled for this user."]);
exit;
} }
// $username = (string)($_SESSION['username'] ?? '');
// 3) Delegate the actual file handling $userPerms = loadUserPermissions($username) ?: [];
// $isAdmin = ACL::isAdmin($userPerms);
// Admins should never be blocked by account-level "disableUpload"
if (!$isAdmin && !empty($userPerms['disableUpload'])) {
http_response_code(403);
echo json_encode(['error' => 'Upload disabled for this user.']);
return;
}
// ---- 3) Folder-level WRITE permission (ACL) ----
// Always require client to send the folder; fall back to GET if needed.
$folderParam = isset($_POST['folder']) ? (string)$_POST['folder'] : (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
$targetFolder = ACL::normalizeFolder($folderParam);
// Admins bypass folder canWrite checks
if (!$isAdmin && !ACL::canWrite($username, $userPerms, $targetFolder)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
return;
}
// ---- 4) Delegate to model (actual file/chunk processing) ----
// (Optionally re-check in UploadModel before finalizing.)
$result = UploadModel::handleUpload($_POST, $_FILES); $result = UploadModel::handleUpload($_POST, $_FILES);
// // ---- 5) Response ----
// 4) Respond
//
if (isset($result['error'])) { if (isset($result['error'])) {
http_response_code(400); http_response_code(400);
echo json_encode($result); echo json_encode($result);
exit; return;
} }
if (isset($result['status'])) { if (isset($result['status'])) {
// e.g., {"status":"chunk uploaded"}
echo json_encode($result); echo json_encode($result);
exit; return;
} }
// fullupload redirect echo json_encode([
$_SESSION['upload_message'] = "File uploaded successfully."; 'success' => 'File uploaded successfully',
exit; 'newFilename' => $result['newFilename'] ?? null
]);
} }
/** /**
@@ -176,24 +188,21 @@ class UploadController {
public function removeChunks(): void { public function removeChunks(): void {
header('Content-Type: application/json'); header('Content-Type: application/json');
// CSRF Protection: Validate token from POST data.
$receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : ''; $receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : '';
if ($receivedToken !== $_SESSION['csrf_token']) { if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403); http_response_code(403);
echo json_encode(["error" => "Invalid CSRF token"]); echo json_encode(['error' => 'Invalid CSRF token']);
exit; return;
} }
// Check that the folder parameter is provided.
if (!isset($_POST['folder'])) { if (!isset($_POST['folder'])) {
http_response_code(400); http_response_code(400);
echo json_encode(["error" => "No folder specified"]); echo json_encode(['error' => 'No folder specified']);
exit; return;
} }
$folder = $_POST['folder']; $folder = (string)$_POST['folder'];
$result = UploadModel::removeChunks($folder); $result = UploadModel::removeChunks($folder);
echo json_encode($result); echo json_encode($result);
exit;
} }
} }

View File

@@ -62,10 +62,31 @@ class UserController
private static function requireAdmin(): void private static function requireAdmin(): void
{ {
self::requireAuth(); self::requireAuth();
if (empty($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true) {
http_response_code(401); // Prefer the session flag
$isAdmin = (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true);
// Fallback: check the users role in storage (e.g., users.txt/DB)
if (!$isAdmin) {
$u = $_SESSION['username'] ?? '';
if ($u) {
try {
// UserModel::getUserRole($u) should return '1' for admins
$isAdmin = (UserModel::getUserRole($u) === '1');
if ($isAdmin) {
// Normalize session so downstream ACL checks see admin
$_SESSION['isAdmin'] = true;
}
} catch (\Throwable $e) {
// ignore and continue to deny
}
}
}
if (!$isAdmin) {
http_response_code(403);
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode(['error' => 'Unauthorized']); echo json_encode(['error' => 'Admin privileges required.']);
exit; exit;
} }
} }

347
src/lib/ACL.php Normal file
View File

@@ -0,0 +1,347 @@
<?php
// src/lib/ACL.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/config/config.php';
class ACL
{
/** In-memory cache of the ACL file. */
private static $cache = null;
/** Absolute path to folder_acl.json */
private static $path = null;
/** Capability buckets we store per folder. */
private const BUCKETS = ['owners','read','write','share','read_own']; // + read_own (view own only)
/** Compute/cache the ACL storage path. */
private static function path(): string {
if (!self::$path) {
self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
}
return self::$path;
}
/** Normalize folder names (slashes + root). */
public static function normalizeFolder(string $f): string {
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
if ($f === '' || $f === 'root') return 'root';
return $f;
}
public static function purgeUser(string $user): bool {
$user = (string)$user;
$acl = self::$cache ?? self::loadFresh();
$changed = false;
foreach ($acl['folders'] as $folder => &$rec) {
foreach (self::BUCKETS as $k) {
$before = $rec[$k] ?? [];
$rec[$k] = array_values(array_filter($before, fn($u) => strcasecmp((string)$u, $user) !== 0));
if ($rec[$k] !== $before) $changed = true;
}
}
unset($rec);
return $changed ? self::save($acl) : true;
}
/** Load ACL fresh from disk, create/heal if needed. */
private static function loadFresh(): array {
$path = self::path();
if (!is_file($path)) {
@mkdir(dirname($path), 0755, true);
$init = [
'folders' => [
'root' => [
'owners' => ['admin'],
'read' => ['admin'],
'write' => ['admin'],
'share' => ['admin'],
'read_own'=> [], // new bucket; empty by default
],
],
'groups' => [],
];
@file_put_contents($path, json_encode($init, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
}
$json = (string) @file_get_contents($path);
$data = json_decode($json, true);
if (!is_array($data)) $data = [];
// Normalize shape
$data['folders'] = isset($data['folders']) && is_array($data['folders']) ? $data['folders'] : [];
$data['groups'] = isset($data['groups']) && is_array($data['groups']) ? $data['groups'] : [];
// Ensure root exists and has all buckets
if (!isset($data['folders']['root']) || !is_array($data['folders']['root'])) {
$data['folders']['root'] = [
'owners' => ['admin'],
'read' => ['admin'],
'write' => ['admin'],
'share' => ['admin'],
'read_own' => [],
];
} else {
foreach (self::BUCKETS as $k) {
if (!isset($data['folders']['root'][$k]) || !is_array($data['folders']['root'][$k])) {
// sensible defaults: admin in the classic buckets, empty for read_own
$data['folders']['root'][$k] = ($k === 'read_own') ? [] : ['admin'];
}
}
}
// Heal any folder records
$healed = false;
foreach ($data['folders'] as $folder => &$rec) {
if (!is_array($rec)) { $rec = []; $healed = true; }
foreach (self::BUCKETS as $k) {
$v = $rec[$k] ?? [];
if (!is_array($v)) { $v = []; $healed = true; }
$v = array_values(array_unique(array_map('strval', $v)));
if (($rec[$k] ?? null) !== $v) { $rec[$k] = $v; $healed = true; }
}
}
unset($rec);
self::$cache = $data;
// Persist back if we healed anything
if ($healed) {
@file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
}
return $data;
}
/** Persist ACL to disk and refresh cache. */
private static function save(array $acl): bool {
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
if ($ok) self::$cache = $acl;
return $ok;
}
/** Get a bucket list (owners/read/write/share/read_own) for a folder (explicit only). */
private static function listFor(string $folder, string $key): array {
$acl = self::$cache ?? self::loadFresh();
$f = $acl['folders'][$folder] ?? null;
return is_array($f[$key] ?? null) ? $f[$key] : [];
}
/** Ensure a folder record exists (giving an initial owner). */
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void {
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
if (!isset($acl['folders'][$folder])) {
$acl['folders'][$folder] = [
'owners' => [$owner],
'read' => [$owner],
'write' => [$owner],
'share' => [$owner],
'read_own' => [],
];
self::save($acl);
}
}
/** True if this request is admin. */
public static function isAdmin(array $perms = []): bool {
if (!empty($_SESSION['isAdmin'])) return true;
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
if (isset($perms['role']) && (string)$perms['role'] === '1') return true;
if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true;
// Optional: if you configured DEFAULT_ADMIN_USER, treat that username as admin
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) {
return true;
}
return false;
}
/** Case-insensitive membership in a capability bucket. $cap: owner|owners|read|write|share|read_own */
public static function hasGrant(string $user, string $folder, string $cap): bool {
$folder = self::normalizeFolder($folder);
$capKey = ($cap === 'owner') ? 'owners' : $cap;
$arr = self::listFor($folder, $capKey);
foreach ($arr as $u) {
if (strcasecmp((string)$u, $user) === 0) return true;
}
return false;
}
/** True if user is an explicit owner (or admin). */
public static function isOwner(string $user, array $perms, string $folder): bool {
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners');
}
/** "Manage" in UI == owner. */
public static function canManage(string $user, array $perms, string $folder): bool {
return self::isOwner($user, $perms, $folder);
}
public static function canRead(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
// IMPORTANT: write no longer implies read
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'read');
}
/** Own-only view = read_own OR (any full view). */
public static function canReadOwn(string $user, array $perms, string $folder): bool {
// if they can full-view, this is trivially true
if (self::canRead($user, $perms, $folder)) return true;
return self::hasGrant($user, $folder, 'read_own');
}
/** Upload = write OR owner. No bypassOwnership. */
public static function canWrite(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'write');
}
/** Share = share OR owner. No bypassOwnership. */
public static function canShare(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'share');
}
/**
* Return explicit lists for a folder (no inheritance).
* Keys: owners, read, write, share, read_own (always arrays).
*/
public static function explicit(string $folder): array {
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
$rec = $acl['folders'][$folder] ?? [];
$norm = function ($v): array {
if (!is_array($v)) return [];
$v = array_map('strval', $v);
return array_values(array_unique($v));
};
return [
'owners' => $norm($rec['owners'] ?? []),
'read' => $norm($rec['read'] ?? []),
'write' => $norm($rec['write'] ?? []),
'share' => $norm($rec['share'] ?? []),
'read_own' => $norm($rec['read_own'] ?? []),
];
}
/**
* Upsert a full explicit record for a folder.
* NOTE: preserves existing 'read_own' so older callers don't wipe it.
*/
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool {
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
$existing = $acl['folders'][$folder] ?? ['read_own' => []];
$fmt = function (array $arr): array {
return array_values(array_unique(array_map('strval', $arr)));
};
$acl['folders'][$folder] = [
'owners' => $fmt($owners),
'read' => $fmt($read),
'write' => $fmt($write),
'share' => $fmt($share),
// preserve any own-only grants unless caller explicitly manages them elsewhere
'read_own' => isset($existing['read_own']) && is_array($existing['read_own'])
? array_values(array_unique(array_map('strval', $existing['read_own'])))
: [],
];
return self::save($acl);
}
/**
* Atomic per-user update across many folders.
* $grants is like:
* [
* "folderA" => ["view"=>true, "viewOwn"=>false, "upload"=>true, "manage"=>false, "share"=>false],
* "folderB" => ["view"=>false, "viewOwn"=>true, "upload"=>false, "manage"=>false, "share"=>false],
* ]
* If a folder is INCLUDED with all false, the user is removed from all its buckets.
* (If the frontend omits a folder entirely, this method leaves that folder unchanged.)
*/
public static function applyUserGrantsAtomic(string $user, array $grants): array {
$user = (string)$user;
$path = self::path();
$fh = @fopen($path, 'c+');
if (!$fh) throw new RuntimeException('Cannot open ACL storage');
if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); }
try {
// Read current content
$raw = stream_get_contents($fh);
if ($raw === false) $raw = '';
$acl = json_decode($raw, true);
if (!is_array($acl)) $acl = ['folders'=>[], 'groups'=>[]];
if (!isset($acl['folders']) || !is_array($acl['folders'])) $acl['folders'] = [];
if (!isset($acl['groups']) || !is_array($acl['groups'])) $acl['groups'] = [];
$changed = [];
foreach ($grants as $folder => $caps) {
$ff = self::normalizeFolder((string)$folder);
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) {
$acl['folders'][$ff] = ['owners'=>[], 'read'=>[], 'write'=>[], 'share'=>[], 'read_own'=>[]];
}
$rec =& $acl['folders'][$ff];
// Remove user from all buckets first (idempotent)
foreach (self::BUCKETS as $k) {
$rec[$k] = array_values(array_filter(
array_map('strval', $rec[$k]),
fn($u) => strcasecmp($u, $user) !== 0
));
}
$v = !empty($caps['view']); // full view
$vo = !empty($caps['viewOwn']); // own-only view
$u = !empty($caps['upload']);
$m = !empty($caps['manage']);
$s = !empty($caps['share']);
// Implications
if ($m) { $v = true; $u = true; } // owner implies read+write
if ($u && !$v && !$vo) $vo = true; // upload needs at least own-only visibility
if ($s && !$v) $v = true; // sharing implies full read (can be relaxed if desired)
// Add back per caps
if ($m) $rec['owners'][] = $user;
if ($v) $rec['read'][] = $user;
if ($vo) $rec['read_own'][]= $user;
if ($u) $rec['write'][] = $user;
if ($s) $rec['share'][] = $user;
// De-dup
foreach (self::BUCKETS as $k) {
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
}
$changed[] = $ff;
unset($rec);
}
// Write back atomically
ftruncate($fh, 0);
rewind($fh);
$ok = fwrite($fh, json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) !== false;
if (!$ok) throw new RuntimeException('Write failed');
self::$cache = $acl;
return ['ok' => true, 'updated' => $changed];
} finally {
fflush($fh);
flock($fh, LOCK_UN);
fclose($fh);
}
}
}

View File

@@ -2,6 +2,7 @@
// src/models/FileModel.php // src/models/FileModel.php
require_once PROJECT_ROOT . '/config/config.php'; require_once PROJECT_ROOT . '/config/config.php';
require_once __DIR__ . '/../../src/lib/ACL.php';
class FileModel { class FileModel {

View File

@@ -2,9 +2,99 @@
// src/models/FolderModel.php // src/models/FolderModel.php
require_once PROJECT_ROOT . '/config/config.php'; require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
class FolderModel class FolderModel
{ {
/* ============================================================
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
* ============================================================ */
/** Load the folder → owner map. */
public static function getFolderOwners(): array
{
$f = FOLDER_OWNERS_FILE;
if (!file_exists($f)) return [];
$json = json_decode(@file_get_contents($f), true);
return is_array($json) ? $json : [];
}
/** Persist the folder → owner map. */
public static function saveFolderOwners(array $map): bool
{
return (bool) @file_put_contents(FOLDER_OWNERS_FILE, json_encode($map, JSON_PRETTY_PRINT), LOCK_EX);
}
/** Set (or replace) the owner for a specific folder (relative path or 'root'). */
public static function setOwnerFor(string $folder, string $owner): void
{
$key = trim($folder, "/\\ ");
$key = ($key === '' ? 'root' : $key);
$owners = self::getFolderOwners();
$owners[$key] = $owner;
self::saveFolderOwners($owners);
}
/** Get the owner for a folder (relative path or 'root'); returns null if unmapped. */
public static function getOwnerFor(string $folder): ?string
{
$key = trim($folder, "/\\ ");
$key = ($key === '' ? 'root' : $key);
$owners = self::getFolderOwners();
return $owners[$key] ?? null;
}
/** Rename a single ownership key (old → new). */
public static function renameOwnerKey(string $old, string $new): void
{
$old = trim($old, "/\\ ");
$new = trim($new, "/\\ ");
$owners = self::getFolderOwners();
if (isset($owners[$old])) {
$owners[$new] = $owners[$old];
unset($owners[$old]);
self::saveFolderOwners($owners);
}
}
/** Remove ownership for a folder and all its descendants. */
public static function removeOwnerForTree(string $folder): void
{
$folder = trim($folder, "/\\ ");
$owners = self::getFolderOwners();
foreach (array_keys($owners) as $k) {
if ($k === $folder || strpos($k, $folder . '/') === 0) {
unset($owners[$k]);
}
}
self::saveFolderOwners($owners);
}
/** Rename ownership keys for an entire subtree: old/... → new/... */
public static function renameOwnersForTree(string $oldFolder, string $newFolder): void
{
$old = trim($oldFolder, "/\\ ");
$new = trim($newFolder, "/\\ ");
$owners = self::getFolderOwners();
$rebased = [];
foreach ($owners as $k => $v) {
if ($k === $old || strpos($k, $old . '/') === 0) {
$suffix = substr($k, strlen($old));
// ensure no leading slash duplication
$suffix = ltrim($suffix, '/');
$rebased[$new . ($suffix !== '' ? '/' . $suffix : '')] = $v;
} else {
$rebased[$k] = $v;
}
}
self::saveFolderOwners($rebased);
}
/* ============================================================
* Existing helpers
* ============================================================ */
/** /**
* Resolve a (possibly nested) relative folder like "invoices/2025" to a real path * Resolve a (possibly nested) relative folder like "invoices/2025" to a real path
* under UPLOAD_DIR. Validates each path segment against REGEX_FOLDER_NAME, enforces * under UPLOAD_DIR. Validates each path segment against REGEX_FOLDER_NAME, enforces
@@ -59,9 +149,7 @@ class FolderModel
return [$real, $relative, null]; return [$real, $relative, null];
} }
/** /** Build metadata file path for a given (relative) folder. */
* Build metadata file path for a given (relative) folder.
*/
private static function getMetadataFilePath(string $folder): string private static function getMetadataFilePath(string $folder): string
{ {
if (strtolower($folder) === 'root' || trim($folder) === '') { if (strtolower($folder) === 'root' || trim($folder) === '') {
@@ -72,42 +160,67 @@ class FolderModel
/** /**
* Creates a folder under the specified parent (or in root) and creates an empty metadata file. * Creates a folder under the specified parent (or in root) and creates an empty metadata file.
* Also records the creator as the owner (if a session user is available).
*/ */
public static function createFolder(string $folderName, string $parent = ""): array <?php
require_once PROJECT_ROOT . '/src/lib/ACL.php';
class FolderModel
{
/**
* Create a folder on disk and register it in ACL with the creator as owner.
* @param string $folderName leaf name
* @param string $parent 'root' or nested key (e.g. 'team/reports')
* @param string $creator username to set as initial owner (falls back to 'admin')
*/
public static function createFolder(string $folderName, string $parent = 'root', string $creator = 'admin'): array
{ {
$folderName = trim($folderName); $folderName = trim($folderName);
$parent = trim($parent); $parent = trim($parent);
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) { if ($folderName === '' || !preg_match(REGEX_FOLDER_NAME, $folderName)) {
return ["error" => "Invalid folder name."]; return ['success' => false, 'error' => 'Invalid folder name', 'code' => 400];
}
if ($parent !== '' && strcasecmp($parent, 'root') !== 0 && !preg_match(REGEX_FOLDER_NAME, $parent)) {
return ['success' => false, 'error' => 'Invalid parent folder', 'code' => 400];
} }
// Resolve parent path (root ok; nested ok) // Compute ACL key and filesystem path
[$parentReal, $parentRel, $err] = self::resolveFolderPath($parent === '' ? 'root' : $parent, true); $aclKey = ($parent === '' || strcasecmp($parent, 'root') === 0) ? $folderName : ($parent . '/' . $folderName);
if ($err) return ["error" => $err];
$targetRel = ($parentRel === 'root') ? $folderName : ($parentRel . '/' . $folderName); $base = rtrim(UPLOAD_DIR, '/\\');
$targetDir = $parentReal . DIRECTORY_SEPARATOR . $folderName; $path = ($parent === '' || strcasecmp($parent, 'root') === 0)
? $base . DIRECTORY_SEPARATOR . $folderName
: $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $parent) . DIRECTORY_SEPARATOR . $folderName;
if (file_exists($targetDir)) { // Safety: stay inside UPLOAD_DIR
return ["error" => "Folder already exists."]; $realBase = realpath($base);
$realPath = $path; // may not exist yet
$parentDir = dirname($path);
if (!is_dir($parentDir) && !@mkdir($parentDir, 0775, true)) {
return ['success' => false, 'error' => 'Failed to create parent path', 'code' => 500];
} }
if (!mkdir($targetDir, 0775, true)) { if (is_dir($path)) {
return ["error" => "Failed to create folder."]; // Idempotent: still ensure ACL record exists
ACL::ensureFolderRecord($aclKey, $creator ?: 'admin');
return ['success' => true, 'folder' => $aclKey, 'alreadyExists' => true];
} }
// Create an empty metadata file for the new folder. if (!@mkdir($path, 0775, true)) {
$metadataFile = self::getMetadataFilePath($targetRel); return ['success' => false, 'error' => 'Failed to create folder', 'code' => 500];
if (file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT), LOCK_EX) === false) {
return ["error" => "Folder created but failed to create metadata file."];
} }
return ["success" => true]; // Seed ACL: owner/read/write/share -> creator; read_own empty
ACL::ensureFolderRecord($aclKey, $creator ?: 'admin');
return ['success' => true, 'folder' => $aclKey];
}
} }
/** /**
* Deletes a folder if it is empty and removes its corresponding metadata. * Deletes a folder if it is empty and removes its corresponding metadata.
* Also removes ownership mappings for this folder and all its descendants.
*/ */
public static function deleteFolder(string $folder): array public static function deleteFolder(string $folder): array
{ {
@@ -119,12 +232,12 @@ class FolderModel
if ($err) return ["error" => $err]; if ($err) return ["error" => $err];
// Prevent deletion if not empty. // Prevent deletion if not empty.
$items = array_diff(scandir($real), array('.', '..')); $items = array_diff(@scandir($real) ?: [], array('.', '..'));
if (count($items) > 0) { if (count($items) > 0) {
return ["error" => "Folder is not empty."]; return ["error" => "Folder is not empty."];
} }
if (!rmdir($real)) { if (!@rmdir($real)) {
return ["error" => "Failed to delete folder."]; return ["error" => "Failed to delete folder."];
} }
@@ -134,11 +247,15 @@ class FolderModel
@unlink($metadataFile); @unlink($metadataFile);
} }
// Remove ownership mappings for the subtree.
self::removeOwnerForTree($relative);
return ["success" => true]; return ["success" => true];
} }
/** /**
* Renames a folder and updates related metadata files (by renaming their filenames). * Renames a folder and updates related metadata files (by renaming their filenames).
* Also rewrites ownership keys for the whole subtree from old → new.
*/ */
public static function renameFolder(string $oldFolder, string $newFolder): array public static function renameFolder(string $oldFolder, string $newFolder): array
{ {
@@ -163,6 +280,7 @@ class FolderModel
if ($base === false) return ["error" => "Uploads directory not configured correctly."]; if ($base === false) return ["error" => "Uploads directory not configured correctly."];
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p!==''); $newParts = array_filter(explode('/', $newFolder), fn($p) => $p!=='');
$newRel = implode('/', $newParts);
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts); $newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
// Parent of new path must exist // Parent of new path must exist
@@ -174,13 +292,13 @@ class FolderModel
return ["error" => "New folder name already exists."]; return ["error" => "New folder name already exists."];
} }
if (!rename($oldReal, $newPath)) { if (!@rename($oldReal, $newPath)) {
return ["error" => "Failed to rename folder."]; return ["error" => "Failed to rename folder."];
} }
// Update metadata filenames (prefix-rename) // Update metadata filenames (prefix-rename)
$oldPrefix = str_replace(['/', '\\', ' '], '-', $oldRel); $oldPrefix = str_replace(['/', '\\', ' '], '-', $oldRel);
$newPrefix = str_replace(['/', '\\', ' '], '-', implode('/', $newParts)); $newPrefix = str_replace(['/', '\\', ' '], '-', $newRel);
$globPat = META_DIR . $oldPrefix . '*_metadata.json'; $globPat = META_DIR . $oldPrefix . '*_metadata.json';
$metadataFiles = glob($globPat) ?: []; $metadataFiles = glob($globPat) ?: [];
@@ -191,6 +309,9 @@ class FolderModel
@rename($oldMetaFile, $newMeta); @rename($oldMetaFile, $newMeta);
} }
// Update ownership mapping for the entire subtree.
self::renameOwnersForTree($oldRel, $newRel);
return ["success" => true]; return ["success" => true];
} }
@@ -217,8 +338,9 @@ class FolderModel
/** /**
* Retrieves the list of folders (including "root") along with file count metadata. * Retrieves the list of folders (including "root") along with file count metadata.
* (Ownership filtering is handled in the controller; this function remains unchanged.)
*/ */
public static function getFolderList(): array public static function getFolderList($ignoredParent = null, ?string $username = null, array $perms = []): array
{ {
$baseDir = realpath(UPLOAD_DIR); $baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) { if ($baseDir === false) {
@@ -256,6 +378,12 @@ class FolderModel
]; ];
} }
if ($username !== null) {
$folderInfoList = array_values(array_filter(
$folderInfoList,
fn($row) => ACL::canRead($username, $perms, $row['folder'])
));
}
return $folderInfoList; return $folderInfoList;
} }

View File

@@ -102,9 +102,9 @@ class userModel
if (count($parts) < 3) { if (count($parts) < 3) {
continue; continue;
} }
if ($parts[0] === $usernameToRemove) { if (strcasecmp($parts[0], $usernameToRemove) === 0) {
$userFound = true; $userFound = true;
continue; // skip continue; // skip this user
} }
$newUsers[] = $line; $newUsers[] = $line;
} }
@@ -118,7 +118,7 @@ class userModel
return ["error" => "Failed to update users file"]; return ["error" => "Failed to update users file"];
} }
// Update *encrypted* userPermissions.json consistently // Update encrypted userPermissions.json — remove any key matching case-insensitively
$permissionsFile = USERS_DIR . "userPermissions.json"; $permissionsFile = USERS_DIR . "userPermissions.json";
if (file_exists($permissionsFile)) { if (file_exists($permissionsFile)) {
$raw = file_get_contents($permissionsFile); $raw = file_get_contents($permissionsFile);
@@ -128,13 +128,44 @@ class userModel
: (json_decode($raw, true) ?: []); // tolerate legacy plaintext : (json_decode($raw, true) ?: []); // tolerate legacy plaintext
if (is_array($permissionsArray)) { if (is_array($permissionsArray)) {
unset($permissionsArray[strtolower($usernameToRemove)]); foreach (array_keys($permissionsArray) as $k) {
if (strcasecmp($k, $usernameToRemove) === 0) {
unset($permissionsArray[$k]);
}
}
$plain = json_encode($permissionsArray, JSON_PRETTY_PRINT); $plain = json_encode($permissionsArray, JSON_PRETTY_PRINT);
$enc = encryptData($plain, $encryptionKey); $enc = encryptData($plain, $encryptionKey);
file_put_contents($permissionsFile, $enc, LOCK_EX); file_put_contents($permissionsFile, $enc, LOCK_EX);
} }
} }
// Purge from ACL (remove from every bucket in every folder)
require_once PROJECT_ROOT . '/src/lib/ACL.php';
if (method_exists('ACL', 'purgeUser')) {
ACL::purgeUser($usernameToRemove);
} else {
// Fallback inline purge if you haven't added ACL::purgeUser yet:
$aclPath = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
$acl = is_file($aclPath) ? json_decode((string)file_get_contents($aclPath), true) : [];
if (!is_array($acl)) $acl = ['folders' => [], 'groups' => []];
$buckets = ['owners','read','write','share','read_own'];
$changed = false;
foreach ($acl['folders'] ?? [] as $f => &$rec) {
foreach ($buckets as $b) {
if (!isset($rec[$b]) || !is_array($rec[$b])) { $rec[$b] = []; continue; }
$before = $rec[$b];
$rec[$b] = array_values(array_filter($rec[$b], fn($u) => strcasecmp((string)$u, $usernameToRemove) !== 0));
if ($rec[$b] !== $before) $changed = true;
}
}
unset($rec);
if ($changed) {
@file_put_contents($aclPath, json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
}
}
return ["success" => "User removed successfully"]; return ["success" => "User removed successfully"];
} }
@@ -188,7 +219,7 @@ class userModel
if (file_exists($permissionsFile)) { if (file_exists($permissionsFile)) {
$encryptedContent = file_get_contents($permissionsFile); $encryptedContent = file_get_contents($permissionsFile);
$json = decryptData($encryptedContent, $encryptionKey); $json = decryptData($encryptedContent, $encryptionKey);
if ($json === false) $json = $encryptedContent; // plain JSON fallback if ($json === false) $json = $encryptedContent; // legacy plaintext
$existingPermissions = json_decode($json, true) ?: []; $existingPermissions = json_decode($json, true) ?: [];
} }
@@ -209,22 +240,34 @@ class userModel
'bypassOwnership','canShare','canZip','viewOwnOnly' 'bypassOwnership','canShare','canZip','viewOwnOnly'
]; ];
// Build a map of lowercase->actual key to update existing entries case-insensitively
$lcIndex = [];
foreach ($existingPermissions as $k => $_) {
$lcIndex[strtolower($k)] = $k;
}
foreach ($permissions as $perm) { foreach ($permissions as $perm) {
if (empty($perm['username'])) continue; if (empty($perm['username'])) continue;
$uname = strtolower($perm['username']);
$role = $userRoles[$uname] ?? null; $unameOrig = (string)$perm['username']; // preserve original case
$unameLc = strtolower($unameOrig);
$role = $userRoles[$unameLc] ?? null;
if ($role === "1") continue; // skip admins if ($role === "1") continue; // skip admins
$current = $existingPermissions[$uname] ?? []; // Find existing key case-insensitively; otherwise use original case as canonical
$storeKey = $lcIndex[$unameLc] ?? $unameOrig;
$current = $existingPermissions[$storeKey] ?? [];
foreach ($knownKeys as $k) { foreach ($knownKeys as $k) {
if (array_key_exists($k, $perm)) { if (array_key_exists($k, $perm)) {
$current[$k] = (bool)$perm[$k]; $current[$k] = (bool)$perm[$k];
} elseif (!isset($current[$k])) { } elseif (!isset($current[$k])) {
// default missing keys to false (preserve existing if set)
$current[$k] = false; $current[$k] = false;
} }
} }
$existingPermissions[$uname] = $current;
$existingPermissions[$storeKey] = $current;
$lcIndex[$unameLc] = $storeKey; // keep index up to date
} }
$plain = json_encode($existingPermissions, JSON_PRETTY_PRINT); $plain = json_encode($existingPermissions, JSON_PRETTY_PRINT);

View File

@@ -1,9 +1,9 @@
<?php <?php
namespace FileRise\WebDAV; namespace FileRise\WebDAV;
// Bootstrap constants and models require_once __DIR__ . '/../../config/config.php'; // constants + loadUserPermissions()
require_once __DIR__ . '/../../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT
require_once __DIR__ . '/../../vendor/autoload.php'; // SabreDAV 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/FolderModel.php';
require_once __DIR__ . '/../../src/models/FileModel.php'; require_once __DIR__ . '/../../src/models/FileModel.php';
require_once __DIR__ . '/FileRiseFile.php'; require_once __DIR__ . '/FileRiseFile.php';
@@ -12,24 +12,27 @@ use Sabre\DAV\ICollection;
use Sabre\DAV\INode; use Sabre\DAV\INode;
use Sabre\DAV\Exception\NotFound; use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\Forbidden;
use FileRise\WebDAV\FileRiseFile;
use FolderModel;
use FileModel;
class FileRiseDirectory implements ICollection, INode { class FileRiseDirectory implements ICollection, INode {
private string $path; private string $path;
private string $user; 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 $path Absolute filesystem path (no trailing slash)
* @param string $user Authenticated username * @param string $user Authenticated username
* @param bool $folderOnly If true, nonadmins only see $path/{user} * @param bool $isAdmin
* @param array $perms user-permissions map (readOnly, disableUpload, bypassOwnership, etc.)
*/ */
public function __construct(string $path, string $user, bool $folderOnly) { public function __construct(string $path, string $user, bool $isAdmin, array $perms) {
$this->path = rtrim($path, '/\\'); $this->path = rtrim($path, '/\\');
$this->user = $user; $this->user = $user;
$this->folderOnly = $folderOnly; $this->isAdmin = $isAdmin;
$this->perms = $perms;
} }
// ── INode ─────────────────────────────────────────── // ── INode ───────────────────────────────────────────
@@ -39,72 +42,185 @@ class FileRiseDirectory implements ICollection, INode {
} }
public function getLastModified(): int { public function getLastModified(): int {
return filemtime($this->path); return @filemtime($this->path) ?: time();
} }
public function delete(): void { public function delete(): void {
throw new Forbidden('Cannot delete this node'); throw new Forbidden('Cannot delete directories via WebDAV');
} }
public function setName($name): void { public function setName($name): void {
throw new Forbidden('Renaming not supported'); throw new Forbidden('Renaming directories is not supported');
} }
// ── ICollection ──────────────────────────────────── // ── ICollection ────────────────────────────────────
public function getChildren(): array { 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 = []; $nodes = [];
$hide = ['trash','profile_pics']; // internal dirs to hide
foreach (new \DirectoryIterator($this->path) as $item) { foreach (new \DirectoryIterator($this->path) as $item) {
if ($item->isDot()) continue; if ($item->isDot()) continue;
$name = $item->getFilename();
if (in_array(strtolower($name), $hide, true)) continue;
$full = $item->getPathname(); $full = $item->getPathname();
if ($item->isDir()) { if ($item->isDir()) {
$nodes[] = new self($full, $this->user, $this->folderOnly); // Decide if the *child folder* should be visible
} else { $childKey = $this->folderKeyForPath($full);
$nodes[] = new FileRiseFile($full, $this->user); $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); return array_values($nodes);
} }
public function childExists($name): bool { 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 { public function getChild($name): INode {
$full = $this->path . DIRECTORY_SEPARATOR . $name; $full = $this->path . DIRECTORY_SEPARATOR . $name;
if (!file_exists($full)) throw new NotFound("Not found: $name"); if (!file_exists($full)) throw new NotFound("Not found: $name");
return is_dir($full)
? new self($full, $this->user, $this->folderOnly) $folderKey = $this->folderKeyForPath($this->path);
: new FileRiseFile($full, $this->user); 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 { 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; $full = $this->path . DIRECTORY_SEPARATOR . $name;
$content = is_resource($data) ? stream_get_contents($data) : (string)$data; $content = is_resource($data) ? stream_get_contents($data) : (string)$data;
// Compute folderkey relative to UPLOAD_DIR // Let FileRiseFile handle metadata & overwrite semantics
$rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1); $fileNode = new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
$parts = explode('/', str_replace('\\','/',$rel)); $fileNode->put($content);
$filename = array_pop($parts);
$folder = empty($parts) ? 'root' : implode('/', $parts);
FileModel::saveFile($folder, $filename, $content, $this->user); return $fileNode;
return new FileRiseFile($full, $this->user);
} }
public function createDirectory($name): INode { public function createDirectory($name): INode {
$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; $full = $this->path . DIRECTORY_SEPARATOR . $name;
$rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1); if (!is_dir($full)) {
@mkdir($full, 0755, true);
}
// FileRise folder bookkeeping (owner = creator)
$rel = $this->relFromUploads($full);
$parent = dirname(str_replace('\\','/',$rel)); $parent = dirname(str_replace('\\','/',$rel));
if ($parent === '.' || $parent === '/') $parent = ''; if ($parent === '.' || $parent === '/') $parent = '';
FolderModel::createFolder($name, $parent, $this->user); \FolderModel::createFolder($name, $parent, $this->user);
return new self($full, $this->user, $this->folderOnly);
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__ . '/../../config/config.php';
require_once __DIR__ . '/../../vendor/autoload.php'; require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/../../src/lib/ACL.php';
require_once __DIR__ . '/../../src/models/FileModel.php'; require_once __DIR__ . '/../../src/models/FileModel.php';
require_once __DIR__ . '/CurrentUser.php';
use Sabre\DAV\IFile; use Sabre\DAV\IFile;
use Sabre\DAV\INode; use Sabre\DAV\INode;
use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\Forbidden;
use FileModel;
class FileRiseFile implements IFile, INode { class FileRiseFile implements IFile, INode {
private string $path; private string $path;
private string $user;
private bool $isAdmin;
private array $perms;
public function __construct(string $path) { public function __construct(string $path, string $user, bool $isAdmin, array $perms) {
$this->path = $path; $this->path = $path;
$this->user = $user;
$this->isAdmin = $isAdmin;
$this->perms = $perms;
} }
// ── INode ─────────────────────────────────────────── // ── INode ───────────────────────────────────────────
@@ -26,90 +33,134 @@ class FileRiseFile implements IFile, INode {
} }
public function getLastModified(): int { public function getLastModified(): int {
return filemtime($this->path); return @filemtime($this->path) ?: time();
} }
public function delete(): void { public function delete(): void {
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR; [$folderKey, $fileName] = $this->split();
$rel = substr($this->path, strlen($base)); if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
$parts = explode(DIRECTORY_SEPARATOR, $rel); throw new Forbidden('No write access to delete this file');
$file = array_pop($parts); }
$folder = empty($parts) ? 'root' : $parts[0]; if (!$this->canTouchOwnership($folderKey, $fileName)) {
FileModel::deleteFiles($folder, [$file]); throw new Forbidden('You do not own this file');
}
\FileModel::deleteFiles($folderKey, [$fileName]);
} }
public function setName($newName): void { public function setName($newName): void {
throw new Forbidden('Renaming files not supported'); throw new Forbidden('Renaming files via WebDAV is not supported');
} }
// ── IFile ─────────────────────────────────────────── // ── IFile ───────────────────────────────────────────
public function get() { 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'); return fopen($this->path, 'rb');
} }
public function put($data): ?string { 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( file_put_contents(
$this->path, $this->path,
is_resource($data) ? stream_get_contents($data) : (string)$data is_resource($data) ? stream_get_contents($data) : (string)$data
); );
// 2) Update metadata with CurrentUser // Update metadata (uploader on first write; modified every write)
$this->updateMetadata(); $this->updateMetadata($folderKey, $fileName);
// 3) Flush to client fast
if (function_exists('fastcgi_finish_request')) { if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request(); fastcgi_finish_request();
} }
return null; // no ETag return null; // no ETag
} }
public function getSize(): int { public function getSize(): int {
return filesize($this->path); return @filesize($this->path) ?: 0;
} }
public function getETag(): string { public function getETag(): string {
return '"' . md5($this->getLastModified() . $this->getSize()) . '"'; return '"' . md5(($this->getLastModified() ?: 0) . ':' . ($this->getSize() ?: 0)) . '"';
} }
public function getContentType(): ?string { 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 { private function split(): array {
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR; $base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
$rel = substr($this->path, strlen($base)); $rel = ltrim(str_replace('\\','/', substr($this->path, strlen($base))), '/');
$parts = explode(DIRECTORY_SEPARATOR, $rel); $parts = explode('/', $rel);
$fileName = array_pop($parts); $file = array_pop($parts);
$folder = empty($parts) ? 'root' : $parts[0]; $folder = empty($parts) ? 'root' : implode('/', $parts);
return [$folder, $file];
}
$metaFile = META_DIR private function metaFile(string $folderKey): string {
. ($folder === 'root' return META_DIR . (
$folderKey === 'root'
? 'root_metadata.json' ? 'root_metadata.json'
: str_replace(['/', '\\', ' '], '-', $folder) . '_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); $now = date(DATE_TIME_FORMAT);
$uploaded = $metadata[$fileName]['uploaded'] ?? $now; $uploaded = $meta[$fileName]['uploaded'] ?? $now;
$uploader = CurrentUser::get(); $uploader = CurrentUser::get() ?: $this->user;
$metadata[$fileName] = [ $meta[$fileName] = [
'uploaded' => $uploaded, 'uploaded' => $uploaded,
'modified' => $now, 'modified' => $now,
'uploader' => $uploader, 'uploader' => $uploader,
]; ];
$this->saveMeta($folderKey, $meta);
file_put_contents($metaFile, json_encode($metadata, JSON_PRETTY_PRINT));
} }
} }