Skip to content

Commit 22bdea6

Browse files
authored
release(v3.11.1): shared-hosting worker fallback and deleted-user session invalidation (closes #110)
- transfer(shared-hosting): fall back from shell_exec to exec or foreground workers so move/copy/zip jobs stay usable on restrictive hosts (#110) - compat(shell): degrade ClamAV, archive, and admin diagnostics paths cleanly when PHP command execution is unavailable - auth(delete-user): invalidate deleted-account sessions and revoke remember-me tokens so removed users cannot regain access on subsequent requests
1 parent a78e25c commit 22bdea6

13 files changed

Lines changed: 592 additions & 86 deletions

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,37 @@
11
# Changelog
22

3+
## Changes 03/24/2026 (v3.11.1)
4+
5+
`release(v3.11.1): shared-hosting worker fallback and deleted-user session invalidation (closes #110)`
6+
7+
**Commit message**
8+
9+
```text
10+
release(v3.11.1): shared-hosting worker fallback and deleted-user session invalidation (closes #110)
11+
12+
- transfer(shared-hosting): fall back from shell_exec to exec or foreground workers so move/copy/zip jobs stay usable on restrictive hosts (#110)
13+
- compat(shell): degrade ClamAV, archive, and admin diagnostics paths cleanly when PHP command execution is unavailable
14+
- auth(delete-user): invalidate deleted-account sessions and revoke remember-me tokens so removed users cannot regain access on subsequent requests
15+
```
16+
17+
**Fixed**
18+
19+
- **Shared-hosting transfer compatibility**
20+
- Fixed a case where move/copy jobs could fail with `500` on hosts that disable `proc_open()` / `shell_exec()` and similar process-launch functions, leaving folder operations unusable.
21+
- FileRise now falls back to safer worker-launch paths and foreground execution where appropriate so transfer and ZIP workflows remain usable on more restrictive shared-hosting environments.
22+
23+
- **Deleted-account session invalidation**
24+
- Fixed a case where a deleted account could continue using an already-established session until the PHP session expired or the web service was restarted.
25+
- Deleted users can no longer regain access through remember-me restoration, and user deletion now revokes stored remember-me tokens for that account.
26+
27+
**Changed**
28+
29+
- **Shell-dependent feature degradation**
30+
- Shell-backed features now report clearer host limitations when PHP command execution is unavailable instead of failing with less actionable worker or command errors.
31+
- ClamAV diagnostics, archive operations, and related admin/runtime checks now degrade more cleanly on locked-down hosts.
32+
33+
---
34+
335
## Changes 03/20/2026 (v3.11.0)
436

537
`release(v3.11.0): snippet ownership enforcement and phpseclib security update`

config/config.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,18 @@
155155
define('FR_WEBDAV_MAX_UPLOAD_BYTES', 0);
156156
}
157157
}
158+
// Background worker mode for transfer / zip / scan jobs.
159+
// auto = prefer background workers, fallback when unavailable
160+
// async = require background workers
161+
// sync = force foreground execution where supported
162+
if (!defined('FR_WORKER_MODE')) {
163+
$envVal = getenv('FR_WORKER_MODE');
164+
$mode = strtolower(trim($envVal === false ? '' : (string)$envVal));
165+
if (!in_array($mode, ['auto', 'async', 'sync'], true)) {
166+
$mode = 'auto';
167+
}
168+
define('FR_WORKER_MODE', $mode);
169+
}
158170
// Antivirus / ClamAV (optional)
159171
// If VIRUS_SCAN_ENABLED is set in the environment, it overrides the admin setting.
160172
// If it is not set, we don't define the constant and the admin checkbox controls scanning.
@@ -342,6 +354,38 @@ function loadUserPermissions($username)
342354
return is_array($row) ? $row : false;
343355
}
344356

357+
function fr_local_user_exists($username): bool
358+
{
359+
$username = trim((string)$username);
360+
if ($username === '') {
361+
return false;
362+
}
363+
364+
return \FileRise\Domain\UserModel::getUserRole($username) !== null;
365+
}
366+
367+
function fr_forget_authenticated_user(bool $clearRememberCookie = false): void
368+
{
369+
if ($clearRememberCookie && !empty($_COOKIE['remember_me_token']) && class_exists(\FileRise\Domain\AuthModel::class)) {
370+
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
371+
\FileRise\Domain\AuthModel::revokeRememberToken((string)$_COOKIE['remember_me_token']);
372+
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
373+
unset($_COOKIE['remember_me_token']);
374+
}
375+
376+
unset(
377+
$_SESSION['authenticated'],
378+
$_SESSION['username'],
379+
$_SESSION['isAdmin'],
380+
$_SESSION['folderOnly'],
381+
$_SESSION['readOnly'],
382+
$_SESSION['disableUpload'],
383+
$_SESSION['pending_login_user'],
384+
$_SESSION['pending_login_secret'],
385+
$_SESSION['pending_login_remember_me']
386+
);
387+
}
388+
345389
// Determine HTTPS usage
346390
$envSecure = getenv('SECURE');
347391
$secure = ($envSecure !== false)
@@ -394,6 +438,10 @@ function loadUserPermissions($username)
394438
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
395439
}
396440

