Compare commits

...

16 Commits

Author SHA1 Message Date
Ryan
567d2f62e8 chore(doc) readme updated to remove duplicated onlyoffice info 2025-11-09 20:19:30 -05:00
Ryan
9be53ba033 chore(scripts): fix shellcheck SC2148 and harden manual-sync.sh 2025-11-09 20:01:21 -05:00
github-actions[bot]
de925e6fc2 chore(release): set APP_VERSION to v1.9.1 [skip ci] 2025-11-10 00:55:18 +00:00
Ryan
bd7ff4d9cd release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script 2025-11-09 19:55:07 -05:00
Ryan
6727cc66ac docs(assets): refresh screenshots to showcase new Folder Manager 2025-11-09 02:48:26 -05:00
Ryan
f3269877c7 Update image link in README.md 2025-11-09 02:41:38 -05:00
github-actions[bot]
5ffe9b3ffc chore(release): set APP_VERSION to v1.9.0 [skip ci] 2025-11-09 06:45:49 +00:00
Ryan
abd3dad5a5 release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening 2025-11-09 01:45:39 -05:00
github-actions[bot]
4c849b1dc3 chore(release): set APP_VERSION to v1.8.13 [skip ci] 2025-11-09 00:30:43 +00:00
Ryan
7cc314179f release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync 2025-11-08 19:30:33 -05:00
github-actions[bot]
9ddb633cca chore(release): set APP_VERSION to v1.8.12 [skip ci] 2025-11-08 21:05:31 +00:00
Ryan
448e246689 release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons 2025-11-08 16:05:20 -05:00
Ryan
dc7797e50d chore(doc): readme spacing issue fixed 2025-11-08 14:57:54 -05:00
Ryan
913d370ef2 Update README with new gif and remove dark mode image 2025-11-08 14:36:51 -05:00
github-actions[bot]
488b5cb532 chore(release): set APP_VERSION to v1.8.11 [skip ci] 2025-11-08 19:12:57 +00:00
Ryan
15b5aa6d8d release(v1.8.11): doc updated 2025-11-08 14:12:48 -05:00
28 changed files with 3115 additions and 1875 deletions

View File

