This commit is contained in:
64
CHANGELOG.md
64
CHANGELOG.md
@@ -1,5 +1,69 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 10/17/2025 (v1.5.0)
|
||||
|
||||
Security and permission model overhaul. Tightens access controls with explicit, server‑side ACL checks across controllers and WebDAV. Introduces `read_own` for own‑only visibility and separates view from write so uploaders can’t 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 (own‑only visibility) alongside `owners`, `read`, `write`, `share`.
|
||||
- **Semantic change:** `write` no longer implies `read`.
|
||||
- `ACL::applyUserGrantsAtomic()` to atomically set per‑folder grants (`view`, `viewOwn`, `upload`, `manage`, `share`).
|
||||
- `ACL::purgeUser($username)` to remove a user from all buckets (used when deleting a user).
|
||||
- Auto‑heal `folder_acl.json` (ensure `root` exists; add missing buckets; de‑dupe; 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 share‑link creation. `getFileList()` now filters to the caller’s 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 name‑based ownership assumptions.
|
||||
|
||||
- **Admin UI**
|
||||
- Folder Access grid now includes **View (own)**; bulk toolbar actions; column alignment fixes; more space for folder names; dark‑mode polish.
|
||||
|
||||
- **WebDAV**
|
||||
- WebDAV now enforces ACL consistently: listing requires `read` (or `read_own` ⇒ shows only caller’s 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 ACL‑based 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 caller’s 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` — ACL‑filtered listing and parent scoping.
|
||||
- `public/api/admin/acl/*.php` — includes `viewOwn` round‑trip and sanitization.
|
||||
- `public/js/*` & CSS — folder access grid alignment and layout fixes.
|
||||
- `src/webdav/*` & `public/webdav.php` — ACL‑aware WebDAV server.
|
||||
|
||||
### Credits
|
||||
|
||||
- Security report acknowledged privately and will be credited in the published advisory.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
@@ -6,8 +6,8 @@ We provide security fixes for the latest minor release line.
|
||||
|
||||
| Version | Supported |
|
||||
|------------|-----------|
|
||||
| v1.4.x | ✅ |
|
||||
| < v1.4.0 | ❌ |
|
||||
| v1.5.x | ✅ |
|
||||
| < v1.5.0 | ❌ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@@ -35,13 +35,11 @@ define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
||||
|
||||
date_default_timezone_set(TIMEZONE);
|
||||
|
||||
|
||||
if (!defined('DEFAULT_BYPASS_OWNERSHIP')) define('DEFAULT_BYPASS_OWNERSHIP', false);
|
||||
if (!defined('DEFAULT_CAN_SHARE')) define('DEFAULT_CAN_SHARE', true);
|
||||
if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
|
||||
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
|
||||
|
||||
|
||||
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
|
||||
|
||||
// Encryption helpers
|
||||
function encryptData($data, $encryptionKey)
|
||||
@@ -77,16 +75,27 @@ function loadUserPermissions($username)
|
||||
{
|
||||
global $encryptionKey;
|
||||
$permissionsFile = USERS_DIR . 'userPermissions.json';
|
||||
if (file_exists($permissionsFile)) {
|
||||
if (!file_exists($permissionsFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = file_get_contents($permissionsFile);
|
||||
$decrypted = decryptData($content, $encryptionKey);
|
||||
$json = ($decrypted !== false) ? $decrypted : $content;
|
||||
$perms = json_decode($json, true);
|
||||
if (is_array($perms) && isset($perms[$username])) {
|
||||
return !empty($perms[$username]) ? $perms[$username] : false;
|
||||
}
|
||||
}
|
||||
$permsAll = json_decode($json, true);
|
||||
|
||||
if (!is_array($permsAll)) {
|
||||
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
|
||||
@@ -98,23 +107,37 @@ $secure = ($envSecure !== false)
|
||||
// Choose session lifetime based on "remember me" cookie
|
||||
$defaultSession = 7200; // 2 hours
|
||||
$persistentDays = 30 * 24 * 60 * 60; // 30 days
|
||||
$sessionLifetime = isset($_COOKIE['remember_me_token'])
|
||||
? $persistentDays
|
||||
: $defaultSession;
|
||||
$sessionLifetime = isset($_COOKIE['remember_me_token']) ? $persistentDays : $defaultSession;
|
||||
|
||||
// Configure PHP session cookie and GC
|
||||
session_set_cookie_params([
|
||||
/**
|
||||
* 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([
|
||||
'lifetime' => $sessionLifetime,
|
||||
'path' => '/',
|
||||
'domain' => '', // adjust if you need a specific domain
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
]);
|
||||
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
]);
|
||||
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
|
||||
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
|
||||
@@ -122,8 +145,7 @@ if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
|
||||
// Auto‑login via persistent token
|
||||
// Auto-login via persistent token
|
||||
if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) {
|
||||
$tokFile = USERS_DIR . 'persistent_tokens.json';
|
||||
$tokens = [];
|
||||
|
||||
79
public/api/admin/acl/getGrants.php
Normal file
79
public/api/admin/acl/getGrants.php
Normal 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);
|
||||
105
public/api/admin/acl/saveGrants.php
Normal file
105
public/api/admin/acl/saveGrants.php
Normal 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}]}']);
|
||||
120
public/api/folder/capabilities.php
Normal file
120
public/api/folder/capabilities.php
Normal 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);
|
||||
@@ -1,9 +1,10 @@
|
||||
// adminPanel.js
|
||||
import { t } from './i18n.js';
|
||||
import { loadAdminConfigFunc } from './auth.js';
|
||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.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>`;
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// --- 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 —————
|
||||
(function () {
|
||||
if (document.getElementById('adminPanelStyles')) return;
|
||||
@@ -22,8 +40,10 @@ const tf = (key, fallback) => {
|
||||
#adminPanelModal .modal-content {
|
||||
max-width: 1100px;
|
||||
width: 50%;
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
|
||||
/* Small phones: 90% width */
|
||||
@media (max-width: 900px) {
|
||||
#adminPanelModal .modal-content {
|
||||
@@ -31,91 +51,96 @@ const tf = (key, fallback) => {
|
||||
max-width: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark-mode fixes */
|
||||
body.dark-mode #adminPanelModal .modal-content {
|
||||
border-color: #555 !important;
|
||||
}
|
||||
|
||||
/* enforce light‐mode styling */
|
||||
#adminPanelModal .modal-content {
|
||||
max-width: 1100px;
|
||||
width: 50%;
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
|
||||
/* enforce dark‐mode 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 #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; }
|
||||
body.dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; }
|
||||
body.dark-mode .form-control::placeholder { color:#888; }
|
||||
|
||||
/* Section headers */
|
||||
.section-header {
|
||||
background: #f5f5f5;
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
background:#f5f5f5; padding:10px 15px; 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.collapsed .material-icons { transform: rotate(-90deg); }
|
||||
.section-header .material-icons { transition: transform .3s; color: #444; }
|
||||
|
||||
body.dark-mode .section-header {
|
||||
background: #3a3a3a;
|
||||
color: #eee;
|
||||
}
|
||||
body.dark-mode .section-header .material-icons { color: #ccc; }
|
||||
.section-header:first-of-type { margin-top:0; }
|
||||
.section-header.collapsed .material-icons { transform:rotate(-90deg); }
|
||||
.section-header .material-icons { transition:transform .3s; color:#444; }
|
||||
body.dark-mode .section-header { background:#3a3a3a; color:#eee; }
|
||||
body.dark-mode .section-header .material-icons { color:#ccc; }
|
||||
|
||||
/* Hidden by default */
|
||||
.section-content {
|
||||
display: none;
|
||||
margin-left: 20px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.section-content { display:none; margin-left:20px; margin-top:8px; margin-bottom:8px; }
|
||||
|
||||
/* Close button */
|
||||
#adminPanelModal .editor-close-btn {
|
||||
position: absolute; top:10px; right:10px;
|
||||
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%;
|
||||
text-align:center; line-height:30px;
|
||||
color:#ff4d4d; background:rgba(255,255,255,0.9);
|
||||
position:absolute; top:10px; right:10px; 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%;
|
||||
text-align:center; line-height:30px; color:#ff4d4d; background:rgba(255,255,255,0.9);
|
||||
border:2px solid transparent; transition:all .3s;
|
||||
}
|
||||
#adminPanelModal .editor-close-btn:hover {
|
||||
color:white; background:#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;
|
||||
}
|
||||
#adminPanelModal .editor-close-btn:hover { color:#fff; background:#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 {
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
margin-top:15px;
|
||||
.action-row { display:flex; justify-content:space-between; margin-top:15px; }
|
||||
|
||||
/* ---------- Folder access editor ---------- */
|
||||
.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);
|
||||
@@ -179,7 +204,6 @@ function toggleSection(id) {
|
||||
const hdr = document.getElementById(id + "Header");
|
||||
const cnt = document.getElementById(id + "Content");
|
||||
const isCollapsedNow = hdr.classList.toggle("collapsed");
|
||||
// collapsed class present => hide; absent => show
|
||||
cnt.style.display = isCollapsedNow ? "none" : "block";
|
||||
if (!isCollapsedNow && id === "shareLinks") {
|
||||
loadShareLinksSection();
|
||||
@@ -190,23 +214,12 @@ function loadShareLinksSection() {
|
||||
const container = document.getElementById("shareLinksContent");
|
||||
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) {
|
||||
return fetch(`/api/admin/readMetadata.php?file=${encodeURIComponent(fileName)}`, {
|
||||
credentials: "include"
|
||||
})
|
||||
.then(resp => {
|
||||
if (!resp.ok) {
|
||||
// 404 or any other non-OK → treat as empty
|
||||
return {};
|
||||
}
|
||||
return resp.json();
|
||||
})
|
||||
.catch(() => {
|
||||
// network failure, parse error, etc → also empty
|
||||
return {};
|
||||
});
|
||||
.then(resp => resp.ok ? resp.json() : {})
|
||||
.catch(() => ({}));
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
@@ -214,7 +227,6 @@ function loadShareLinksSection() {
|
||||
fetchMeta("share_links.json")
|
||||
])
|
||||
.then(([folders, files]) => {
|
||||
// if *both* are empty, show "no shared links"
|
||||
const hasAny = Object.keys(folders).length || Object.keys(files).length;
|
||||
if (!hasAny) {
|
||||
container.innerHTML = `<p>${t("no_shared_links_available")}</p>`;
|
||||
@@ -252,7 +264,6 @@ function loadShareLinksSection() {
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// wire up delete buttons
|
||||
container.querySelectorAll(".delete-share").forEach(btn => {
|
||||
btn.addEventListener("click", evt => {
|
||||
evt.preventDefault();
|
||||
@@ -268,10 +279,7 @@ function loadShareLinksSection() {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ token })
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then(res => res.ok ? res.json() : Promise.reject(res))
|
||||
.then(json => {
|
||||
if (json.success) {
|
||||
showToast(t("share_deleted_successfully"));
|
||||
@@ -293,12 +301,10 @@ function loadShareLinksSection() {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function openAdminPanel() {
|
||||
fetch("/api/admin/getConfig.php", { credentials: "include" })
|
||||
.then(r => r.json())
|
||||
.then(config => {
|
||||
// apply header title + globals
|
||||
if (config.header_title) {
|
||||
document.querySelector(".header-title h1").textContent = config.header_title;
|
||||
window.headerTitle = config.header_title;
|
||||
@@ -333,8 +339,6 @@ export function openAdminPanel() {
|
||||
<div class="editor-close-btn" id="closeAdminPanel">×</div>
|
||||
<h3>${adminTitle}</h3>
|
||||
<form id="adminPanelForm">
|
||||
|
||||
<!-- each section: header + content -->
|
||||
${[
|
||||
{ id: "userManagement", label: t("user_management") },
|
||||
{ id: "headerSettings", label: t("header_settings") },
|
||||
@@ -372,13 +376,15 @@ export function openAdminPanel() {
|
||||
.addEventListener("click", () => toggleSection(id));
|
||||
});
|
||||
|
||||
// Populate each section’s CONTENT:
|
||||
// — User Mgmt —
|
||||
document.getElementById("userManagementContent").innerHTML = `
|
||||
<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="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")
|
||||
.addEventListener("click", () => {
|
||||
toggleVisibility("addUserModal", true);
|
||||
@@ -458,7 +464,6 @@ export function openAdminPanel() {
|
||||
}
|
||||
});
|
||||
});
|
||||
// If authBypass is checked, clear the other three
|
||||
document.getElementById("authBypass").addEventListener("change", e => {
|
||||
if (e.target.checked) {
|
||||
["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
|
||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
||||
@@ -479,7 +505,6 @@ export function openAdminPanel() {
|
||||
} else {
|
||||
// modal already exists → just refresh values & re-show
|
||||
mdl.style.display = "flex";
|
||||
// update dark/light as above...
|
||||
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
|
||||
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
|
||||
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
|
||||
@@ -533,9 +558,7 @@ function handleSave() {
|
||||
enableWebDAV: eWD,
|
||||
sharedMaxUploadSize: sMax,
|
||||
globalOtpauthUrl: gURL
|
||||
}, {
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
})
|
||||
}, { "X-CSRF-Token": window.csrfToken })
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
@@ -556,7 +579,223 @@ export async function closeAdminPanel() {
|
||||
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() {
|
||||
let userPermissionsModal = document.getElementById("userPermissionsModal");
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
@@ -565,8 +804,8 @@ export function openUserPermissionsModal() {
|
||||
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
|
||||
color: ${isDarkMode ? "#e0e0e0" : "#000"};
|
||||
padding: 20px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-width: 780px;
|
||||
width: 95%;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
`;
|
||||
@@ -576,22 +815,20 @@ export function openUserPermissionsModal() {
|
||||
userPermissionsModal.id = "userPermissionsModal";
|
||||
userPermissionsModal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
top: 0; left: 0; width: 100vw; height: 100vh;
|
||||
background-color: ${overlayBackground};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
z-index: 3500;
|
||||
`;
|
||||
userPermissionsModal.innerHTML = `
|
||||
<div class="modal-content" style="${modalContentStyles}">
|
||||
<span id="closeUserPermissionsModal" class="editor-close-btn">×</span>
|
||||
<h3>${t("user_permissions")}</h3>
|
||||
<div id="userPermissionsList" style="max-height: 300px; overflow-y: auto; margin-bottom: 15px;">
|
||||
<!-- User rows will be loaded here -->
|
||||
<h3>${tf("folder_access", "Folder Access")}</h3>
|
||||
<div class="muted" style="margin:-4px 0 10px;">
|
||||
${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 style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button type="button" id="cancelUserPermissionsBtn" class="btn btn-secondary">${t("cancel")}</button>
|
||||
@@ -606,98 +843,206 @@ export function openUserPermissionsModal() {
|
||||
document.getElementById("cancelUserPermissionsBtn").addEventListener("click", () => {
|
||||
userPermissionsModal.style.display = "none";
|
||||
});
|
||||
document.getElementById("saveUserPermissionsBtn").addEventListener("click", () => {
|
||||
// Collect permissions data from each user row.
|
||||
document.getElementById("saveUserPermissionsBtn").addEventListener("click", async () => {
|
||||
// Collect grants for every expanded user (or all rows that have a grants list)
|
||||
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
|
||||
const permissionsData = [];
|
||||
let saves = [];
|
||||
rows.forEach(row => {
|
||||
const g = k => row.querySelector(`input[data-permission='${k}']`)?.checked ?? false;
|
||||
permissionsData.push({
|
||||
username: row.getAttribute("data-username"),
|
||||
folderOnly: g("folderOnly"),
|
||||
readOnly: g("readOnly"),
|
||||
disableUpload: g("disableUpload"),
|
||||
bypassOwnership: g("bypassOwnership"),
|
||||
canShare: g("canShare"),
|
||||
canZip: g("canZip"),
|
||||
viewOwnOnly: g("viewOwnOnly"),
|
||||
const username = row.getAttribute("data-username");
|
||||
const grantsBox = row.querySelector(".folder-grants-box");
|
||||
if (!username || !grantsBox) return;
|
||||
const grants = collectGrantsFrom(grantsBox);
|
||||
saves.push({ user: username, grants });
|
||||
});
|
||||
});
|
||||
// Send the permissionsData to the server.
|
||||
sendRequest("/api/updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken })
|
||||
.then(response => {
|
||||
if (response.success) {
|
||||
showToast(t("user_permissions_updated_successfully"));
|
||||
userPermissionsModal.style.display = "none";
|
||||
} else {
|
||||
showToast(t("error_updating_permissions") + ": " + (response.error || t("unknown_error")));
|
||||
|
||||
try {
|
||||
if (saves.length === 0) {
|
||||
showToast(tf("nothing_to_save", "Nothing to save"));
|
||||
return;
|
||||
}
|
||||
for (const payload of saves) {
|
||||
await sendRequest("/api/admin/acl/saveGrants.php", "POST", payload, { "X-CSRF-Token": window.csrfToken });
|
||||
}
|
||||
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 {
|
||||
userPermissionsModal.style.display = "flex";
|
||||
}
|
||||
// Load the list of users into the modal.
|
||||
|
||||
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;">×</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");
|
||||
if (!listContainer) return;
|
||||
listContainer.innerHTML = "";
|
||||
listContainer.innerHTML = `<p>${t("loading")}…</p>`;
|
||||
|
||||
// First, fetch the current permissions from the server.
|
||||
fetch("/api/getUserPermissions.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(permissionsData => {
|
||||
// Then, fetch the list of users.
|
||||
return fetch("/api/getUsers.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(usersData => {
|
||||
try {
|
||||
const usersRes = await fetch("/api/getUsers.php", { credentials: "include" });
|
||||
const usersData = await safeJson(usersRes);
|
||||
const users = Array.isArray(usersData) ? usersData : (usersData.users || []);
|
||||
if (users.length === 0) {
|
||||
if (!users.length) {
|
||||
listContainer.innerHTML = "<p>" + t("no_users_found") + "</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
// Preload folders once (admin should see all)
|
||||
const folders = await getAllFolders();
|
||||
|
||||
listContainer.innerHTML = ""; // clear
|
||||
users.forEach(user => {
|
||||
// Skip admin users.
|
||||
if ((user.role && user.role === "1") || user.username.toLowerCase() === "admin") return;
|
||||
// Skip admins
|
||||
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,
|
||||
};
|
||||
const row = document.createElement("div");
|
||||
row.classList.add("user-permission-row");
|
||||
row.setAttribute("data-username", user.username);
|
||||
row.style.padding = "6px 0";
|
||||
|
||||
// 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");
|
||||
row.classList.add("user-permission-row");
|
||||
row.setAttribute("data-username", user.username);
|
||||
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"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@@ -709,46 +1054,49 @@ row.innerHTML = `
|
||||
<i class="material-icons perm-caret" style="transition:transform .2s; transform:rotate(-90deg);">expand_more</i>
|
||||
</div>
|
||||
|
||||
<div class="user-perm-details"
|
||||
style="display:none;margin:8px 4px 2px 10px;
|
||||
display:none;gap:8px;
|
||||
grid-template-columns: 1fr 1fr;">
|
||||
<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 class="user-perm-details" style="display:none;margin:8px 4px 2px 10px;">
|
||||
<div class="folder-grants-box">
|
||||
<div class="muted">${t("loading")}…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 details = row.querySelector(".user-perm-details");
|
||||
const caret = row.querySelector(".perm-caret");
|
||||
const header = row.querySelector(".user-perm-header");
|
||||
const details = row.querySelector(".user-perm-details");
|
||||
const caret = row.querySelector(".perm-caret");
|
||||
const grantsBox = row.querySelector(".folder-grants-box");
|
||||
|
||||
function toggleOpen() {
|
||||
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() {
|
||||
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");
|
||||
caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)";
|
||||
}
|
||||
if (willShow) ensureLoaded();
|
||||
}
|
||||
|
||||
header.addEventListener("click", toggleOpen);
|
||||
header.addEventListener("keydown", e => {
|
||||
header.addEventListener("click", toggleOpen);
|
||||
header.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); }
|
||||
});
|
||||
});
|
||||
|
||||
listContainer.appendChild(row);
|
||||
listContainer.appendChild(row);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>";
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,28 @@ import { openFolderShareModal } from './folderShareModal.js';
|
||||
import { fetchWithCsrf } from './auth.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)
|
||||
----------------------*/
|
||||
@@ -15,7 +37,7 @@ import { loadCsrfToken } from './main.js';
|
||||
export function formatFolderName(folder) {
|
||||
if (typeof folder !== "string") return "";
|
||||
if (folder.indexOf("/") !== -1) {
|
||||
let parts = folder.split("/");
|
||||
const parts = folder.split("/");
|
||||
let indent = "";
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
indent += "\u00A0\u00A0\u00A0\u00A0"; // 4 non-breaking spaces per level
|
||||
@@ -34,9 +56,7 @@ function buildFolderTree(folders) {
|
||||
const parts = folderPath.split('/');
|
||||
let current = tree;
|
||||
parts.forEach(part => {
|
||||
if (!current[part]) {
|
||||
current[part] = {};
|
||||
}
|
||||
if (!current[part]) current[part] = {};
|
||||
current = current[part];
|
||||
});
|
||||
});
|
||||
@@ -66,23 +86,29 @@ export function getParentFolder(folder) {
|
||||
Breadcrumb Functions
|
||||
----------------------*/
|
||||
|
||||
function renderBreadcrumb(normalizedFolder) {
|
||||
if (!normalizedFolder || normalizedFolder === "") return "";
|
||||
const parts = normalizedFolder.split("/");
|
||||
let breadcrumbItems = [];
|
||||
// Use the first segment as the root.
|
||||
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="${parts[0]}">${escapeHTML(parts[0])}</span>`);
|
||||
let cumulative = parts[0];
|
||||
parts.slice(1).forEach(part => {
|
||||
cumulative += "/" + part;
|
||||
breadcrumbItems.push(`<span class="breadcrumb-separator"> / </span>`);
|
||||
breadcrumbItems.push(`<span class="breadcrumb-link" data-folder="${cumulative}">${escapeHTML(part)}</span>`);
|
||||
});
|
||||
return breadcrumbItems.join('');
|
||||
async function applyFolderCapabilities(folder) {
|
||||
try {
|
||||
const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' });
|
||||
if (!res.ok) return;
|
||||
const caps = await res.json();
|
||||
|
||||
// top buttons
|
||||
const createBtn = document.getElementById('createFolderBtn');
|
||||
const renameBtn = document.getElementById('renameFolderBtn');
|
||||
const deleteBtn = document.getElementById('deleteFolderBtn');
|
||||
const shareBtn = document.getElementById('shareFolderBtn');
|
||||
|
||||
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 ---
|
||||
// bindBreadcrumbEvents(); removed in favor of delegation
|
||||
// --- Breadcrumb Delegation Setup ---
|
||||
export function setupBreadcrumbDelegation() {
|
||||
const container = document.getElementById("fileListTitle");
|
||||
if (!container) {
|
||||
@@ -104,7 +130,6 @@ export function setupBreadcrumbDelegation() {
|
||||
|
||||
// Click handler via delegation
|
||||
function breadcrumbClickHandler(e) {
|
||||
// find the nearest .breadcrumb-link
|
||||
const link = e.target.closest(".breadcrumb-link");
|
||||
if (!link) return;
|
||||
|
||||
@@ -115,12 +140,10 @@ function breadcrumbClickHandler(e) {
|
||||
window.currentFolder = folder;
|
||||
localStorage.setItem("lastOpenedFolder", folder);
|
||||
|
||||
// rebuild the title safely
|
||||
updateBreadcrumbTitle(folder);
|
||||
applyFolderCapabilities(folder);
|
||||
expandTreePath(folder);
|
||||
document.querySelectorAll(".folder-option").forEach(el =>
|
||||
el.classList.remove("selected")
|
||||
);
|
||||
document.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected"));
|
||||
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
||||
if (target) target.classList.add("selected");
|
||||
|
||||
@@ -158,20 +181,18 @@ function breadcrumbDropHandler(e) {
|
||||
}
|
||||
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
||||
if (filesToMove.length === 0) return;
|
||||
fetch("/api/file/moveFiles.php", {
|
||||
|
||||
fetchWithCsrf("/api/file/moveFiles.php", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: dragData.sourceFolder,
|
||||
files: filesToMove,
|
||||
destination: dropFolder
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(safeJson)
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(`File(s) moved successfully to ${dropFolder}!`);
|
||||
@@ -186,47 +207,39 @@ function breadcrumbDropHandler(e) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------
|
||||
Check Current User's Folder-Only Permission
|
||||
----------------------*/
|
||||
// This function uses localStorage values (set during login) to determine if the current user is restricted.
|
||||
// If folderOnly is "true", then the personal folder (i.e. username) is forced as the effective root.
|
||||
function checkUserFolderPermission() {
|
||||
const username = localStorage.getItem("username");
|
||||
console.log("checkUserFolderPermission: username =", username);
|
||||
if (!username) {
|
||||
console.warn("No username in localStorage; skipping getUserPermissions fetch.");
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
if (localStorage.getItem("folderOnly") === "true") {
|
||||
window.userFolderOnly = true;
|
||||
console.log("checkUserFolderPermission: using localStorage.folderOnly = true");
|
||||
// Authoritatively determine from the server; still write to localStorage for UI,
|
||||
// but ignore any preexisting localStorage override for security.
|
||||
async function checkUserFolderPermission() {
|
||||
const username = localStorage.getItem("username") || "";
|
||||
try {
|
||||
const res = await fetchWithCsrf("/api/getUserPermissions.php", {
|
||||
method: "GET",
|
||||
credentials: "include"
|
||||
});
|
||||
const permissionsData = await safeJson(res);
|
||||
|
||||
const isFolderOnly =
|
||||
!!(permissionsData &&
|
||||
permissionsData[username] &&
|
||||
permissionsData[username].folderOnly);
|
||||
|
||||
window.userFolderOnly = isFolderOnly;
|
||||
localStorage.setItem("folderOnly", isFolderOnly ? "true" : "false");
|
||||
|
||||
if (isFolderOnly && username) {
|
||||
localStorage.setItem("lastOpenedFolder", username);
|
||||
window.currentFolder = username;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return fetch("/api/getUserPermissions.php", { credentials: "include" })
|
||||
.then(response => response.json())
|
||||
.then(permissionsData => {
|
||||
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 {
|
||||
return isFolderOnly;
|
||||
} catch (err) {
|
||||
console.error("Error fetching user permissions:", err);
|
||||
window.userFolderOnly = false;
|
||||
localStorage.setItem("folderOnly", "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");
|
||||
if (toggle) {
|
||||
toggle.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
||||
let state = loadFolderTreeState();
|
||||
const state = loadFolderTreeState();
|
||||
state[cumulative] = "block";
|
||||
saveFolderTreeState(state);
|
||||
}
|
||||
@@ -307,20 +320,18 @@ function folderDropHandler(event) {
|
||||
}
|
||||
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
|
||||
if (filesToMove.length === 0) return;
|
||||
fetch("/api/file/moveFiles.php", {
|
||||
|
||||
fetchWithCsrf("/api/file/moveFiles.php", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: dragData.sourceFolder,
|
||||
files: filesToMove,
|
||||
destination: dropFolder
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(safeJson)
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(`File(s) moved successfully to ${dropFolder}!`);
|
||||
@@ -338,7 +349,7 @@ function folderDropHandler(event) {
|
||||
/* ----------------------
|
||||
Main Folder Tree Rendering and Event Binding
|
||||
----------------------*/
|
||||
// --- Helpers for safe breadcrumb rendering ---
|
||||
// Safe breadcrumb DOM builder
|
||||
function renderBreadcrumbFragment(folderPath) {
|
||||
const frag = document.createDocumentFragment();
|
||||
const parts = folderPath.split("/");
|
||||
@@ -363,49 +374,52 @@ function renderBreadcrumbFragment(folderPath) {
|
||||
|
||||
export function updateBreadcrumbTitle(folder) {
|
||||
const titleEl = document.getElementById("fileListTitle");
|
||||
if (!titleEl) return;
|
||||
titleEl.textContent = "";
|
||||
titleEl.appendChild(document.createTextNode(t("files_in") + " ("));
|
||||
titleEl.appendChild(renderBreadcrumbFragment(folder));
|
||||
titleEl.appendChild(document.createTextNode(")"));
|
||||
setupBreadcrumbDelegation();
|
||||
// Ensure context menu delegation is hooked to the dynamic breadcrumb container
|
||||
bindFolderManagerContextMenu();
|
||||
}
|
||||
|
||||
export async function loadFolderTree(selectedFolder) {
|
||||
try {
|
||||
// Check if the user has folder-only permission.
|
||||
// Check if the user has folder-only permission (server-authoritative).
|
||||
await checkUserFolderPermission();
|
||||
|
||||
// Determine effective root folder.
|
||||
const username = localStorage.getItem("username") || "root";
|
||||
let effectiveRoot = "root";
|
||||
let effectiveLabel = "(Root)";
|
||||
if (window.userFolderOnly) {
|
||||
effectiveRoot = username; // Use the username as the personal root.
|
||||
if (window.userFolderOnly && username) {
|
||||
effectiveRoot = username; // personal root
|
||||
effectiveLabel = `(Root)`;
|
||||
// Force override of any saved folder.
|
||||
localStorage.setItem("lastOpenedFolder", username);
|
||||
window.currentFolder = username;
|
||||
} else {
|
||||
window.currentFolder = localStorage.getItem("lastOpenedFolder") || "root";
|
||||
}
|
||||
|
||||
// Build fetch URL.
|
||||
let fetchUrl = '/api/folder/getFolderList.php';
|
||||
if (window.userFolderOnly) {
|
||||
fetchUrl += '?restricted=1';
|
||||
}
|
||||
console.log("Fetching folder list from:", fetchUrl);
|
||||
// Fetch folder list from the server (server enforces scope).
|
||||
const res = await fetchWithCsrf('/api/folder/getFolderList.php', {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
// Fetch folder list from the server.
|
||||
const response = await fetch(fetchUrl);
|
||||
if (response.status === 401) {
|
||||
console.error("Unauthorized: Please log in to view folders.");
|
||||
if (res.status === 401) {
|
||||
showToast("Session expired. Please log in again.");
|
||||
window.location.href = "/api/auth/logout.php";
|
||||
return;
|
||||
}
|
||||
let folderData = await response.json();
|
||||
console.log("Folder data received:", folderData);
|
||||
if (res.status === 403) {
|
||||
showToast("You don't have permission to view folders.");
|
||||
return;
|
||||
}
|
||||
|
||||
const folderData = await safeJson(res);
|
||||
|
||||
let folders = [];
|
||||
if (Array.isArray(folderData) && folderData.length && typeof folderData[0] === "object" && folderData[0].folder) {
|
||||
folders = folderData.map(item => item.folder);
|
||||
@@ -413,13 +427,12 @@ export async function loadFolderTree(selectedFolder) {
|
||||
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");
|
||||
|
||||
// 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") {
|
||||
folders = folders.filter(folder => folder.startsWith(effectiveRoot + "/"));
|
||||
// Force current folder to be the effective root.
|
||||
localStorage.setItem("lastOpenedFolder", effectiveRoot);
|
||||
window.currentFolder = effectiveRoot;
|
||||
}
|
||||
@@ -455,8 +468,9 @@ export async function loadFolderTree(selectedFolder) {
|
||||
}
|
||||
localStorage.setItem("lastOpenedFolder", window.currentFolder);
|
||||
|
||||
// Initial breadcrumb update
|
||||
// Initial breadcrumb + file list
|
||||
updateBreadcrumbTitle(window.currentFolder);
|
||||
applyFolderCapabilities(window.currentFolder);
|
||||
loadFileList(window.currentFolder);
|
||||
|
||||
const folderState = loadFolderTreeState();
|
||||
@@ -480,8 +494,8 @@ export async function loadFolderTree(selectedFolder) {
|
||||
window.currentFolder = selected;
|
||||
localStorage.setItem("lastOpenedFolder", selected);
|
||||
|
||||
// Safe breadcrumb update
|
||||
updateBreadcrumbTitle(selected);
|
||||
applyFolderCapabilities(selected);
|
||||
loadFileList(selected);
|
||||
});
|
||||
});
|
||||
@@ -493,7 +507,7 @@ export async function loadFolderTree(selectedFolder) {
|
||||
e.stopPropagation();
|
||||
const nestedUl = container.querySelector("#rootRow + ul");
|
||||
if (nestedUl) {
|
||||
let state = loadFolderTreeState();
|
||||
const state = loadFolderTreeState();
|
||||
if (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded")) {
|
||||
nestedUl.classList.remove("collapsed");
|
||||
nestedUl.classList.add("expanded");
|
||||
@@ -516,7 +530,7 @@ export async function loadFolderTree(selectedFolder) {
|
||||
e.stopPropagation();
|
||||
const siblingUl = this.parentNode.querySelector("ul");
|
||||
const folderPath = this.getAttribute("data-folder");
|
||||
let state = loadFolderTreeState();
|
||||
const state = loadFolderTreeState();
|
||||
if (siblingUl) {
|
||||
if (siblingUl.classList.contains("collapsed") || !siblingUl.classList.contains("expanded")) {
|
||||
siblingUl.classList.remove("collapsed");
|
||||
@@ -536,10 +550,12 @@ export async function loadFolderTree(selectedFolder) {
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error loading folder tree:", error);
|
||||
if (error.status === 403) {
|
||||
showToast("You don't have permission to view folders.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For backward compatibility.
|
||||
export function loadFolderList(selectedFolder) {
|
||||
loadFolderTree(selectedFolder);
|
||||
@@ -548,8 +564,11 @@ export function loadFolderList(selectedFolder) {
|
||||
/* ----------------------
|
||||
Folder Management (Rename, Delete, Create)
|
||||
----------------------*/
|
||||
document.getElementById("renameFolderBtn").addEventListener("click", openRenameFolderModal);
|
||||
document.getElementById("deleteFolderBtn").addEventListener("click", openDeleteFolderModal);
|
||||
const renameBtn = document.getElementById("renameFolderBtn");
|
||||
if (renameBtn) renameBtn.addEventListener("click", openRenameFolderModal);
|
||||
|
||||
const deleteBtn = document.getElementById("deleteFolderBtn");
|
||||
if (deleteBtn) deleteBtn.addEventListener("click", openDeleteFolderModal);
|
||||
|
||||
export function openRenameFolderModal() {
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
@@ -558,45 +577,50 @@ export function openRenameFolderModal() {
|
||||
return;
|
||||
}
|
||||
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 modal = document.getElementById("renameFolderModal");
|
||||
if (!input || !modal) return;
|
||||
input.value = parts[parts.length - 1];
|
||||
modal.style.display = "block";
|
||||
setTimeout(() => {
|
||||
input.focus();
|
||||
input.select();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
document.getElementById("cancelRenameFolder").addEventListener("click", function () {
|
||||
document.getElementById("renameFolderModal").style.display = "none";
|
||||
document.getElementById("newRenameFolderName").value = "";
|
||||
});
|
||||
const cancelRename = document.getElementById("cancelRenameFolder");
|
||||
if (cancelRename) {
|
||||
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");
|
||||
document.getElementById("submitRenameFolder").addEventListener("click", function (event) {
|
||||
|
||||
const submitRename = document.getElementById("submitRenameFolder");
|
||||
if (submitRename) {
|
||||
submitRename.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
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()) {
|
||||
showToast("Please enter a valid new folder name.");
|
||||
return;
|
||||
}
|
||||
const parentPath = getParentFolder(selectedFolder);
|
||||
const newFolderFull = parentPath === "root" ? newNameBasename : parentPath + "/" + newNameBasename;
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
if (!csrfToken) {
|
||||
showToast("CSRF token not loaded yet! Please try again.");
|
||||
return;
|
||||
}
|
||||
fetch("/api/folder/renameFolder.php", {
|
||||
|
||||
fetchWithCsrf("/api/folder/renameFolder.php", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrfToken
|
||||
},
|
||||
body: JSON.stringify({ oldFolder: window.currentFolder, newFolder: newFolderFull })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(safeJson)
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast("Folder renamed successfully!");
|
||||
@@ -609,10 +633,13 @@ document.getElementById("submitRenameFolder").addEventListener("click", function
|
||||
})
|
||||
.catch(error => console.error("Error renaming folder:", error))
|
||||
.finally(() => {
|
||||
document.getElementById("renameFolderModal").style.display = "none";
|
||||
document.getElementById("newRenameFolderName").value = "";
|
||||
const modal = document.getElementById("renameFolderModal");
|
||||
const input2 = document.getElementById("newRenameFolderName");
|
||||
if (modal) modal.style.display = "none";
|
||||
if (input2) input2.value = "";
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function openDeleteFolderModal() {
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
@@ -620,27 +647,34 @@ export function openDeleteFolderModal() {
|
||||
showToast("Please select a valid folder to delete.");
|
||||
return;
|
||||
}
|
||||
document.getElementById("deleteFolderMessage").textContent =
|
||||
"Are you sure you want to delete folder " + selectedFolder + "?";
|
||||
document.getElementById("deleteFolderModal").style.display = "block";
|
||||
const msgEl = document.getElementById("deleteFolderMessage");
|
||||
const modal = document.getElementById("deleteFolderModal");
|
||||
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 () {
|
||||
document.getElementById("deleteFolderModal").style.display = "none";
|
||||
});
|
||||
const cancelDelete = document.getElementById("cancelDeleteFolder");
|
||||
if (cancelDelete) {
|
||||
cancelDelete.addEventListener("click", function () {
|
||||
const modal = document.getElementById("deleteFolderModal");
|
||||
if (modal) modal.style.display = "none";
|
||||
});
|
||||
}
|
||||
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 csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
fetch("/api/folder/deleteFolder.php", {
|
||||
|
||||
fetchWithCsrf("/api/folder/deleteFolder.php", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrfToken
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ folder: selectedFolder })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(safeJson)
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast("Folder deleted successfully!");
|
||||
@@ -653,22 +687,38 @@ document.getElementById("confirmDeleteFolder").addEventListener("click", functio
|
||||
})
|
||||
.catch(error => console.error("Error deleting folder:", error))
|
||||
.finally(() => {
|
||||
document.getElementById("deleteFolderModal").style.display = "none";
|
||||
const modal = document.getElementById("deleteFolderModal");
|
||||
if (modal) modal.style.display = "none";
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("createFolderBtn").addEventListener("click", function () {
|
||||
document.getElementById("createFolderModal").style.display = "block";
|
||||
document.getElementById("newFolderName").focus();
|
||||
});
|
||||
const createBtn = document.getElementById("createFolderBtn");
|
||||
if (createBtn) {
|
||||
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 () {
|
||||
document.getElementById("createFolderModal").style.display = "none";
|
||||
document.getElementById("newFolderName").value = "";
|
||||
});
|
||||
const cancelCreate = document.getElementById("cancelCreateFolder");
|
||||
if (cancelCreate) {
|
||||
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");
|
||||
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.");
|
||||
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
@@ -685,23 +735,12 @@ document.getElementById("submitCreateFolder").addEventListener("click", async ()
|
||||
fetchWithCsrf("/api/folder/createFolder.php", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ folderName: folderInput, parent })
|
||||
})
|
||||
.then(async res => {
|
||||
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(safeJson)
|
||||
.then(data => {
|
||||
if (!data.success) throw new Error(data.error || "Server rejected the request");
|
||||
showToast("Folder created!");
|
||||
const full = parent ? `${parent}/${folderInput}` : folderInput;
|
||||
window.currentFolder = full;
|
||||
@@ -712,10 +751,13 @@ document.getElementById("submitCreateFolder").addEventListener("click", async ()
|
||||
showToast("Error creating folder: " + e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
document.getElementById("createFolderModal").style.display = "none";
|
||||
document.getElementById("newFolderName").value = "";
|
||||
const modal = document.getElementById("createFolderModal");
|
||||
const input2 = document.getElementById("newFolderName");
|
||||
if (modal) modal.style.display = "none";
|
||||
if (input2) input2.value = "";
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
|
||||
export function showFolderManagerContextMenu(x, y, menuItems) {
|
||||
@@ -773,21 +815,28 @@ export function hideFolderManagerContextMenu() {
|
||||
}
|
||||
|
||||
function folderManagerContextMenuHandler(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const target = e.target.closest(".folder-option, .breadcrumb-link");
|
||||
if (!target) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const folder = target.getAttribute("data-folder");
|
||||
if (!folder) return;
|
||||
window.currentFolder = folder;
|
||||
|
||||
// Visual selection
|
||||
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));
|
||||
target.classList.add("selected");
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: t("create_folder"),
|
||||
action: () => {
|
||||
document.getElementById("createFolderModal").style.display = "block";
|
||||
document.getElementById("newFolderName").focus();
|
||||
const modal = document.getElementById("createFolderModal");
|
||||
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);
|
||||
}
|
||||
|
||||
// Delegate contextmenu so it works with dynamically re-rendered breadcrumbs
|
||||
function bindFolderManagerContextMenu() {
|
||||
const container = document.getElementById("folderTreeContainer");
|
||||
if (container) {
|
||||
container.removeEventListener("contextmenu", folderManagerContextMenuHandler);
|
||||
container.addEventListener("contextmenu", folderManagerContextMenuHandler, false);
|
||||
const tree = document.getElementById("folderTreeContainer");
|
||||
if (tree) {
|
||||
// remove old bound handler if present
|
||||
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 () {
|
||||
@@ -825,8 +891,8 @@ document.addEventListener("click", function () {
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.addEventListener("keydown", function (e) {
|
||||
const tag = e.target.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || e.target.isContentEditable) {
|
||||
const tag = e.target.tagName ? e.target.tagName.toLowerCase() : "";
|
||||
if (tag === "input" || tag === "textarea" || (e.target && e.target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
// Call the folder share modal from the module.
|
||||
openFolderShareModal(selectedFolder);
|
||||
});
|
||||
} else {
|
||||
@@ -855,4 +920,5 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
}
|
||||
});
|
||||
|
||||
// Initial context menu delegation bind
|
||||
bindFolderManagerContextMenu();
|
||||
@@ -51,6 +51,52 @@ async function fetchWithCsrfAndRefresh(input, init = {}) {
|
||||
// Replace global fetch with the wrapped version so *all* callers benefit.
|
||||
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 don’t have access to that.", "error");
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
/* =========================
|
||||
APP INIT
|
||||
========================= */
|
||||
@@ -94,7 +140,7 @@ export function initializeApp() {
|
||||
initUpload();
|
||||
loadFolderTree();
|
||||
setupTrashRestoreDelete();
|
||||
loadAdminConfigFunc();
|
||||
// NOTE: loadAdminConfigFunc() is called once in DOMContentLoaded; calling here would duplicate requests.
|
||||
|
||||
const helpBtn = document.getElementById("folderHelpBtn");
|
||||
const helpTooltip = document.getElementById("folderHelpTooltip");
|
||||
@@ -170,6 +216,7 @@ window.openDownloadModal = openDownloadModal;
|
||||
window.currentFolder = "root";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Load admin config once here; non-admins may get 403, which is fine.
|
||||
loadAdminConfigFunc();
|
||||
|
||||
// i18n
|
||||
|
||||
@@ -13,10 +13,12 @@ if (
|
||||
}
|
||||
|
||||
// ─── 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__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole(), loadFolderPermission()
|
||||
require_once __DIR__ . '/../src/models/AdminModel.php'; // AdminModel::getConfig()
|
||||
require_once __DIR__ . '/../src/models/AuthModel.php'; // AuthModel::authenticate(), getUserRole()
|
||||
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 ──────────────────────────────────────
|
||||
$adminConfig = AdminModel::getConfig();
|
||||
@@ -27,42 +29,46 @@ if (!$enableWebDAV) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// ─── 2) Load WebDAV directory implementation ──────────────────────────
|
||||
// ─── 2) Load WebDAV directory implementation (ACL-aware) ────────────────────
|
||||
require_once __DIR__ . '/../src/webdav/FileRiseDirectory.php';
|
||||
|
||||
use Sabre\DAV\Server;
|
||||
use Sabre\DAV\Auth\Backend\BasicCallBack;
|
||||
use Sabre\DAV\Auth\Plugin as AuthPlugin;
|
||||
use Sabre\DAV\Locks\Plugin as LocksPlugin;
|
||||
use Sabre\DAV\Locks\Backend\File as LocksFileBackend;
|
||||
use FileRise\WebDAV\FileRiseDirectory;
|
||||
use FileRise\WebDAV\CurrentUser;
|
||||
|
||||
// ─── 3) HTTP‑Basic backend ─────────────────────────────────────────────────
|
||||
// ─── 3) HTTP-Basic backend (delegates to your AuthModel) ────────────────────
|
||||
$authBackend = new BasicCallBack(function(string $user, string $pass) {
|
||||
return \AuthModel::authenticate($user, $pass) !== false;
|
||||
});
|
||||
$authPlugin = new AuthPlugin($authBackend, 'FileRise');
|
||||
|
||||
// ─── 4) Determine user scope ────────────────────────────────────────────────
|
||||
// ─── 4) Resolve authenticated user + perms ──────────────────────────────────
|
||||
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
|
||||
$isAdmin = (\AuthModel::getUserRole($user) === '1');
|
||||
$folderOnly = (bool)\AuthModel::loadFolderPermission($user);
|
||||
|
||||
if ($isAdmin || !$folderOnly) {
|
||||
// Admins (or users without folder-only restriction) see the full /uploads
|
||||
$rootPath = rtrim(UPLOAD_DIR, '/\\');
|
||||
} else {
|
||||
// Folder‑only users see only /uploads/{username}
|
||||
$rootPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $user;
|
||||
if (!is_dir($rootPath)) {
|
||||
mkdir($rootPath, 0755, true);
|
||||
}
|
||||
if ($user === '') {
|
||||
header('HTTP/1.1 401 Unauthorized');
|
||||
header('WWW-Authenticate: Basic realm="FileRise"');
|
||||
echo 'Authentication required.';
|
||||
exit;
|
||||
}
|
||||
|
||||
// ─── 5) Spin up SabreDAV ────────────────────────────────────────────────────
|
||||
$perms = is_callable('loadUserPermissions') ? (loadUserPermissions($user) ?: []) : [];
|
||||
$isAdmin = (\AuthModel::getUserRole($user) === '1');
|
||||
|
||||
// set for metadata attribution in WebDAV writes
|
||||
CurrentUser::set($user);
|
||||
|
||||
// ─── 5) Mount the real uploads root; ACL filters everything at node level ───
|
||||
$rootPath = rtrim(UPLOAD_DIR, '/\\');
|
||||
|
||||
$server = new Server([
|
||||
new FileRiseDirectory($rootPath, $user, $folderOnly),
|
||||
new FileRiseDirectory($rootPath, $user, $isAdmin, $perms),
|
||||
]);
|
||||
|
||||
// Auth + Locks
|
||||
$server->addPlugin($authPlugin);
|
||||
$server->addPlugin(
|
||||
new LocksPlugin(
|
||||
@@ -70,5 +76,8 @@ $server->addPlugin(
|
||||
)
|
||||
);
|
||||
|
||||
// Base URI (adjust if you serve from a subdir or rewrite rule)
|
||||
$server->setBaseUri('/webdav.php/');
|
||||
|
||||
// Execute
|
||||
$server->exec();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
// src/controllers/UploadController.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
require_once PROJECT_ROOT . '/src/models/UploadModel.php';
|
||||
|
||||
class UploadController {
|
||||
@@ -73,68 +74,79 @@ class UploadController {
|
||||
public function handleUpload(): void {
|
||||
header('Content-Type: application/json');
|
||||
|
||||
//
|
||||
// 1) CSRF – pull from header or POST fields
|
||||
//
|
||||
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
// ---- 1) CSRF (header or form field) ----
|
||||
$headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
|
||||
$received = '';
|
||||
if (!empty($headersArr['x-csrf-token'])) {
|
||||
$received = trim($headersArr['x-csrf-token']);
|
||||
} elseif (!empty($_POST['csrf_token'])) {
|
||||
$received = trim($_POST['csrf_token']);
|
||||
} elseif (!empty($_POST['upload_token'])) {
|
||||
// legacy alias
|
||||
$received = trim($_POST['upload_token']);
|
||||
}
|
||||
|
||||
// 1a) If it doesn’t match, soft-fail: send new token and let client retry
|
||||
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));
|
||||
// tell client “please retry with this new token”
|
||||
http_response_code(200);
|
||||
echo json_encode([
|
||||
'csrf_expired' => true,
|
||||
'csrf_token' => $_SESSION['csrf_token']
|
||||
]);
|
||||
exit;
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// 2) Auth checks
|
||||
//
|
||||
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
// ---- 2) Auth + account-level flags ----
|
||||
if (empty($_SESSION['authenticated'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
$userPerms = loadUserPermissions($_SESSION['username']);
|
||||
if (!empty($userPerms['disableUpload'])) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Upload disabled for this user."]);
|
||||
exit;
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// 3) Delegate the actual file handling
|
||||
//
|
||||
$username = (string)($_SESSION['username'] ?? '');
|
||||
$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);
|
||||
|
||||
//
|
||||
// 4) Respond
|
||||
//
|
||||
// ---- 5) Response ----
|
||||
if (isset($result['error'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
return;
|
||||
}
|
||||
if (isset($result['status'])) {
|
||||
// e.g., {"status":"chunk uploaded"}
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
return;
|
||||
}
|
||||
|
||||
// full‐upload redirect
|
||||
$_SESSION['upload_message'] = "File uploaded successfully.";
|
||||
exit;
|
||||
echo json_encode([
|
||||
'success' => 'File uploaded successfully',
|
||||
'newFilename' => $result['newFilename'] ?? null
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,24 +188,21 @@ class UploadController {
|
||||
public function removeChunks(): void {
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// CSRF Protection: Validate token from POST data.
|
||||
$receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : '';
|
||||
if ($receivedToken !== $_SESSION['csrf_token']) {
|
||||
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
|
||||
http_response_code(403);
|
||||
echo json_encode(["error" => "Invalid CSRF token"]);
|
||||
exit;
|
||||
echo json_encode(['error' => 'Invalid CSRF token']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that the folder parameter is provided.
|
||||
if (!isset($_POST['folder'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(["error" => "No folder specified"]);
|
||||
exit;
|
||||
echo json_encode(['error' => 'No folder specified']);
|
||||
return;
|
||||
}
|
||||
|
||||
$folder = $_POST['folder'];
|
||||
$folder = (string)$_POST['folder'];
|
||||
$result = UploadModel::removeChunks($folder);
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -60,15 +60,36 @@ class UserController
|
||||
|
||||
/** Enforce admin (401). */
|
||||
private static function requireAdmin(): void
|
||||
{
|
||||
{
|
||||
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 user’s 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');
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
echo json_encode(['error' => 'Admin privileges required.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */
|
||||
private static function requireCsrf(): void
|
||||
|
||||
347
src/lib/ACL.php
Normal file
347
src/lib/ACL.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// src/models/FileModel.php
|
||||
|
||||
require_once PROJECT_ROOT . '/config/config.php';
|
||||
require_once __DIR__ . '/../../src/lib/ACL.php';
|
||||
|
||||
class FileModel {
|
||||
|
||||
|
||||
@@ -2,9 +2,99 @@
|
||||
// src/models/FolderModel.php
|
||||
|
||||
require_once PROJECT_ROOT . '/config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
|
||||
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
|
||||
* under UPLOAD_DIR. Validates each path segment against REGEX_FOLDER_NAME, enforces
|
||||
@@ -59,9 +149,7 @@ class FolderModel
|
||||
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
|
||||
{
|
||||
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.
|
||||
* 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);
|
||||
$parent = trim($parent);
|
||||
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
||||
return ["error" => "Invalid folder name."];
|
||||
if ($folderName === '' || !preg_match(REGEX_FOLDER_NAME, $folderName)) {
|
||||
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)
|
||||
[$parentReal, $parentRel, $err] = self::resolveFolderPath($parent === '' ? 'root' : $parent, true);
|
||||
if ($err) return ["error" => $err];
|
||||
// Compute ACL key and filesystem path
|
||||
$aclKey = ($parent === '' || strcasecmp($parent, 'root') === 0) ? $folderName : ($parent . '/' . $folderName);
|
||||
|
||||
$targetRel = ($parentRel === 'root') ? $folderName : ($parentRel . '/' . $folderName);
|
||||
$targetDir = $parentReal . DIRECTORY_SEPARATOR . $folderName;
|
||||
$base = rtrim(UPLOAD_DIR, '/\\');
|
||||
$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)) {
|
||||
return ["error" => "Folder already exists."];
|
||||
// Safety: stay inside UPLOAD_DIR
|
||||
$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)) {
|
||||
return ["error" => "Failed to create folder."];
|
||||
if (is_dir($path)) {
|
||||
// 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.
|
||||
$metadataFile = self::getMetadataFilePath($targetRel);
|
||||
if (file_put_contents($metadataFile, json_encode([], JSON_PRETTY_PRINT), LOCK_EX) === false) {
|
||||
return ["error" => "Folder created but failed to create metadata file."];
|
||||
if (!@mkdir($path, 0775, true)) {
|
||||
return ['success' => false, 'error' => 'Failed to create folder', 'code' => 500];
|
||||
}
|
||||
|
||||
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.
|
||||
* Also removes ownership mappings for this folder and all its descendants.
|
||||
*/
|
||||
public static function deleteFolder(string $folder): array
|
||||
{
|
||||
@@ -119,12 +232,12 @@ class FolderModel
|
||||
if ($err) return ["error" => $err];
|
||||
|
||||
// Prevent deletion if not empty.
|
||||
$items = array_diff(scandir($real), array('.', '..'));
|
||||
$items = array_diff(@scandir($real) ?: [], array('.', '..'));
|
||||
if (count($items) > 0) {
|
||||
return ["error" => "Folder is not empty."];
|
||||
}
|
||||
|
||||
if (!rmdir($real)) {
|
||||
if (!@rmdir($real)) {
|
||||
return ["error" => "Failed to delete folder."];
|
||||
}
|
||||
|
||||
@@ -134,11 +247,15 @@ class FolderModel
|
||||
@unlink($metadataFile);
|
||||
}
|
||||
|
||||
// Remove ownership mappings for the subtree.
|
||||
self::removeOwnerForTree($relative);
|
||||
|
||||
return ["success" => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
@@ -163,6 +280,7 @@ class FolderModel
|
||||
if ($base === false) return ["error" => "Uploads directory not configured correctly."];
|
||||
|
||||
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p!=='');
|
||||
$newRel = implode('/', $newParts);
|
||||
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
|
||||
|
||||
// Parent of new path must exist
|
||||
@@ -174,13 +292,13 @@ class FolderModel
|
||||
return ["error" => "New folder name already exists."];
|
||||
}
|
||||
|
||||
if (!rename($oldReal, $newPath)) {
|
||||
if (!@rename($oldReal, $newPath)) {
|
||||
return ["error" => "Failed to rename folder."];
|
||||
}
|
||||
|
||||
// Update metadata filenames (prefix-rename)
|
||||
$oldPrefix = str_replace(['/', '\\', ' '], '-', $oldRel);
|
||||
$newPrefix = str_replace(['/', '\\', ' '], '-', implode('/', $newParts));
|
||||
$newPrefix = str_replace(['/', '\\', ' '], '-', $newRel);
|
||||
$globPat = META_DIR . $oldPrefix . '*_metadata.json';
|
||||
$metadataFiles = glob($globPat) ?: [];
|
||||
|
||||
@@ -191,6 +309,9 @@ class FolderModel
|
||||
@rename($oldMetaFile, $newMeta);
|
||||
}
|
||||
|
||||
// Update ownership mapping for the entire subtree.
|
||||
self::renameOwnersForTree($oldRel, $newRel);
|
||||
|
||||
return ["success" => true];
|
||||
}
|
||||
|
||||
@@ -217,8 +338,9 @@ class FolderModel
|
||||
|
||||
/**
|
||||
* 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);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ class userModel
|
||||
* Remove a user and update encrypted userPermissions.json.
|
||||
*/
|
||||
public static function removeUser($usernameToRemove)
|
||||
{
|
||||
{
|
||||
global $encryptionKey;
|
||||
|
||||
if (!preg_match(REGEX_USER, $usernameToRemove)) {
|
||||
@@ -102,9 +102,9 @@ class userModel
|
||||
if (count($parts) < 3) {
|
||||
continue;
|
||||
}
|
||||
if ($parts[0] === $usernameToRemove) {
|
||||
if (strcasecmp($parts[0], $usernameToRemove) === 0) {
|
||||
$userFound = true;
|
||||
continue; // skip
|
||||
continue; // skip this user
|
||||
}
|
||||
$newUsers[] = $line;
|
||||
}
|
||||
@@ -118,7 +118,7 @@ class userModel
|
||||
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";
|
||||
if (file_exists($permissionsFile)) {
|
||||
$raw = file_get_contents($permissionsFile);
|
||||
@@ -128,15 +128,46 @@ class userModel
|
||||
: (json_decode($raw, true) ?: []); // tolerate legacy plaintext
|
||||
|
||||
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);
|
||||
$enc = encryptData($plain, $encryptionKey);
|
||||
file_put_contents($permissionsFile, $enc, LOCK_EX);
|
||||
}
|
||||
}
|
||||
|
||||
return ["success" => "User removed successfully"];
|
||||
// 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"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permissions for current user (or all, if admin).
|
||||
@@ -188,7 +219,7 @@ class userModel
|
||||
if (file_exists($permissionsFile)) {
|
||||
$encryptedContent = file_get_contents($permissionsFile);
|
||||
$json = decryptData($encryptedContent, $encryptionKey);
|
||||
if ($json === false) $json = $encryptedContent; // plain JSON fallback
|
||||
if ($json === false) $json = $encryptedContent; // legacy plaintext
|
||||
$existingPermissions = json_decode($json, true) ?: [];
|
||||
}
|
||||
|
||||
@@ -209,22 +240,34 @@ class userModel
|
||||
'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) {
|
||||
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
|
||||
|
||||
$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) {
|
||||
if (array_key_exists($k, $perm)) {
|
||||
$current[$k] = (bool)$perm[$k];
|
||||
} elseif (!isset($current[$k])) {
|
||||
// default missing keys to false (preserve existing if set)
|
||||
$current[$k] = false;
|
||||
}
|
||||
}
|
||||
$existingPermissions[$uname] = $current;
|
||||
|
||||
$existingPermissions[$storeKey] = $current;
|
||||
$lcIndex[$unameLc] = $storeKey; // keep index up to date
|
||||
}
|
||||
|
||||
$plain = json_encode($existingPermissions, JSON_PRETTY_PRINT);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
namespace FileRise\WebDAV;
|
||||
|
||||
// Bootstrap constants and models
|
||||
require_once __DIR__ . '/../../config/config.php'; // UPLOAD_DIR, META_DIR, DATE_TIME_FORMAT
|
||||
require_once __DIR__ . '/../../config/config.php'; // constants + loadUserPermissions()
|
||||
require_once __DIR__ . '/../../vendor/autoload.php'; // SabreDAV
|
||||
require_once __DIR__ . '/../../src/lib/ACL.php';
|
||||
require_once __DIR__ . '/../../src/models/FolderModel.php';
|
||||
require_once __DIR__ . '/../../src/models/FileModel.php';
|
||||
require_once __DIR__ . '/FileRiseFile.php';
|
||||
@@ -12,24 +12,27 @@ use Sabre\DAV\ICollection;
|
||||
use Sabre\DAV\INode;
|
||||
use Sabre\DAV\Exception\NotFound;
|
||||
use Sabre\DAV\Exception\Forbidden;
|
||||
use FileRise\WebDAV\FileRiseFile;
|
||||
use FolderModel;
|
||||
use FileModel;
|
||||
|
||||
class FileRiseDirectory implements ICollection, INode {
|
||||
private string $path;
|
||||
private string $user;
|
||||
private bool $folderOnly;
|
||||
private bool $isAdmin;
|
||||
private array $perms;
|
||||
|
||||
/** cache of folder => metadata array */
|
||||
private array $metaCache = [];
|
||||
|
||||
/**
|
||||
* @param string $path Absolute filesystem path (no trailing slash)
|
||||
* @param string $user Authenticated username
|
||||
* @param bool $folderOnly If true, non‑admins 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->user = $user;
|
||||
$this->folderOnly = $folderOnly;
|
||||
$this->isAdmin = $isAdmin;
|
||||
$this->perms = $perms;
|
||||
}
|
||||
|
||||
// ── INode ───────────────────────────────────────────
|
||||
@@ -39,72 +42,185 @@ class FileRiseDirectory implements ICollection, INode {
|
||||
}
|
||||
|
||||
public function getLastModified(): int {
|
||||
return filemtime($this->path);
|
||||
return @filemtime($this->path) ?: time();
|
||||
}
|
||||
|
||||
public function delete(): void {
|
||||
throw new Forbidden('Cannot delete this node');
|
||||
throw new Forbidden('Cannot delete directories via WebDAV');
|
||||
}
|
||||
|
||||
public function setName($name): void {
|
||||
throw new Forbidden('Renaming not supported');
|
||||
throw new Forbidden('Renaming directories is not supported');
|
||||
}
|
||||
|
||||
// ── ICollection ────────────────────────────────────
|
||||
|
||||
public function getChildren(): array {
|
||||
// Determine “folder key” relative to UPLOAD_DIR for ACL checks
|
||||
$folderKey = $this->folderKeyForPath($this->path);
|
||||
|
||||
// Check view permission on *this* directory
|
||||
$canFull = \ACL::canRead($this->user, $this->perms, $folderKey);
|
||||
$canOwn = \ACL::hasGrant($this->user, $folderKey, 'read_own');
|
||||
if (!$this->isAdmin && !$canFull && !$canOwn) {
|
||||
throw new Forbidden('No view access to this folder');
|
||||
}
|
||||
|
||||
$nodes = [];
|
||||
$hide = ['trash','profile_pics']; // internal dirs to hide
|
||||
foreach (new \DirectoryIterator($this->path) as $item) {
|
||||
if ($item->isDot()) continue;
|
||||
$name = $item->getFilename();
|
||||
if (in_array(strtolower($name), $hide, true)) continue;
|
||||
|
||||
$full = $item->getPathname();
|
||||
|
||||
if ($item->isDir()) {
|
||||
$nodes[] = new self($full, $this->user, $this->folderOnly);
|
||||
} else {
|
||||
$nodes[] = new FileRiseFile($full, $this->user);
|
||||
// Decide if the *child folder* should be visible
|
||||
$childKey = $this->folderKeyForPath($full);
|
||||
$canChild = $this->isAdmin
|
||||
|| \ACL::canRead($this->user, $this->perms, $childKey)
|
||||
|| \ACL::hasGrant($this->user, $childKey, 'read_own');
|
||||
|
||||
if ($canChild) {
|
||||
$nodes[] = new self($full, $this->user, $this->isAdmin, $this->perms);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// File in this directory: only list if full-view OR (own-only AND owner)
|
||||
if ($canFull || $this->fileIsOwnedByUser($folderKey, $name)) {
|
||||
$nodes[] = new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
|
||||
}
|
||||
}
|
||||
// Apply folder‑only at the top level
|
||||
if (
|
||||
$this->folderOnly
|
||||
&& realpath($this->path) === realpath(rtrim(UPLOAD_DIR,'/\\'))
|
||||
) {
|
||||
$nodes = array_filter($nodes, fn(INode $n)=> $n->getName() === $this->user);
|
||||
}
|
||||
|
||||
return array_values($nodes);
|
||||
}
|
||||
|
||||
public function childExists($name): bool {
|
||||
return file_exists($this->path . DIRECTORY_SEPARATOR . $name);
|
||||
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||
if (!file_exists($full)) return false;
|
||||
|
||||
$folderKey = $this->folderKeyForPath($this->path);
|
||||
$isDir = is_dir($full);
|
||||
|
||||
if ($isDir) {
|
||||
$childKey = $this->folderKeyForPath($full);
|
||||
return $this->isAdmin
|
||||
|| \ACL::canRead($this->user, $this->perms, $childKey)
|
||||
|| \ACL::hasGrant($this->user, $childKey, 'read_own');
|
||||
}
|
||||
|
||||
// file
|
||||
$canFull = $this->isAdmin || \ACL::canRead($this->user, $this->perms, $folderKey);
|
||||
if ($canFull) return true;
|
||||
|
||||
return \ACL::hasGrant($this->user, $folderKey, 'read_own')
|
||||
&& $this->fileIsOwnedByUser($folderKey, $name);
|
||||
}
|
||||
|
||||
public function getChild($name): INode {
|
||||
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||
if (!file_exists($full)) throw new NotFound("Not found: $name");
|
||||
return is_dir($full)
|
||||
? new self($full, $this->user, $this->folderOnly)
|
||||
: new FileRiseFile($full, $this->user);
|
||||
|
||||
$folderKey = $this->folderKeyForPath($this->path);
|
||||
if (is_dir($full)) {
|
||||
$childKey = $this->folderKeyForPath($full);
|
||||
$canDir = $this->isAdmin
|
||||
|| \ACL::canRead($this->user, $this->perms, $childKey)
|
||||
|| \ACL::hasGrant($this->user, $childKey, 'read_own');
|
||||
if (!$canDir) throw new Forbidden('No view access to requested folder');
|
||||
return new self($full, $this->user, $this->isAdmin, $this->perms);
|
||||
}
|
||||
|
||||
// file
|
||||
$canFull = $this->isAdmin || \ACL::canRead($this->user, $this->perms, $folderKey);
|
||||
if (!$canFull) {
|
||||
if (!\ACL::hasGrant($this->user, $folderKey, 'read_own') || !$this->fileIsOwnedByUser($folderKey, $name)) {
|
||||
throw new Forbidden('No view access to requested file');
|
||||
}
|
||||
}
|
||||
return new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
|
||||
}
|
||||
|
||||
public function createFile($name, $data = null): INode {
|
||||
$folderKey = $this->folderKeyForPath($this->path);
|
||||
|
||||
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
|
||||
throw new Forbidden('No write access to this folder');
|
||||
}
|
||||
if (!empty($this->perms['disableUpload']) && !$this->isAdmin) {
|
||||
throw new Forbidden('Uploads are disabled for your account');
|
||||
}
|
||||
|
||||
// Write directly to FS, then ensure metadata via FileRiseFile::put()
|
||||
$full = $this->path . DIRECTORY_SEPARATOR . $name;
|
||||
$content = is_resource($data) ? stream_get_contents($data) : (string)$data;
|
||||
|
||||
// Compute folder‑key relative to UPLOAD_DIR
|
||||
$rel = substr($full, strlen(rtrim(UPLOAD_DIR,'/\\'))+1);
|
||||
$parts = explode('/', str_replace('\\','/',$rel));
|
||||
$filename = array_pop($parts);
|
||||
$folder = empty($parts) ? 'root' : implode('/', $parts);
|
||||
// Let FileRiseFile handle metadata & overwrite semantics
|
||||
$fileNode = new FileRiseFile($full, $this->user, $this->isAdmin, $this->perms);
|
||||
$fileNode->put($content);
|
||||
|
||||
FileModel::saveFile($folder, $filename, $content, $this->user);
|
||||
return new FileRiseFile($full, $this->user);
|
||||
return $fileNode;
|
||||
}
|
||||
|
||||
public function createDirectory($name): INode {
|
||||
$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;
|
||||
$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));
|
||||
if ($parent === '.' || $parent === '/') $parent = '';
|
||||
FolderModel::createFolder($name, $parent, $this->user);
|
||||
return new self($full, $this->user, $this->folderOnly);
|
||||
\FolderModel::createFolder($name, $parent, $this->user);
|
||||
|
||||
return new self($full, $this->user, $this->isAdmin, $this->perms);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function folderKeyForPath(string $absPath): string {
|
||||
$base = rtrim(UPLOAD_DIR, '/\\');
|
||||
$realBase = realpath($base) ?: $base;
|
||||
$real = realpath($absPath) ?: $absPath;
|
||||
|
||||
if (stripos($real, $realBase) !== 0) return 'root';
|
||||
$rel = ltrim(str_replace('\\','/', substr($real, strlen($realBase))), '/');
|
||||
return ($rel === '' ? 'root' : $rel);
|
||||
}
|
||||
|
||||
private function relFromUploads(string $absPath): string {
|
||||
$base = rtrim(UPLOAD_DIR, '/\\');
|
||||
return ltrim(str_replace('\\','/', substr($absPath, strlen($base))), '/');
|
||||
}
|
||||
|
||||
private function loadMeta(string $folderKey): array {
|
||||
if (isset($this->metaCache[$folderKey])) return $this->metaCache[$folderKey];
|
||||
|
||||
$metaFile = META_DIR . (
|
||||
$folderKey === 'root'
|
||||
? 'root_metadata.json'
|
||||
: str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json'
|
||||
);
|
||||
|
||||
$data = [];
|
||||
if (is_file($metaFile)) {
|
||||
$decoded = json_decode(@file_get_contents($metaFile), true);
|
||||
if (is_array($decoded)) $data = $decoded;
|
||||
}
|
||||
return $this->metaCache[$folderKey] = $data;
|
||||
}
|
||||
|
||||
private function fileIsOwnedByUser(string $folderKey, string $fileName): bool {
|
||||
$meta = $this->loadMeta($folderKey);
|
||||
return isset($meta[$fileName]['uploader'])
|
||||
&& strcasecmp((string)$meta[$fileName]['uploader'], $this->user) === 0;
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,25 @@ namespace FileRise\WebDAV;
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../../src/lib/ACL.php';
|
||||
require_once __DIR__ . '/../../src/models/FileModel.php';
|
||||
require_once __DIR__ . '/CurrentUser.php';
|
||||
|
||||
use Sabre\DAV\IFile;
|
||||
use Sabre\DAV\INode;
|
||||
use Sabre\DAV\Exception\Forbidden;
|
||||
use FileModel;
|
||||
|
||||
class FileRiseFile implements IFile, INode {
|
||||
private string $path;
|
||||
private string $user;
|
||||
private bool $isAdmin;
|
||||
private array $perms;
|
||||
|
||||
public function __construct(string $path) {
|
||||
public function __construct(string $path, string $user, bool $isAdmin, array $perms) {
|
||||
$this->path = $path;
|
||||
$this->user = $user;
|
||||
$this->isAdmin = $isAdmin;
|
||||
$this->perms = $perms;
|
||||
}
|
||||
|
||||
// ── INode ───────────────────────────────────────────
|
||||
@@ -26,90 +33,134 @@ class FileRiseFile implements IFile, INode {
|
||||
}
|
||||
|
||||
public function getLastModified(): int {
|
||||
return filemtime($this->path);
|
||||
return @filemtime($this->path) ?: time();
|
||||
}
|
||||
|
||||
public function delete(): void {
|
||||
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||
$rel = substr($this->path, strlen($base));
|
||||
$parts = explode(DIRECTORY_SEPARATOR, $rel);
|
||||
$file = array_pop($parts);
|
||||
$folder = empty($parts) ? 'root' : $parts[0];
|
||||
FileModel::deleteFiles($folder, [$file]);
|
||||
[$folderKey, $fileName] = $this->split();
|
||||
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
|
||||
throw new Forbidden('No write access to delete this file');
|
||||
}
|
||||
if (!$this->canTouchOwnership($folderKey, $fileName)) {
|
||||
throw new Forbidden('You do not own this file');
|
||||
}
|
||||
\FileModel::deleteFiles($folderKey, [$fileName]);
|
||||
}
|
||||
|
||||
public function setName($newName): void {
|
||||
throw new Forbidden('Renaming files not supported');
|
||||
throw new Forbidden('Renaming files via WebDAV is not supported');
|
||||
}
|
||||
|
||||
// ── IFile ───────────────────────────────────────────
|
||||
|
||||
public function get() {
|
||||
[$folderKey, $fileName] = $this->split();
|
||||
$canFull = $this->isAdmin || \ACL::canRead($this->user, $this->perms, $folderKey);
|
||||
if (!$canFull) {
|
||||
// own-only?
|
||||
if (!\ACL::hasGrant($this->user, $folderKey, 'read_own') || !$this->isOwner($folderKey, $fileName)) {
|
||||
throw new Forbidden('No view access to this file');
|
||||
}
|
||||
}
|
||||
return fopen($this->path, 'rb');
|
||||
}
|
||||
|
||||
public function put($data): ?string {
|
||||
// 1) Save incoming data
|
||||
[$folderKey, $fileName] = $this->split();
|
||||
|
||||
if (!$this->isAdmin && !\ACL::canWrite($this->user, $this->perms, $folderKey)) {
|
||||
throw new Forbidden('No write access to this folder');
|
||||
}
|
||||
if (!empty($this->perms['disableUpload']) && !$this->isAdmin) {
|
||||
throw new Forbidden('Uploads are disabled for your account');
|
||||
}
|
||||
|
||||
// If overwriting existing file, enforce ownership for non-admin unless bypassOwnership
|
||||
$exists = is_file($this->path);
|
||||
$bypass = !empty($this->perms['bypassOwnership']);
|
||||
if ($exists && !$this->isAdmin && !$bypass && !$this->isOwner($folderKey, $fileName)) {
|
||||
throw new Forbidden('You do not own the target file');
|
||||
}
|
||||
|
||||
// Write data
|
||||
file_put_contents(
|
||||
$this->path,
|
||||
is_resource($data) ? stream_get_contents($data) : (string)$data
|
||||
);
|
||||
|
||||
// 2) Update metadata with CurrentUser
|
||||
$this->updateMetadata();
|
||||
// Update metadata (uploader on first write; modified every write)
|
||||
$this->updateMetadata($folderKey, $fileName);
|
||||
|
||||
// 3) Flush to client fast
|
||||
if (function_exists('fastcgi_finish_request')) {
|
||||
fastcgi_finish_request();
|
||||
}
|
||||
|
||||
return null; // no ETag
|
||||
}
|
||||
|
||||
public function getSize(): int {
|
||||
return filesize($this->path);
|
||||
return @filesize($this->path) ?: 0;
|
||||
}
|
||||
|
||||
public function getETag(): string {
|
||||
return '"' . md5($this->getLastModified() . $this->getSize()) . '"';
|
||||
return '"' . md5(($this->getLastModified() ?: 0) . ':' . ($this->getSize() ?: 0)) . '"';
|
||||
}
|
||||
|
||||
public function getContentType(): ?string {
|
||||
return mime_content_type($this->path) ?: null;
|
||||
return @mime_content_type($this->path) ?: null;
|
||||
}
|
||||
|
||||
// ── Metadata helper ───────────────────────────────────
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function updateMetadata(): void {
|
||||
private function split(): array {
|
||||
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
|
||||
$rel = substr($this->path, strlen($base));
|
||||
$parts = explode(DIRECTORY_SEPARATOR, $rel);
|
||||
$fileName = array_pop($parts);
|
||||
$folder = empty($parts) ? 'root' : $parts[0];
|
||||
$rel = ltrim(str_replace('\\','/', substr($this->path, strlen($base))), '/');
|
||||
$parts = explode('/', $rel);
|
||||
$file = array_pop($parts);
|
||||
$folder = empty($parts) ? 'root' : implode('/', $parts);
|
||||
return [$folder, $file];
|
||||
}
|
||||
|
||||
$metaFile = META_DIR
|
||||
. ($folder === 'root'
|
||||
private function metaFile(string $folderKey): string {
|
||||
return META_DIR . (
|
||||
$folderKey === 'root'
|
||||
? 'root_metadata.json'
|
||||
: str_replace(['/', '\\', ' '], '-', $folder) . '_metadata.json');
|
||||
|
||||
$metadata = [];
|
||||
if (file_exists($metaFile)) {
|
||||
$decoded = json_decode(file_get_contents($metaFile), true);
|
||||
if (is_array($decoded)) {
|
||||
$metadata = $decoded;
|
||||
}
|
||||
: str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json'
|
||||
);
|
||||
}
|
||||
|
||||
private function loadMeta(string $folderKey): array {
|
||||
$mf = $this->metaFile($folderKey);
|
||||
if (!is_file($mf)) return [];
|
||||
$d = json_decode(@file_get_contents($mf), true);
|
||||
return is_array($d) ? $d : [];
|
||||
}
|
||||
|
||||
private function saveMeta(string $folderKey, array $meta): void {
|
||||
@file_put_contents($this->metaFile($folderKey), json_encode($meta, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
private function isOwner(string $folderKey, string $fileName): bool {
|
||||
$meta = $this->loadMeta($folderKey);
|
||||
return isset($meta[$fileName]['uploader']) &&
|
||||
strcasecmp((string)$meta[$fileName]['uploader'], $this->user) === 0;
|
||||
}
|
||||
|
||||
private function canTouchOwnership(string $folderKey, string $fileName): bool {
|
||||
if ($this->isAdmin || !empty($this->perms['bypassOwnership'])) return true;
|
||||
return $this->isOwner($folderKey, $fileName);
|
||||
}
|
||||
|
||||
private function updateMetadata(string $folderKey, string $fileName): void {
|
||||
$meta = $this->loadMeta($folderKey);
|
||||
$now = date(DATE_TIME_FORMAT);
|
||||
$uploaded = $metadata[$fileName]['uploaded'] ?? $now;
|
||||
$uploader = CurrentUser::get();
|
||||
$uploaded = $meta[$fileName]['uploaded'] ?? $now;
|
||||
$uploader = CurrentUser::get() ?: $this->user;
|
||||
|
||||
$metadata[$fileName] = [
|
||||
$meta[$fileName] = [
|
||||
'uploaded' => $uploaded,
|
||||
'modified' => $now,
|
||||
'uploader' => $uploader,
|
||||
];
|
||||
|
||||
file_put_contents($metaFile, json_encode($metadata, JSON_PRETTY_PRINT));
|
||||
$this->saveMeta($folderKey, $meta);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user