441+
if (!empty($_SESSION['authenticated']) && !fr_local_user_exists((string)($_SESSION['username'] ?? ''))) {
442+
fr_forget_authenticated_user(true);
443+
}
444+
397445
// Auto-login via persistent token
398446
if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) {
399447
$payload = \FileRise\Domain\AuthModel::consumeRememberToken($_COOKIE['remember_me_token']);

src/FileRise/Domain/AuthModel.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ class AuthModel
1515
private const FAIL2BAN_LOG_MAX_BYTES = 50 * 1024 * 1024;
1616
private const FAIL2BAN_LOG_MAX_FILES = 5;
1717

18+
public static function userExists(string $username): bool
19+
{
20+
return UserModel::getUserRole($username) !== null;
21+
}
22+
1823
public static function isOidcDemoteAllowed(): bool
1924
{
2025
// 1) Container / env always wins if set
@@ -586,6 +591,13 @@ public static function validateRememberToken(string $token): ?array
586591
return null;
587592
}
588593

594+
$username = (string)($payload['username'] ?? '');
595+
if ($username === '' || !self::userExists($username)) {
596+
unset($all[$hash], $all[$token]);
597+
self::saveRememberTokenStore($all);
598+
return null;
599+
}
600+
589601
return $payload;
590602
}
591603

@@ -632,6 +644,15 @@ public static function consumeRememberToken(string $token): ?array
632644
return null;
633645
}
634646

647+
if (!self::userExists($username)) {
648+
unset($all[$hash]);
649+
if ($legacyKey !== null) {
650+
unset($all[$legacyKey]);
651+
}
652+
self::saveRememberTokenStore($all);
653+
return null;
654+
}
655+
635656
$expiry = (int)$payload['expiry'];
636657
$isAdmin = !empty($payload['isAdmin']);
637658

@@ -713,6 +734,27 @@ public static function revokeRememberToken(string $token): void
713734
}
714735
}
715736