@@ -1,8 +1,125 @@
# Changelog
## Changes 11/9/2025 (v1.9.1)
release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script
### Highlights v1.9.1
- 🎨 Per-folder colors with live SVG preview and consistent styling in light/dark modes.
- 📄 Folder icons auto-refresh when contents change (no full page reload).
- 🧭 Drag-and-drop breadcrumb fallback for folder→folder moves.
- 🛠️ Safer upgrade helper script to rsync app files without touching data.
- feat(colors): add per-folder color customization
- New endpoints: GET /api/folder/getFolderColors.php and POST /api/folder/saveFolderColor.php
- AuthZ: reuse canRename for “customize folder”, validate hex, and write atomically to metadata/folder_colors.json.
- Read endpoint filters map by ACL::canRead before returning to the user.
- Frontend: load/apply colors to tree rows; persist on move/rename; API helpers saveFolderColor/getFolderColors.
- feat(ui): color-picker modal with live SVG folder preview
- Shows preview that updates as you pick; supports Save/Reset; protects against accidental toggle clicks.
- feat(controls): “Color folder” button in Folder Management card
- New `.btn-color-folder` with accent palette (#008CB4), hover/active/focus states, dark-mode tuning; event wiring gated by caps.
- i18n: add strings for color UI (color_folder, choose_color, reset_default, save_color, folder_color_saved, folder_color_cleared).
- ux(tree): make expansion state more predictable across refreshes
- `expandTreePath(path, {force,persist,includeLeaf})` with persistence; keep ancestors expanded; add click-suppression guard.
- ux(layout): center the folder-actions toolbar; remove left padding hacks; normalize icon sizing.
- chore(ops): add scripts/manual-sync.sh (safe rsync update path, preserves data dirs and public/.htaccess).
---
## Changes 11/9/2025 (v1.9.0)
release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening
feat(ui): modern folder tree
- New crisp folder SVG with clear paper insert; unified yellow/orange palette for light & dark
- Proper ARIA tree semantics (role=treeitem, aria-expanded), cleaner chevrons, better alignment
- Breadcrumb tweaks ( separators), hover/selected polish
- Prime icons locally, then confirm via counts for accurate “empty vs non-empty”
feat(api): add /api/folder/isEmpty.php via controller/model
- public/api/folder/isEmpty.php delegates to FolderController::stats()
- FolderModel::countVisible() enforces ACL, path safety, and short-circuits after first entry
- Releases PHP session lock early to avoid parallel-request pileups
perf: cap concurrent “isEmpty” requests + timeouts
- Small concurrency limiter + fetch timeouts
- In-memory result & inflight caches for fewer network hits
fix(state): preserve user expand/collapse choices
- Respect saved folderTreeState; dont auto-expand unopened nodes
- Only show ancestors for visibility when navigating (no unwanted persists)
security: tighten .htaccess while enabling WebDAV
- Deny direct PHP except /api/*.php, /api.php, and /webdav.php
- AcceptPathInfo On; keep path-aware dotfile denial
refactor: move count logic to model; thin controller action
chore(css): add unified “folder tree” block with variables (sizes, gaps, colors)
Files touched: FolderModel.php, FolderController.php, public/js/folderManager.js, public/css/styles.css, public/api/folder/isEmpty.php (new), public/.htaccess
---
## Changes 11/8/2025 (v1.8.13)
release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync
- dnd: fix disappearing/overlapping cards when moving between sidebar/top; return to origin on failed drop
- layout: placeCardInZone now live-updates top layout, sidebar visibility, and toggle icon
- toggle/collapse: move ALL cards to header on collapse, restore saved layout on expand; keep icon state synced; add body.sidebar-hidden for proper file list expansion; emit `zones:collapsed-changed`
- header dock: show dock whenever icons exist (and on collapse); hide when empty
- responsive: enforceResponsiveZones also updates toggle icon; stash/restore behavior unchanged
- sidebar: hard-lock width to 350px (CSS) and remove runtime 280px minWidth; add placeholder when empty to make dropping back easy
- CSS: right-align header dock buttons, centered “Drop Zone” label, sensible min-height; dark-mode safe
- refactor: small renames/ordering; remove redundant z-index on toggle; minor formatting
---
## Changes 11/8/2025 (v1.8.12)
release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons
- auth (public/js/main.js)
- Robust login options: tolerate key variants (disableFormLogin/disable_form_login, etc.).
- Correctly show/hide wrapper + individual methods (form/OIDC/basic).
- Auto-SSO when OIDC is the only enabled method; add opt-out with `?noauto=1`.
- Minor cleanup (SW register catch spacing).
- drag & drop (public/js/dragAndDrop.js)
- Reworked zones model: Sidebar / Top (left/right) / Header (icon+modal).
- Persist user layout with `userZonesSnapshot.v2` and responsive stash for small screens.
- Live UI sync: toggle icon (`material-icons`) updates immediately after moves.
- Smarter small-screen behavior: lift sidebar cards ephemerally; restore only what belonged to sidebar.
- Cleaner header icon modal plumbing; remove legacy/dead code.
- styles (public/css/styles.css)
- Header drop zone fills remaining space and right-aligns its icons.
UX:
- OIDC button reliably appears when form/basic are disabled.
- If OIDC is the sole method, users are taken straight to the provider (unless `?noauto=1`).
- Header icons sit with the other header actions (right-aligned), and the toggle icon reflects layout changes instantly.
---
## Changes 11/8/2025 (v1.8.11)
release (v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client
release(v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client
- Force PKCE via setCodeChallengeMethod('S256') so Authelias public-client policy is satisfied.
- Convert empty OIDC client secret to null to correctly signal a public client.

View File

@@ -29,8 +29,7 @@ New: Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **Pow
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
**Dark mode:**
![Dark Header](https://raw.githubusercontent.com/error311/FileRise/refs/heads/master/resources/dark-header.png)
![filerise-v1 9 0](https://github.com/user-attachments/assets/a346dd8a-eef1-4180-8140-4c1c08e6026e)
---
@@ -446,18 +445,11 @@ Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
### ONLYOFFICE integration
FileRise can open office documents using a self-hosted ONLYOFFICE Document Server.
- **We do not bundle ONLYOFFICE.** Admins point FileRise to an existing ONLYOFFICE Docs server and (optionally) set a JWT secret in **Admin > ONLYOFFICE**.
- **Licensing:** ONLYOFFICE Document Server (Community Edition) is released under the GNU AGPL v3. Enterprise editions are commercially licensed. When you deploy ONLYOFFICE, you are responsible for complying with the license of the edition you use.
Project page & license: <https://github.com/ONLYOFFICE/DocumentServer> (AGPL-3.0)
- **FileRise license unaffected:** FileRise communicates with ONLYOFFICE over standard HTTP and loads `api.js` from the configured Document Server at runtime; FileRise does not redistribute ONLYOFFICE code.
- **Trademarks:** ONLYOFFICE is a trademark of Ascensio System SIA. FileRise is not affiliated with or endorsed by ONLYOFFICE.
#### Security / CSP
If you enable ONLYOFFICE, allow its origin in your CSP (`script-src`, `frame-src`, `connect-src`). The Admin panel shows a ready-to-copy line for Apache/Nginx.
### PHP Libraries
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)

View File

@@ -4,6 +4,9 @@
Options -Indexes -Multiviews
DirectoryIndex index.html
# Allow PATH_INFO for routes like /webdav.php/foo/bar
AcceptPathInfo On
# ---------------- Security: dotfiles ----------------
<IfModule mod_authz_core.c>
# Block direct access to dotfiles like .env, .gitignore, etc.
@@ -24,10 +27,14 @@ RewriteRule - - [L]
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
RewriteRule "(^|/)\.(?!well-known/)" - [F]
# 2) Deny direct access to PHP outside /api/
# This stops scanners from hitting /index.php, /admin.php, /wso.php, etc.
RewriteCond %{REQUEST_URI} !^/api/
RewriteRule \.php$ - [F]
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
# - allow /api/*.php (API endpoints)
# - allow /api.php (ReDoc/spec page)
# - allow /webdav.php (SabreDAV front)
RewriteCond %{REQUEST_URI} !^/api/ [NC]
RewriteCond %{REQUEST_URI} !^/api\.php$ [NC]
RewriteCond %{REQUEST_URI} !^/webdav\.php$ [NC]
RewriteRule \.php$ - [F,L]
# 3) Never redirect local/dev hosts
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
try {
$ctl = new FolderController();
$ctl->getFolderColors(); // echoes JSON + status codes
} catch (Throwable $e) {
error_log('getFolderColors failed: ' . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Internal server error']);
}

View File

@@ -0,0 +1,30 @@
<?php
// public/api/folder/isEmpty.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
// Snapshot then release session lock so parallel requests dont block
$user = (string)($_SESSION['username'] ?? '');
$perms = [
'role' => $_SESSION['role'] ?? null,
'admin' => $_SESSION['admin'] ?? null,
'isAdmin' => $_SESSION['isAdmin'] ?? null,
];
@session_write_close();
// Input
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || $folder === 'root') ? 'root' : trim($folder, '/');
// Delegate to controller (model handles ACL + path safety)
$result = FolderController::stats($folder, $user, $perms);
// Always return a compact JSON object like before
echo json_encode([
'folders' => (int)($result['folders'] ?? 0),
'files' => (int)($result['files'] ?? 0),
]);

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
try {
$ctl = new FolderController();
$ctl->saveFolderColor(); // validates method + CSRF, does ACL, echoes JSON
} catch (Throwable $e) {
error_log('saveFolderColor failed: ' . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Internal server error']);
}

View File

@@ -62,6 +62,51 @@ body {
@media (max-width: 600px) {
.zones-toggle { left: 85px !important; }
}
/* Optional tokens */
:root{
--filr-accent-500:#008CB4; /* base */
--filr-accent-600:#00789A; /* hover */
--filr-accent-700:#006882; /* active/border */
--filr-accent-ring:rgba(0,140,180,.4);
}
/* Button */
.btn-color-folder{
display:inline-flex; align-items:center; gap:6px;
background:var(--filr-accent-500);
border:1px solid var(--filr-accent-700);
color:#fff; /* ensure white text */
}
.btn-color-folder .material-icons{
color:currentColor; /* makes icon white too */
}
.btn-color-folder:hover,
.btn-color-folder:focus-visible{
background:var(--filr-accent-600);
border-color:var(--filr-accent-700);
}
.btn-color-folder:active{
background:var(--filr-accent-700);
}
.btn-color-folder:focus-visible{
outline:2px solid var(--filr-accent-ring);
outline-offset:2px;
}
/* Dark mode: start slightly deeper so it doesn't glow */
.dark-mode .btn-color-folder{
background:var(--filr-accent-600);
border-color:var(--filr-accent-700);
color:#fff;
}
.dark-mode .btn-color-folder:hover,
.dark-mode .btn-color-folder:focus-visible{
background:var(--filr-accent-700);
}
/* ===========================================================
HEADER & NAVIGATION
=========================================================== */
@@ -141,7 +186,15 @@ body {
}#userDropdownToggle {
border-radius: 4px !important;
padding: 6px 10px !important;
}.header-buttons button:hover {
}
#headerDropArea.header-drop-zone{
display: flex;
justify-content: flex-end; /* buttons to the right */
align-items: center;
min-height: 40px; /* so the label has room */
}
.header-buttons button:hover {
background-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
color: #fff;
@@ -793,14 +846,17 @@ body {
}
#uploadForm {
display: none;
}.folder-actions {
display: flex;
flex-wrap: nowrap;
padding-left: 8px;
}
.folder-actions {
display: flex;
justify-content: center;
align-items: center;
white-space: nowrap;
padding-top: 10px;
}@media (min-width: 600px) and (max-width: 992px) {
gap: 2px;
flex-wrap: wrap;
white-space: normal;
margin: 0; /* no hacks needed */
}
@media (min-width: 600px) and (max-width: 992px) {
.folder-actions {
white-space: nowrap;
}}
@@ -813,10 +869,8 @@ body {
}.folder-actions .material-icons {
font-size: 24px;
vertical-align: -2px;
}.folder-actions .btn + .btn {
margin-left: 6px;
}.folder-actions .btn {
padding: 10px 12px;
font-size: 0.85rem;
line-height: 1.1;
border-radius: 6px;
@@ -826,7 +880,7 @@ body {
transition: transform 120ms ease, box-shadow 120ms ease;
will-change: transform;
}.folder-actions .material-icons {
font-size: 24px;
font-size: 20px;
vertical-align: -2px;
transition: transform 120ms ease;
}.folder-actions .btn:hover,
@@ -1124,7 +1178,7 @@ body {
border-radius: 4px;
}.folder-tree {
list-style-type: none;
padding-left: 10px;
padding-left: 5px;
margin: 0;
}.folder-tree.collapsed {
display: none;
@@ -1141,9 +1195,10 @@ body {
text-align: right;
}.folder-indent-placeholder {
display: inline-block;
width: 30px;
width: 5px;
}#folderTreeContainer {
display: block;
margin-left: 10px;
}.folder-option {
cursor: pointer;
}.folder-option:hover {
@@ -1524,7 +1579,16 @@ body {
.drag-header.active {
width: 350px;
height: 750px;
}.main-column {
}
/* Fixed-width sidebar (always 350px) */
#sidebarDropArea{
width: 350px;
min-width: 350px;
max-width: 350px;
flex: 0 0 350px;
box-sizing: border-box;
}
.main-column {
flex: 1;
transition: margin-left 0.3s ease;
}#uploadFolderRow {
@@ -1592,8 +1656,8 @@ body {
}#sidebarDropArea,
#uploadFolderRow {
background-color: transparent;
}.dark-mode #sidebarDropArea,
}
.dark-mode #sidebarDropArea,
.dark-mode #uploadFolderRow {
background-color: transparent;
}.dark-mode #sidebarDropArea.highlight,
@@ -1607,8 +1671,6 @@ body {
border: none !important;
}.dragging:focus {
outline: none;
}#sidebarDropArea > .card {
margin-bottom: 1rem;
}.card {
background-color: #fff;
color: #000;
@@ -1626,6 +1688,7 @@ body {
}.custom-folder-card-body {
padding-top: 5px !important;
padding-right: 0 !important;
padding-left: 0 !important;
}#addUserModal,
#removeUserModal {
z-index: 5000 !important;
@@ -1705,8 +1768,9 @@ body {
border: 2px dashed #555;
color: #fff;
}.header-drop-zone.drag-active:empty::before {
content: "Drop";
content: "Drop Zone";
font-size: 10px;
padding-right: 6px;
color: #aaa;
}/* Disable text selection on rows to prevent accidental copying when shift-clicking */
#fileList tbody tr.clickable-row {
@@ -1939,4 +2003,172 @@ body {
text-overflow: ellipsis;
}
#downloadProgressBarOuter { height: 10px; }
#downloadProgressBarOuter { height: 10px; }
/* ===== FileRise Folder Tree: unified, crisp, aligned ===== */
/* Knobs (size, spacing, colors) */
#folderTreeContainer {
/* Colors (used in BOTH themes) */
--filr-folder-front: #f6b84e; /* front/lip */
--filr-folder-back: #ffd36e; /* back body */
--filr-folder-stroke:#a87312; /* outline */
--filr-paper-fill: #ffffff; /* paper */
--filr-paper-stroke: #b2c2db; /* paper edges/lines */
/* Size & spacing */
--row-h: 28px; /* row height */
--twisty: 24px; /* chevron hit-area size */
--twisty-gap: -5px; /* gap between chevron and row content */
--icon-size: 24px; /* 2226 look good */
--icon-gap: 6px; /* space between icon and label */
--indent: 10px; /* subtree indent */
}
/* Keep the same yellow/orange in dark mode; boost paper contrast a touch */
.dark-mode #folderTreeContainer {
--filr-folder-front: #f6b84e;
--filr-folder-back: #ffd36e;
--filr-folder-stroke:#a87312;
--filr-paper-fill: #ffffff;
--filr-paper-stroke: #d0def7; /* brighter so it pops on dark */
}
#folderTreeContainer .folder-item { position: static; padding-left: 0; }
/* visible “row” for each node */
#folderTreeContainer .folder-row {
position: relative;
display: flex;
align-items: center;
height: var(--row-h);
padding-left: calc(var(--twisty) + var(--twisty-gap));
}
/* children indent */
#folderTreeContainer .folder-item > .folder-tree { margin-left: var(--indent); }
/* ---------- Chevron toggle (twisty) ---------- */
#folderTreeContainer .folder-row > button.folder-toggle {
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: var(--twisty); height: var(--twisty);
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid transparent; border-radius: 6px;
background: transparent; cursor: pointer;
}
#folderTreeContainer .folder-row > button.folder-toggle::before {
content: "▸"; /* closed */
font-size: calc(var(--twisty) * 0.8);
line-height: 1;
}
#folderTreeContainer li[role="treeitem"][aria-expanded="true"]
> .folder-row > button.folder-toggle::before { content: "▾"; }
/* root row (it's a <div>) */
#rootRow[aria-expanded="true"] > button.folder-toggle::before { content: "▾"; }
#folderTreeContainer .folder-row > button.folder-toggle:hover {
border-color: color-mix(in srgb, #7ab3ff 35%, transparent);
}
/* spacer for leaves so labels align with parents that have a button */
#folderTreeContainer .folder-row > .folder-spacer {
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: var(--twisty); height: var(--twisty); display: inline-block;
}
#folderTreeContainer .folder-option {
display: inline-flex;
align-items: center;
height: var(--row-h);
line-height: 1.2; /* avoids baseline weirdness */
padding: 0 8px;
border-radius: 8px;
user-select: none;
white-space: nowrap;
max-width: 100%;
gap: var(--icon-gap);
}
#folderTreeContainer .folder-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transform: translateY(0.5px); /* tiny optical nudge for text */
}
/* ---------- Icon box (size & alignment) ---------- */
#folderTreeContainer .folder-icon {
flex: 0 0 var(--icon-size);
width: var(--icon-size);
height: var(--icon-size);
display: inline-flex;
align-items: center;
justify-content: center;
transform: translateY(0.5px); /* tiny optical nudge for SVG */
}
#folderTreeContainer .folder-icon svg {
width: 100%;
height: 100%;
display: block;
shape-rendering: geometricPrecision;
}
/* ---------- Crisp colors & strokes for the SVG parts ---------- */
#folderTreeContainer .folder-icon .paper {
fill: var(--filr-paper-fill);
stroke: var(--filr-paper-stroke);
stroke-width: 1.5; /* thick so it reads at 24px */
paint-order: stroke fill;
}
#folderTreeContainer .folder-icon .paper-fold {
fill: var(--filr-paper-stroke);
}
#folderTreeContainer .folder-icon .paper-line {
stroke: var(--filr-paper-stroke);
stroke-width: 1.5;
stroke-linecap: round;
fill: none;
opacity: 0.95;
}
/* subtle highlight along lip to add depth */
#folderTreeContainer .folder-icon .lip-highlight {
stroke: #ffffff;
stroke-opacity: .35;
stroke-width: 0.9;
fill: none;
vector-effect: non-scaling-stroke;
}
/* ---------- Hover / Selected ---------- */
#folderTreeContainer .folder-option:hover {
background: rgba(122,179,255,.14);
}
#folderTreeContainer .folder-option.selected {
background: rgba(122,179,255,.24);
box-shadow: inset 0 0 0 1px rgba(122,179,255,.45);
}
/* variables will be set inline per .folder-option when user colors a folder */
#folderTreeContainer .folder-icon .folder-front,
#folderTreeContainer .folder-icon .folder-back {
fill: currentColor;
stroke: var(--filr-folder-stroke);
stroke-width: 1.1;
vector-effect: non-scaling-stroke;
paint-order: stroke fill;
}
#folderTreeContainer .folder-icon .folder-front { color: var(--filr-folder-front); }
#folderTreeContainer .folder-icon .folder-back { color: var(--filr-folder-back); }

