release(v1.9.12): feat(pro-acl): add user groups and group-aware ACL

This commit is contained in:
Ryan
2025-11-19 02:48:06 -05:00
committed by GitHub
parent e1b20a9f1d
commit 49d3588322
13 changed files with 1325 additions and 227 deletions

View File

@@ -0,0 +1,166 @@
<?php
// src/controllers/AclAdminController.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
class AclAdminController
{
public function getUserGrants(string $user): array
{
if (!preg_match(REGEX_USER, $user)) {
throw new InvalidArgumentException('Invalid user');
}
$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, fall back to ACL file
}
if (empty($folders)) {
$aclPath = rtrim(META_DIR, "/\\") . DIRECTORY_SEPARATOR . '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::explicitAll($f);
$isOwner = $has($rec['owners'], $user);
$canViewAll = $isOwner || $has($rec['read'], $user);
$canViewOwn = $has($rec['read_own'], $user);
$canShare = $isOwner || $has($rec['share'], $user);
$canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user);
if (
$canViewAll || $canViewOwn || $canUpload || $canShare || $isOwner
|| $has($rec['create'], $user) || $has($rec['edit'], $user) || $has($rec['rename'], $user)
|| $has($rec['copy'], $user) || $has($rec['move'], $user) || $has($rec['delete'], $user)
|| $has($rec['extract'], $user) || $has($rec['share_file'], $user) || $has($rec['share_folder'], $user)
) {
$out[$f] = [
'view' => $canViewAll,
'viewOwn' => $canViewOwn,
'write' => $has($rec['write'], $user) || $isOwner,
'manage' => $isOwner,
'share' => $canShare,
'create' => $isOwner || $has($rec['create'], $user),
'upload' => $isOwner || $has($rec['upload'], $user) || $has($rec['write'], $user),
'edit' => $isOwner || $has($rec['edit'], $user) || $has($rec['write'], $user),
'rename' => $isOwner || $has($rec['rename'], $user) || $has($rec['write'], $user),
'copy' => $isOwner || $has($rec['copy'], $user) || $has($rec['write'], $user),
'move' => $isOwner || $has($rec['move'], $user) || $has($rec['write'], $user),
'delete' => $isOwner || $has($rec['delete'], $user) || $has($rec['write'], $user),
'extract' => $isOwner || $has($rec['extract'], $user)|| $has($rec['write'], $user),
'shareFile' => $isOwner || $has($rec['share_file'], $user) || $has($rec['share'], $user),
'shareFolder' => $isOwner || $has($rec['share_folder'], $user) || $has($rec['share'], $user),
];
}
}
return $out;
}
public function saveUserGrantsPayload(array $payload): array
{
$normalizeCaps = function (array $row): array {
$bool = function ($v) {
return !empty($v) && $v !== 'false' && $v !== 0;
};
$k = [
'view','viewOwn','upload','manage','share',
'create','edit','rename','copy','move','delete','extract',
'shareFile','shareFolder','write'
];
$out = [];
foreach ($k as $kk) {
$out[$kk] = $bool($row[$kk] ?? false);
}
if ($out['shareFolder'] && !$out['view']) {
$out['view'] = true;
}
if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) {
$out['viewOwn'] = true;
}
return $out;
};
$sanitizeGrantsMap = function (array $grants) use ($normalizeCaps): array {
$out = [];
foreach ($grants as $folder => $caps) {
if (!is_string($folder)) $folder = (string)$folder;
if (!is_array($caps)) $caps = [];
$out[$folder] = $normalizeCaps($caps);
}
return $out;
};
$validUser = function (string $u): bool {
return ($u !== '' && preg_match(REGEX_USER, $u));
};
// Single-user mode
if (isset($payload['user'], $payload['grants']) && is_array($payload['grants'])) {
$user = trim((string)$payload['user']);
if (!$validUser($user)) {
throw new InvalidArgumentException('Invalid user');
}
$grants = $sanitizeGrantsMap($payload['grants']);
return ACL::applyUserGrantsAtomic($user, $grants);
}
// Batch mode
if (isset($payload['changes']) && is_array($payload['changes'])) {
$updated = [];
foreach ($payload['changes'] as $chg) {
if (!is_array($chg)) continue;
$user = trim((string)($chg['user'] ?? ''));
$gr = $chg['grants'] ?? null;
if (!$validUser($user) || !is_array($gr)) continue;
try {
$res = ACL::applyUserGrantsAtomic($user, $sanitizeGrantsMap($gr));
$updated[$user] = $res['updated'] ?? [];
} catch (\Throwable $e) {
$updated[$user] = ['error' => $e->getMessage()];
}
}
return ['ok' => true, 'updated' => $updated];
}
throw new InvalidArgumentException('Invalid payload: expected {user,grants} or {changes:[{user,grants}]}');
}
}