737+
public static function revokeRememberTokensForUser(string $username): void
738+
{
739+
$all = self::loadRememberTokenStore();
740+
if (!$all) {
741+
return;
742+
}
743+
744+
$changed = false;
745+
foreach ($all as $key => $payload) {
746+
$storedUser = is_array($payload) ? (string)($payload['username'] ?? '') : '';
747+
if ($storedUser !== '' && strcasecmp($storedUser, $username) === 0) {
748+
unset($all[$key]);
749+
$changed = true;
750+
}
751+
}
752+
753+
if ($changed) {
754+
self::saveRememberTokenStore($all);
755+
}
756+
}
757+
716758
protected static function rememberTokenHash(string $token): string
717759
{
718760
$key = $GLOBALS['encryptionKey'] ?? '';

src/FileRise/Domain/DiskUsageScanLauncher.php

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace FileRise\Domain;
66

7+
use FileRise\Support\WorkerLauncher;
78
use RuntimeException;
89

910
require_once PROJECT_ROOT . '/config/config.php';
@@ -32,6 +33,9 @@ public static function launch(string $sourceId = ''): array
3233

3334
$pid = self::launchBackground($php, $worker, $logFile, $sourceId);
3435
if ($pid <= 0) {
36+
if (!WorkerLauncher::allowsForegroundFallback()) {
37+
throw new RuntimeException('Background disk usage worker is unavailable in async mode.');
38+
}
3539
self::launchForeground($php, $worker, $logFile, $sourceId);
3640
$pid = null;
3741
}
@@ -55,49 +59,38 @@ private static function resolveWorkerPath(): string
5559

5660
private static function resolvePhpCli(): string
5761
{
58-
$candidates = array_values(array_filter([
59-
PHP_BINARY ?: null,
60-
'/usr/local/bin/php',
61-
'/usr/bin/php',
62-
'/bin/php',
63-
]));
64-
65-
foreach ($candidates as $bin) {
66-
$rc = 1;
67-
$out = [];
68-
@exec(escapeshellcmd((string)$bin) . ' -v >/dev/null 2>&1', $out, $rc);
69-
if ($rc === 0) {
70-
return (string)$bin;
71-
}
62+
$php = WorkerLauncher::resolvePhpCli();
63+
if ($php) {
64+
return $php;
7265
}
7366

7467
throw new RuntimeException('No working php CLI found.');
7568
}
7669

7770
private static function launchBackground(string $php, string $worker, string $logFile, string $sourceId): int
7871
{
72+
if (WorkerLauncher::prefersSync()) {
73+
return 0;
74+
}
75+
7976
$cmdStr =
8077
'nohup ' . escapeshellcmd($php) . ' ' . escapeshellarg($worker) .
8178
($sourceId !== '' ? (' ' . escapeshellarg($sourceId)) : '') .
8279
' >> ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
8380

84-
$pidRaw = @shell_exec('/bin/sh -c ' . escapeshellarg($cmdStr));
85-
return is_string($pidRaw) ? (int)trim($pidRaw) : 0;
81+
$spawn = WorkerLauncher::spawnBackgroundShell($cmdStr);
82+
return !empty($spawn['ok']) ? (int)($spawn['pid'] ?? 0) : 0;
8683
}
8784

8885
private static function launchForeground(string $php, string $worker, string $logFile, string $sourceId): void
8986
{
90-
$rc = 1;
91-
$out = [];
92-
@exec(
87+
$run = WorkerLauncher::runForegroundCommand(
9388
escapeshellcmd($php) . ' ' . escapeshellarg($worker) .
9489
($sourceId !== '' ? (' ' . escapeshellarg($sourceId)) : '') .
95-
' >> ' . escapeshellarg($logFile) . ' 2>&1',
96-
$out,
97-
$rc
90+
' >> ' . escapeshellarg($logFile) . ' 2>&1'
9891
);
9992

100-
if ($rc !== 0) {
93+
if (empty($run['ok'])) {
10194
throw new RuntimeException('Failed to launch disk usage scan (exec/whitelist issue?). See log: ' . $logFile);
10295
}
10396
}

src/FileRise/Domain/FileModel.php

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use FileRise\Support\CryptoAtRest;
77
use FileRise\Support\FS;
88
use FileRise\Support\UploadNamePolicy;
9+
use FileRise\Support\WorkerLauncher;
910
use FileRise\Storage\StorageAdapterInterface;
1011
use FileRise\Storage\SourceContext;
1112
use FileRise\Storage\StorageRegistry;
@@ -1850,11 +1851,18 @@ public static function extractZipArchive($folder, $files)
18501851
return ['name' => '', 'type' => null, 'mapped' => null];
18511852
};
18521853

1854+
$archiveExecAvailable = WorkerLauncher::canRunForeground();
1855+
$archiveExecError = 'Archive operations for this format are unavailable on this host because PHP command execution (exec) is disabled.';
1856+
18531857
$sevenZipBin = null;
1854-
$findSevenZip = function () use (&$sevenZipBin): ?string {
1858+
$findSevenZip = function () use (&$sevenZipBin, $archiveExecAvailable): ?string {
18551859
if ($sevenZipBin !== null) {
18561860
return $sevenZipBin ?: null;
18571861
}
1862+
if (!$archiveExecAvailable) {
1863+
$sevenZipBin = '';
1864+
return null;
1865+
}
18581866
$candidates = [
18591867
'7zz',
18601868
'/usr/bin/7zz',
@@ -1889,10 +1897,14 @@ public static function extractZipArchive($folder, $files)
18891897
};
18901898

18911899
$unarBin = null;
1892-
$findUnar = function () use (&$unarBin): ?string {
1900+
$findUnar = function () use (&$unarBin, $archiveExecAvailable): ?string {
18931901
if ($unarBin !== null) {
18941902
return $unarBin ?: null;
18951903
}
1904+
if (!$archiveExecAvailable) {
1905+
$unarBin = '';
1906+
return null;
1907+
}
18961908
$candidates = [
18971909
'unar',
18981910
'/usr/bin/unar',
@@ -2355,7 +2367,9 @@ public static function extractZipArchive($folder, $files)
23552367

23562368
$sevenZip = $findSevenZip();
23572369
if (!$sevenZip) {
2358-
$errors[] = "7z is not available on the server; cannot extract $archiveBase.";
2370+
$errors[] = !$archiveExecAvailable
2371+
? "$archiveBase blocked: {$archiveExecError}"
2372+
: "7z is not available on the server; cannot extract $archiveBase.";
23592373
$allSuccess = false;
23602374
continue;
23612375
}

src/FileRise/Domain/GatewayTestService.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use FileRise\Storage\SourceContext;
88
use FileRise\Support\FS;
9+
use FileRise\Support\WorkerLauncher;
910
use RuntimeException;
1011

1112
require_once PROJECT_ROOT . '/config/config.php';
@@ -105,8 +106,8 @@ public static function run(string $id, bool $includeSecrets = false): array
105106
private static function findRclonePath(): string
106107
{
107108
$rclonePath = '';
108-
if (function_exists('shell_exec')) {
109-
$out = @shell_exec('command -v rclone 2>/dev/null');
109+
$out = WorkerLauncher::captureCommand('command -v rclone 2>/dev/null');
110+
if (is_string($out)) {
110111
$rclonePath = is_string($out) ? trim($out) : '';
111112
}
112113

0 commit comments

Comments
 (0)