View File

@@ -252,6 +252,9 @@
</div>
</div>
</div>
<button id="colorFolderBtn" class="btn btn-color-folder ml-2" data-i18n-title="color_folder" title="Color folder">
<i class="material-icons">palette</i>
</button>
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
<i class="material-icons">share</i>

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
export function handleDeleteSelected(e) {
@@ -47,6 +48,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) {
showToast("Selected files deleted successfully!");
loadFileList(window.currentFolder);
refreshFolderIcon(window.currentFolder);
} else {
showToast("Error: " + (data.error || "Could not delete files"));
}
@@ -129,6 +131,7 @@ export async function handleCreateFile(e) {
if (!js.success) throw new Error(js.error);
showToast(t('file_created'));
loadFileList(folder);
refreshFolderIcon(folder);
} catch (err) {
showToast(err.message || t('error_creating_file'));
} finally {
@@ -300,6 +303,7 @@ document.addEventListener("DOMContentLoaded", () => {
}
showToast(t('file_created_successfully'));
loadFileList(window.currentFolder);
refreshFolderIcon(folder);
} catch (err) {
console.error(err);
showToast(err.message || t('error_creating_file'));
@@ -633,6 +637,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) {
showToast("Selected files copied successfully!", 5000);
loadFileList(window.currentFolder);
refreshFolderIcon(targetFolder);
} else {
showToast("Error: " + (data.error || "Could not copy files"), 5000);
}
@@ -685,6 +690,8 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) {
showToast("Selected files moved successfully!");
loadFileList(window.currentFolder);
refreshFolderIcon(targetFolder);
refreshFolderIcon(window.currentFolder);
} else {
showToast("Error: " + (data.error || "Could not move files"));
}