View File

@@ -8,7 +8,7 @@ class AdminController
{
/** Enforce authentication (401). */
private static function requireAuth(): void
public static function requireAuth(): void
{
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
@@ -19,7 +19,7 @@ class AdminController
}
/** Enforce admin (401). */
private static function requireAdmin(): void
public static function requireAdmin(): void
{
self::requireAuth();
@@ -69,7 +69,7 @@ class AdminController
}
/** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */
private static function requireCsrf(): void
public static function requireCsrf(): void
{
$h = self::headersLower();
$token = trim($h['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
@@ -272,6 +272,72 @@ public function setLicense(): void
}
}
public function getProGroups(): array
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$proGroupsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProGroups.php';
if (!is_file($proGroupsPath)) {
throw new RuntimeException('ProGroups.php not found in Pro bundle.');
}
require_once $proGroupsPath;
$store = new ProGroups(FR_PRO_BUNDLE_DIR);
$groups = $store->listGroups();
return $groups;
}
/**
* @param array $groupsPayload Raw "groups" array from JSON body
*/
public function saveProGroups(array $groupsPayload): void
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$proGroupsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProGroups.php';
if (!is_file($proGroupsPath)) {
throw new RuntimeException('ProGroups.php not found in Pro bundle.');
}
require_once $proGroupsPath;
// Normalize / validate the payload into the canonical structure
if (!is_array($groupsPayload)) {
throw new InvalidArgumentException('Invalid groups format.');
}
$data = ['groups' => []];
foreach ($groupsPayload as $name => $info) {
$name = trim((string)$name);
if ($name === '') {
continue;
}
$label = isset($info['label']) ? trim((string)$info['label']) : $name;
$members = isset($info['members']) && is_array($info['members']) ? $info['members'] : [];
$grants = isset($info['grants']) && is_array($info['grants']) ? $info['grants'] : [];
$data['groups'][$name] = [
'name' => $name,
'label' => $label,
'members' => array_values(array_unique(array_map('strval', $members))),
'grants' => $grants,
];
}
$store = new ProGroups(FR_PRO_BUNDLE_DIR);
if (!$store->save($data)) {
throw new RuntimeException('Could not write groups.json');
}
}
public function installProBundle(): void
{
header('Content-Type: application/json; charset=utf-8');
@@ -374,7 +440,6 @@ public function installProBundle(): void
$installed = [
'src' => [],
'public' => [],
'docs' => [],
];
@@ -436,21 +501,6 @@ public function installProBundle(): void
$targetPath = $bundleRoot . DIRECTORY_SEPARATOR . $relative;
$category = 'src';
} elseif (strpos($name, 'public/api/pro/') === 0) {
// e.g. public/api/pro/uploadBrandLogo.php
$relative = substr($name, strlen('public/api/pro/'));
if ($relative === '' || substr($relative, -1) === '/') {
continue;
}
// Persist under bundle dir so it survives image rebuilds:
// users/pro/public/api/pro/...
$targetPath = $bundleRoot
. DIRECTORY_SEPARATOR . 'public'
. DIRECTORY_SEPARATOR . 'api'
. DIRECTORY_SEPARATOR . 'pro'
. DIRECTORY_SEPARATOR . $relative;
$category = 'public';
} else {
// Skip anything outside these prefixes
continue;

View File

@@ -227,6 +227,166 @@ class ACL
return $data;
}
/**
* Load Pro user groups from FR_PRO_BUNDLE_DIR/users/pro/groups.json.
* Returns a map: groupName => ['name','label','members'=>[],'grants'=>[]]
* When Pro is inactive or no file exists, returns an empty array.
*/
private static function loadGroupData(): array
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) return [];
if (!defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) return [];
static $loaded = false;
static $cache = [];
static $mtime = 0;
$base = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\");
if ($base === '') return [];
$file = $base . DIRECTORY_SEPARATOR . 'groups.json';
$mt = @filemtime($file) ?: 0;
if ($loaded && $mtime === $mt) {
return $cache;
}
$loaded = true;
$mtime = $mt;
if (!$mt || !is_file($file)) {
$cache = [];
return $cache;
}
$raw = @file_get_contents($file);
if ($raw === false || $raw === '') {
$cache = [];
return $cache;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
$cache = [];
return $cache;
}
$groups = isset($data['groups']) && is_array($data['groups']) ? $data['groups'] : $data;
$norm = [];
foreach ($groups as $key => $g) {
if (!is_array($g)) continue;
$name = isset($g['name']) ? (string)$g['name'] : (string)$key;
$name = trim($name);
if ($name === '') continue;
$g['name'] = $name;
$g['label'] = isset($g['label']) ? (string)$g['label'] : $name;
if (!isset($g['members']) || !is_array($g['members'])) {
$g['members'] = [];
} else {
$g['members'] = array_values(array_unique(array_map('strval', $g['members'])));
}
if (!isset($g['grants']) || !is_array($g['grants'])) {
$g['grants'] = [];
}
$norm[$name] = $g;
}
$cache = $norm;
return $cache;
}
/**
* Map a group grants record for a single folder to a capability bucket.
* Supports both internal bucket keys and the UI-style keys: view, viewOwn,
* manage, shareFile, shareFolder.
*/
private static function groupGrantsCap(array $grants, string $capKey): bool
{
// Direct match (owners, read, write, share, read_own, create, upload, edit, rename, copy, move, delete, extract, share_file, share_folder)
if (array_key_exists($capKey, $grants) && $grants[$capKey] === true) {
return true;
}
switch ($capKey) {
case 'read':
return !empty($grants['view']);
case 'read_own':
// Full view always implies own
if (!empty($grants['view'])) return true;
return !empty($grants['viewOwn']);
case 'share_file':
if (!empty($grants['share_file'])) return true;
return !empty($grants['shareFile']);
case 'share_folder':
if (!empty($grants['share_folder'])) return true;
return !empty($grants['shareFolder']);
case 'write':
case 'create':
case 'upload':
case 'edit':
case 'rename':
case 'copy':
case 'move':
case 'delete':
case 'extract':
if (!empty($grants[$capKey])) return true;
// Group "manage" implies all write-ish caps
return !empty($grants['manage']);
case 'share':
if (!empty($grants['share'])) return true;
// Manage can optionally imply share; this keeps UI simple
return !empty($grants['manage']);
}
return false;
}
/**
* Check whether any Pro group the user belongs to grants this cap for folder.
* Groups are additive only; they never remove access.
*/
private static function groupHasGrant(string $user, string $folder, string $capKey): bool
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) return false;
$user = (string)$user;
if ($user === '') return false;
$folder = self::normalizeFolder($folder);
if ($folder === '') $folder = 'root';
$groups = self::loadGroupData();
if (!$groups) return false;
foreach ($groups as $g) {
if (!is_array($g)) continue;
$members = $g['members'] ?? [];
$isMember = false;
if (is_array($members)) {
foreach ($members as $m) {
if (strcasecmp((string)$m, $user) === 0) {
$isMember = true;
break;
}
}
}
if (!$isMember) continue;
$folderGrants = $g['grants'][$folder] ?? null;
if (!is_array($folderGrants)) continue;
if (self::groupGrantsCap($folderGrants, $capKey)) {
return true;
}
}
return false;
}
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;
@@ -286,8 +446,20 @@ class ACL
{
$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;
// 1) Core per-folder ACL buckets (folder_acl.json)
$arr = self::listFor($folder, $capKey);
foreach ($arr as $u) {
if (strcasecmp((string)$u, $user) === 0) {
return true;
}
}
// 2) Pro user groups (if enabled) additive only
if (self::groupHasGrant($user, $folder, $capKey)) {
return true;
}
return false;
}
@@ -620,4 +792,4 @@ class ACL
// require full view too
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'read');
}
}
}