File diff suppressed because it is too large Load Diff

View File

@@ -312,7 +312,13 @@ const translations = {
"previous": "Previous",
"next": "Next",
"watched": "Watched",
"reset_progress": "Reset Progress"
"reset_progress": "Reset Progress",
"color_folder": "Color folder",
"choose_color": "Choose a color",
"reset_default": "Reset",
"save_color": "Save",
"folder_color_saved": "Folder color saved.",
"folder_color_cleared": "Folder color reset."
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -403,39 +403,57 @@ function bindDarkMode() {
function applySiteConfig(cfg, { phase = 'final' } = {}) {
try {
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
// Always keep <title> correct early (no visual flicker)
document.title = title;
// --- Login options (apply in BOTH phases so login page is correct) ---
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
const disableForm = !!lo.disableFormLogin;
const disableOIDC = !!lo.disableOIDCLogin;
const disableBasic = !!lo.disableBasicAuth;
const row = $('#loginForm');
if (row) {
if (disableForm) {
row.setAttribute('hidden', '');
row.style.display = ''; // don't leave display:none lying around
// be tolerant to key variants just in case
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
const showForm = !disableForm;
const showOIDC = !disableOIDC;
const showBasic = !disableBasic;
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
const authForm = $('#authForm'); // inner username/password form
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
if (loginWrap) {
const anyMethod = showForm || showOIDC || showBasic;
if (anyMethod) {
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
loginWrap.style.display = ''; // let CSS decide
} else {
row.removeAttribute('hidden');
row.style.display = '';
loginWrap.setAttribute('hidden', '');
loginWrap.style.display = '';
}
}
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
// 2) Toggle the pieces inside the wrapper
if (authForm) authForm.style.display = showForm ? '' : 'none';
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
if (basic) basic.style.display = disableBasic ? 'none' : '';
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
if (phase === 'final') {
const h1 = document.querySelector('.header-title h1');
if (h1) {
// prevent i18n or legacy from overwriting it
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
if (h1.textContent !== title) h1.textContent = title;
// lock it so late code can't stomp it
if (!h1.__titleLock) {
const mo = new MutationObserver(() => {
@@ -1037,6 +1055,21 @@ function bindDarkMode() {
if (login) login.style.display = '';
// …wire stuff…
applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' });
// Auto-SSO if OIDC is the only enabled method (add ?noauto=1 to skip)
(() => {
const lo = (window.__FR_SITE_CFG__ && window.__FR_SITE_CFG__.loginOptions) || {};
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
const onlyOIDC = disableForm && disableBasic && !disableOIDC;
const qp = new URLSearchParams(location.search);
if (onlyOIDC && qp.get('noauto') !== '1') {
const btn = document.getElementById('oidcLoginBtn');
if (btn) setTimeout(() => btn.click(), 250);
}
})();
await revealAppAndHideOverlay();
const hb = document.querySelector('.header-buttons');
if (hb) hb.style.visibility = 'hidden';
@@ -1102,7 +1135,7 @@ function bindDarkMode() {
const onHttps = location.protocol === 'https:' || location.hostname === 'localhost';
if ('serviceWorker' in navigator && onHttps && !hasCapBridge && !isCapUA) {
window.addEventListener('load', () => {
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => {});
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => { });
});
}
})();

View File

@@ -2,7 +2,7 @@
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { loadFolderTree, refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
function showConfirm(message, onConfirm) {
@@ -89,6 +89,7 @@ export function setupTrashRestoreDelete() {
toggleVisibility("restoreFilesModal", false);
loadFileList(window.currentFolder);
loadFolderTree(window.currentFolder);
refreshFolderIcon(window.currentFolder);
})
.catch(err => {
console.error("Error restoring files:", err);

View File

@@ -3,6 +3,7 @@ import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
/* -----------------------------------------------------
@@ -588,7 +589,7 @@ async function initResumableUpload() {
if (removeBtn) removeBtn.style.display = "none";
setTimeout(() => li.remove(), 5000);
}
refreshFolderIcon(window.currentFolder);
loadFileList(window.currentFolder);
});

View File

@@ -1,2 +1,2 @@
// generated by CI
window.APP_VERSION = 'v1.8.10';
window.APP_VERSION = 'v1.9.1';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 KiB

After

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 788 KiB

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

After

Width:  |  Height:  |  Size: 541 KiB

54
scripts/manual-sync.sh Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# === Update FileRise to v1.9.1 (safe rsync) ===
# shellcheck disable=SC2155 # we intentionally assign 'stamp' with command substitution
set -Eeuo pipefail
VER="v1.9.1"
ASSET="FileRise-${VER}.zip" # If the asset name is different, set it exactly (e.g. FileRise-v1.9.0.zip)
WEBROOT="/var/www"
TMP="/tmp/filerise-update"
# 0) (optional) quick backup of critical bits
stamp="$(date +%F-%H%M)"
mkdir -p /root/backups
tar -C "$WEBROOT" -czf "/root/backups/filerise-$stamp.tgz" \
public/.htaccess config users uploads metadata || true
echo "Backup saved to /root/backups/filerise-$stamp.tgz"
# 1) Fetch the release zip
rm -rf "$TMP"
mkdir -p "$TMP"
curl -fsSL "https://github.com/error311/FileRise/releases/download/${VER}/${ASSET}" -o "$TMP/$ASSET"
# 2) Unzip to a staging dir
unzip -q "$TMP/$ASSET" -d "$TMP"
STAGE_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'FileRise*' ! -path "$TMP" | head -n1 || true)"
[ -n "${STAGE_DIR:-}" ] || STAGE_DIR="$TMP"
# 3) Sync code into /var/www
# - keep public/.htaccess
# - keep data dirs and current config.php
rsync -a --delete \
--exclude='public/.htaccess' \
--exclude='uploads/***' \
--exclude='users/***' \
--exclude='metadata/***' \
--exclude='config/config.php' \
--exclude='.github/***' \
--exclude='docker-compose.yml' \
"$STAGE_DIR"/ "$WEBROOT"/
# 4) Ownership (Ubuntu/Debian w/ Apache)
chown -R www-data:www-data "$WEBROOT"
# 5) (optional) Composer autoload optimization if composer is available
if command -v composer >/dev/null 2>&1; then
cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
composer install --no-dev --optimize-autoloader
fi
# 6) Reload Apache (dont fail the whole script if reload isnt available)
systemctl reload apache2 2>/dev/null || true
echo "✅ FileRise updated to ${VER} (code). Data and public/.htaccess preserved."

File diff suppressed because it is too large Load Diff

View File

@@ -10,23 +10,38 @@ class ACL
private static $path = null;
private const BUCKETS = [
'owners','read','write','share','read_own',
'create','upload','edit','rename','copy','move','delete','extract',
'share_file','share_folder'
'owners',
'read',
'write',
'share',
'read_own',
'create',
'upload',
'edit',
'rename',
'copy',
'move',
'delete',
'extract',
'share_file',
'share_folder'
];
private static function path(): string {
private static function path(): string
{
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
return self::$path;
}
public static function normalizeFolder(string $f): string {
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 {
public static function purgeUser(string $user): bool
{
$user = (string)$user;
$acl = self::$cache ?? self::loadFresh();
$changed = false;
@@ -41,49 +56,107 @@ class ACL
return $changed ? self::save($acl) : true;
}
public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
if (self::hasGrant($user, $folder, 'owners')) return true;
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
if (self::hasGrant($user, $folder, 'owners')) return true;
$folder = trim($folder, "/\\ ");
if ($folder === '' || $folder === 'root') return false;
$folder = trim($folder, "/\\ ");
if ($folder === '' || $folder === 'root') return false;
$parts = explode('/', $folder);
while (count($parts) > 1) {
array_pop($parts);
$parent = implode('/', $parts);
if (self::hasGrant($user, $parent, 'owners')) return true;
$parts = explode('/', $folder);
while (count($parts) > 1) {
array_pop($parts);
$parent = implode('/', $parts);
if (self::hasGrant($user, $parent, 'owners')) return true;
}
return false;
}
public static function migrateSubtree(string $source, string $target): array
{
// PHP <8 polyfill
if (!function_exists('str_starts_with')) {
function str_starts_with(string $h, string $n): bool
{
return $n === '' || strncmp($h, $n, strlen($n)) === 0;
}
}
$src = self::normalizeFolder($source);
$dst = self::normalizeFolder($target);
if ($src === 'root') return ['changed' => false, 'moved' => 0];
$file = self::path(); // e.g. META_DIR/folder_acl.json
$raw = @file_get_contents($file);
$map = is_string($raw) ? json_decode($raw, true) : [];
if (!is_array($map)) $map = [];
$prefix = $src;
$needle = $src . '/';
$new = $map;
$changed = false;
$moved = 0;
foreach ($map as $key => $entry) {
$isMatch = ($key === $prefix) || str_starts_with($key . '/', $needle);
if (!$isMatch) continue;
unset($new[$key]);
$suffix = substr($key, strlen($prefix)); // '' or '/sub/...'
$newKey = ($dst === 'root') ? ltrim($suffix, '/\\') : rtrim($dst, '/\\') . $suffix;
// keep only known buckets (defensive)
if (is_array($entry)) {
$clean = [];
foreach (self::BUCKETS as $b) if (array_key_exists($b, $entry)) $clean[$b] = $entry[$b];
$entry = $clean ?: $entry;
}
// overwrite any existing entry at destination path (safer than union)
$new[$newKey] = $entry;
$changed = true;
$moved++;
}
if ($changed) {
@file_put_contents($file, json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
@chmod($file, 0664);
self::$cache = $new; // keep in-process cache fresh if you use it
}
return ['changed' => $changed, 'moved' => $moved];
}
return false;
}
/** Re-key explicit ACL entries for an entire subtree: old/... → new/... */
public static function renameTree(string $oldFolder, string $newFolder): void
{
$old = self::normalizeFolder($oldFolder);
$new = self::normalizeFolder($newFolder);
if ($old === '' || $old === 'root') return; // nothing to re-key for root
public static function renameTree(string $oldFolder, string $newFolder): void
{
$old = self::normalizeFolder($oldFolder);
$new = self::normalizeFolder($newFolder);
if ($old === '' || $old === 'root') return; // nothing to re-key for root
$acl = self::$cache ?? self::loadFresh();
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
$acl = self::$cache ?? self::loadFresh();
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
$rebased = [];
foreach ($acl['folders'] as $k => $rec) {
if ($k === $old || strpos($k, $old . '/') === 0) {
$suffix = substr($k, strlen($old));
$suffix = ltrim((string)$suffix, '/');
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
$rebased[$newKey] = $rec;
} else {
$rebased[$k] = $rec;
$rebased = [];
foreach ($acl['folders'] as $k => $rec) {
if ($k === $old || strpos($k, $old . '/') === 0) {
$suffix = substr($k, strlen($old));
$suffix = ltrim((string)$suffix, '/');
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
$rebased[$newKey] = $rec;
} else {
$rebased[$k] = $rec;
}
}
$acl['folders'] = $rebased;
self::save($acl);
}
$acl['folders'] = $rebased;
self::save($acl);
}
private static function loadFresh(): array {
private static function loadFresh(): array
{
$path = self::path();
if (!is_file($path)) {
@mkdir(dirname($path), 0755, true);
@@ -94,7 +167,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
'read' => ['admin'],
'write' => ['admin'],
'share' => ['admin'],
'read_own'=> [],
'read_own' => [],
'create' => [],
'upload' => [],
'edit' => [],
@@ -130,12 +203,21 @@ public static function renameTree(string $oldFolder, string $newFolder): void
$healed = false;
foreach ($data['folders'] as $folder => &$rec) {
if (!is_array($rec)) { $rec = []; $healed = true; }
if (!is_array($rec)) {
$rec = [];
$healed = true;
}
foreach (self::BUCKETS as $k) {
$v = $rec[$k] ?? [];
if (!is_array($v)) { $v = []; $healed = true; }
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; }
if (($rec[$k] ?? null) !== $v) {
$rec[$k] = $v;
$healed = true;
}
}
}
unset($rec);
@@ -145,19 +227,22 @@ public static function renameTree(string $oldFolder, string $newFolder): void
return $data;
}
private static function save(array $acl): bool {
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;
}
private static function listFor(string $folder, string $key): array {
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] : [];
}
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void {
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void
{
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
if (!isset($acl['folders'][$folder])) {
@@ -182,19 +267,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
}
}
public static function isAdmin(array $perms = []): bool {
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;
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) {
if (
defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0
) {
return true;
}
return false;
}
public static function hasGrant(string $user, string $folder, string $cap): bool {
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);
@@ -202,35 +291,41 @@ public static function renameTree(string $oldFolder, string $newFolder): void
return false;
}
public static function isOwner(string $user, array $perms, string $folder): bool {
public static function isOwner(string $user, array $perms, string $folder): bool
{
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners');
}
public static function canManage(string $user, array $perms, string $folder): bool {
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 {
public static function canRead(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, 'read');
}
public static function canReadOwn(string $user, array $perms, string $folder): bool {
public static function canReadOwn(string $user, array $perms, string $folder): bool
{
if (self::canRead($user, $perms, $folder)) return true;
return self::hasGrant($user, $folder, 'read_own');
}
public static function canWrite(string $user, array $perms, string $folder): bool {
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');
}
public static function canShare(string $user, array $perms, string $folder): bool {
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')
@@ -238,7 +333,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
}
// Legacy-only explicit (to avoid breaking existing callers)
public static function explicit(string $folder): array {
public static function explicit(string $folder): array
{
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
$rec = $acl['folders'][$folder] ?? [];
@@ -257,7 +353,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
}
// New: full explicit including granular
public static function explicitAll(string $folder): array {
public static function explicitAll(string $folder): array
{
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
$rec = $acl['folders'][$folder] ?? [];
@@ -285,7 +382,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
];
}
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool {
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' => []];
@@ -314,19 +412,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
return self::save($acl);
}
public static function applyUserGrantsAtomic(string $user, array $grants): array {
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'); }
if (!flock($fh, LOCK_EX)) {
fclose($fh);
throw new RuntimeException('Cannot lock ACL storage');
}
try {
$raw = stream_get_contents($fh);
if ($raw === false) $raw = '';
$acl = json_decode($raw, true);
if (!is_array($acl)) $acl = ['folders'=>[], 'groups'=>[]];
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'] = [];
@@ -335,7 +437,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
foreach ($grants as $folder => $caps) {
$ff = self::normalizeFolder((string)$folder);
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = [];
$rec =& $acl['folders'][$ff];
$rec = &$acl['folders'][$ff];
foreach (self::BUCKETS as $k) {
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = [];
@@ -365,10 +467,16 @@ public static function renameTree(string $oldFolder, string $newFolder): void
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true; }
if ($m) {
$v = true;
$w = true;
$u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true;
}
if ($u && !$v && !$vo) $vo = true;
//if ($s && !$v) $v = true;
if ($w) { $c = $u = $ed = $rn = $cp = $dl = $ex = true; }
if ($w) {
$c = $u = $ed = $rn = $cp = $dl = $ex = true;
}
if ($m) $rec['owners'][] = $user;
if ($v) $rec['read'][] = $user;
@@ -385,7 +493,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
if ($dl) $rec['delete'][] = $user;
if ($ex) $rec['extract'][] = $user;
if ($sf) $rec['share_file'][] = $user;
if ($sfo)$rec['share_folder'][] = $user;
if ($sfo) $rec['share_folder'][] = $user;
foreach (self::BUCKETS as $k) {
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
@@ -409,90 +517,102 @@ public static function renameTree(string $oldFolder, string $newFolder): void
}
}
// --- Granular write family -----------------------------------------------
// --- Granular write family -----------------------------------------------
public static function canCreate(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, 'create')
|| self::hasGrant($user, $folder, 'write');
}
public static function canCreate(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, 'create')
|| self::hasGrant($user, $folder, 'write');
}
public static function canCreateFolder(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
// Only owners/managers can create subfolders under $folder
return self::hasGrant($user, $folder, 'owners');
}
public static function canCreateFolder(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
// Only owners/managers can create subfolders under $folder
return self::hasGrant($user, $folder, 'owners');
}
public static function canUpload(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, 'upload')
|| self::hasGrant($user, $folder, 'write');
}
public static function canUpload(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, 'upload')
|| self::hasGrant($user, $folder, 'write');
}
public static function canEdit(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, 'edit')
|| self::hasGrant($user, $folder, 'write');
}
public static function canEdit(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, 'edit')
|| self::hasGrant($user, $folder, 'write');
}
public static function canRename(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, 'rename')
|| self::hasGrant($user, $folder, 'write');
}
public static function canRename(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, 'rename')
|| self::hasGrant($user, $folder, 'write');
}
public static function canCopy(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, 'copy')
|| self::hasGrant($user, $folder, 'write');
}
public static function canCopy(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, 'copy')
|| self::hasGrant($user, $folder, 'write');
}
public static function canMove(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canMove(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canMoveFolder(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canMoveFolder(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canDelete(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, 'delete')
|| self::hasGrant($user, $folder, 'write');
}
public static function canDelete(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, 'delete')
|| self::hasGrant($user, $folder, 'write');
}
public static function canExtract(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, 'extract')
|| self::hasGrant($user, $folder, 'write');
}
public static function canExtract(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, 'extract')
|| self::hasGrant($user, $folder, 'write');
}
/** Sharing: files use share, folders require share + full-view. */
public static function canShareFile(string $user, array $perms, string $folder): bool {
public static function canShareFile(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');
}
public static function canShareFolder(string $user, array $perms, string $folder): bool {
public static function canShareFolder(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');

90
src/models/FolderMeta.php Normal file
View File

@@ -0,0 +1,90 @@
<?php
// src/models/FolderMeta.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/config/config.php';
require_once __DIR__ . '/../../src/lib/ACL.php';
class FolderMeta
{
private static function path(): string {
return rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_colors.json';
}
public static function normalizeFolder(string $folder): string {
$f = trim(str_replace('\\','/',$folder), "/ \t\r\n");
return ($f === '' || $f === 'root') ? 'root' : $f;
}
/** Normalize hex (accepts #RGB or #RRGGBB, returns #RRGGBB) */
public static function normalizeHex(?string $hex): ?string {
if ($hex === null || $hex === '') return null;
if (!preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $hex)) {
throw new \InvalidArgumentException('Invalid color hex');
}
if (strlen($hex) === 4) {
$hex = '#' . $hex[1].$hex[1] . $hex[2].$hex[2] . $hex[3].$hex[3];
}
return strtoupper($hex);
}
/** Read full map from disk */
public static function getMap(): array {
$file = self::path();
$raw = @file_get_contents($file);
$map = is_string($raw) ? json_decode($raw, true) : [];
return is_array($map) ? $map : [];
}
/** Write full map to disk (atomic-ish) */
private static function writeMap(array $map): void {
$file = self::path();
$dir = dirname($file);
if (!is_dir($dir)) @mkdir($dir, 0775, true);
$tmp = $file . '.tmp';
@file_put_contents($tmp, json_encode($map, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOCK_EX);
@rename($tmp, $file);
@chmod($file, 0664);
}
/** Set or clear a color for one folder */
public static function setColor(string $folder, ?string $hex): array {
$folder = self::normalizeFolder($folder);
$hex = self::normalizeHex($hex);
$map = self::getMap();
if ($hex === null) unset($map[$folder]);
else $map[$folder] = $hex;
self::writeMap($map);
return ['folder'=>$folder, 'color'=>$map[$folder] ?? null];
}
/** Migrate color entries for a whole subtree (used by move/rename) */
public static function migrateSubtree(string $source, string $target): array {
$src = self::normalizeFolder($source);
$dst = self::normalizeFolder($target);
if ($src === 'root') return ['changed'=>false, 'moved'=>0];
$map = self::getMap();
if (!$map) return ['changed'=>false, 'moved'=>0];
$new = $map;
$moved = 0;
foreach ($map as $key => $hex) {
$isSelf = ($key === $src);
$isSub = str_starts_with($key.'/', $src.'/');
if (!$isSelf && !$isSub) continue;
unset($new[$key]);
$suffix = substr($key, strlen($src)); // '' or '/child/...'
$newKey = $dst === 'root' ? ltrim($suffix,'/') : rtrim($dst,'/') . $suffix;
$new[$newKey] = $hex;
$moved++;
}
if ($moved) self::writeMap($new);
return ['changed'=> (bool)$moved, 'moved'=> $moved];
}
}

View File

@@ -10,6 +10,45 @@ class FolderModel
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
* ============================================================ */
public static function countVisible(string $folder, string $user, array $perms): array
{
// Normalize
$folder = ACL::normalizeFolder($folder);
// ACL gate: if you cant read, report empty (no leaks)
if (!$user || !ACL::canRead($user, $perms, $folder)) {
return ['folders' => 0, 'files' => 0];
}
// Resolve paths under UPLOAD_DIR
$root = rtrim((string)UPLOAD_DIR, '/\\');
$path = ($folder === 'root') ? $root : ($root . '/' . $folder);
$realRoot = @realpath($root);
$realPath = @realpath($path);
if ($realRoot === false || $realPath === false || strpos($realPath, $realRoot) !== 0) {
return ['folders' => 0, 'files' => 0];
}
// Count quickly, skipping UI-internal dirs
$folders = 0; $files = 0;
try {
foreach (new DirectoryIterator($realPath) as $f) {
if ($f->isDot()) continue;
$name = $f->getFilename();
if ($name === 'trash' || $name === 'profile_pics') continue;
if ($f->isDir()) $folders++; else $files++;
if ($folders > 0 || $files > 0) break; // short-circuit: we only care if empty vs not
}
} catch (\Throwable $e) {
// Stay quiet + safe
$folders = 0; $files = 0;
}
return ['folders' => $folders, 'files' => $files];
}
/** Load the folder → owner map. */
public static function getFolderOwners(): array
{