release(v3.4.0): Core Sources (Local + WebDAV), Pro Gateway Shares admin/API, pretheme CSP cleanup, and pagination fix (closes #104)

- sources(core): add SourcesConfig + core WebDAV adapter (Local + WebDAV without Pro)
- sources(api/ui): migrate /api/pro/sources/* to SourcesConfig and expose capability metadata (allowedTypes/proExtended)
- admin: add per-source delete-permanently toggle + trash-off badges/hints
- pro: add Gateway Shares admin section + /api/pro/gateways/{list,save,test,delete}
- ui: fix pagination getting stuck on page 2 in table/gallery (pane state sync)
- frontend/security: move pretheme to external js/pretheme.js and remove inline CSP hash requirement
- licensing: attempt yearly-plan instance auto-bind on license save with clearer autoBind responses
This commit is contained in:
Ryan
2026-02-15 02:33:27 -05:00
committed by GitHub
parent 18d7144128
commit ab2f519cbe
38 changed files with 3050 additions and 247 deletions
+74
View File
@@ -1,5 +1,79 @@
# Changelog
## Changes 02/15/2026 (v3.4.0) FileRise 1 Year Anniversary
`release(v3.4.0): Core Sources (Local + WebDAV), Pro Gateway Shares admin/API, pretheme CSP cleanup, and pagination fix (closes #104)`
**Commit message**
```text
release(v3.4.0): Core Sources (Local + WebDAV), Pro Gateway Shares admin/API, pretheme CSP cleanup, and pagination fix (closes #104)
- sources(core): add SourcesConfig + core WebDAV adapter (Local + WebDAV without Pro)
- sources(api/ui): migrate /api/pro/sources/* to SourcesConfig and expose capability metadata (allowedTypes/proExtended)
- admin: add per-source delete-permanently toggle + trash-off badges/hints
- pro: add Gateway Shares admin section + /api/pro/gateways/{list,save,test,delete}
- ui: fix pagination getting stuck on page 2 in table/gallery (pane state sync)
- frontend/security: move pretheme to external js/pretheme.js and remove inline CSP hash requirement
- licensing: attempt yearly-plan instance auto-bind on license save with clearer autoBind responses
```
**Added**
- **Core Sources support (without Pro)**
- Added `SourcesConfig` to provide a unified source config layer for Core + Pro.
- Core now supports **Local** and **WebDAV** source types directly.
- Added capability metadata in sources responses: `available`, `proExtended`, `allowedTypes`, `coreTypes`, `proTypes`.
- **Core WebDAV adapter**
- Added `WebDavAdapter` in core storage layer and wired it through `StorageFactory`.
- Added legacy shims: `src/lib/SourcesConfig.php`, `src/lib/WebDavAdapter.php`.
- **Pro Gateway Shares API surface (Core integration for Pro)**
- Added admin API endpoints:
- `/api/pro/gateways/list.php`
- `/api/pro/gateways/save.php`
- `/api/pro/gateways/test.php`
- `/api/pro/gateways/delete.php`
- Added admin UI section for Gateway Shares (SFTP/S3/MCP) with snippet preview/copy + validation output.
- **Pre-theme bootstrap script**
- Added `public/js/pretheme.js` to apply theme before full app load and reduce first-paint flash.
**Changed**
- **Sources APIs now run through `SourcesConfig`**
- `/api/pro/sources/{list,save,select,delete,test,visible}` migrated from direct `ProSources` checks to `SourcesConfig`.
- Source endpoints no longer hard-fail with `Pro is not active` for Core-supported source workflows.
- **Source-aware delete/trash behavior**
- Added per-source `disableTrash` support end-to-end (API/UI/storage metadata).
- `FileModel` now respects source trash mode and returns clearer errors when Trash directory creation fails.
- Recycle Bin node is hidden for sources with trash disabled.
- **Admin + i18n updates**
- Updated source wording/hints for the delete-permanently toggle and trash behavior.
- Added gateway-share translation keys and UI strings.
- **CSP / bootstrap flow**
- Replaced inline pre-theme script with external `js/pretheme.js`.
- Updated `.htaccess` CSP baseline to remove inline script hash requirement.
- Added rewrite rule to serve `/index.html` via `index.php` for consistent bootstrapping.
- **License save flow**
- Admin license save now attempts automatic Instance-ID bind for yearly plans and returns `autoBind` result details.
**Fixed**
- **Pagination stuck on page 2 (Issue #104)**
- Fixed pane-local pagination state sync for both table and gallery views so Prev/Next works reliably after page changes.
- **Dual-pane activation guard**
- Prevented pane activation click handling when dual-pane mode is disabled.
- **OnlyOffice CSP helper text**
- Updated generated Apache/Nginx CSP helper examples to remove obsolete inline script hash guidance.
**Docs**
- Added/updated documentation for:
- Sources onboarding and admin notes
- Core/Pro source behavior
- Pro Gateway Shares
---
## Changes 02/10/2026 (v3.3.3)
`release(v3.3.3): fix OnlyOffice local sourceId handling + improve Pro bundle download reliability (Cloudflare UA + ZIP sanity checks)`
+1 -1
View File
@@ -73,7 +73,7 @@ The Admin Panel is where you manage users, folder access, authentication, integr
### Sources
- Enable Sources, add/edit/test connections, and set source read-only.
- Enable Sources, add/edit/test connections, set source read-only, and optionally bypass trash (permanent delete).
- See [Pro Sources](https://github.com/error311/FileRise/wiki/Pro-Sources) for details.
### Pro Features
+68
View File
@@ -0,0 +1,68 @@
# Pro gateway shares (SFTP / S3 / MCP)
Gateway Shares in Pro are control-plane records. They store config, validate it, and generate snippets.
Important: in v1, FileRise does not start or stop long-running gateway services for you.
MCP in v1 is metadata/token scaffolding only; runtime MCP server execution is not shipped yet.
## What "Test" does
- Checks config validity (type, bind, port, credentials).
- Checks `rclone` availability (best effort).
- Checks whether port bind appears available (best effort).
- Returns warnings/errors and generated snippets.
`Test` does not launch `rclone serve ...`.
## SFTP quick start (Docker sidecar, recommended)
1. Keep FileRise running in `filerise-app` (web on `8081`).
2. Run `rclone` as a separate container on the same Docker network.
3. Mount the same uploads volume into the sidecar using the same in-container path expected by the snippet (for local sources this is usually `/var/www/uploads`).
4. Publish the gateway port on the sidecar (`-p 2022:2022`).
5. For LAN access, bind to `0.0.0.0` and connect to `HOST_IP:PORT`.
Example sidecar:
```bash
docker run -d \
--name filerise-gw-test \
--network filerise-net \
-p 2022:2022 \
-v "$HOME/filerise/uploads:/var/www/uploads" \
rclone/rclone:latest \
sh -lc "rclone serve sftp '/var/www/uploads/Documentation' --addr 0.0.0.0:2022 --user test --pass test --read-only=false"
```
## Fallback: run rclone inside the FileRise container
This works, but `apt-get install` in a running container is **not persistent** across recreate/redeploy.
```bash
docker exec -it filerise-app sh -lc "apt-get update && apt-get install -y rclone"
```
If you use this fallback, ensure the FileRise container itself publishes the gateway port (`-p 2022:2022`).
## Common failures
- `rclone not found on PATH (cannot verify)`:
- Install `rclone` in the runtime where the command executes.
- For Docker, prefer `rclone/rclone` as a sidecar instead of installing in a running app container.
- `Connection refused`:
- Gateway service is not running, or Docker port publish is missing.
- Verify container ports include `2022->2022`.
- `Connection timed out`:
- Bind/listen mismatch, firewall, or wrong host IP.
- For LAN clients use `0.0.0.0` bind and connect to host LAN IP.
- `Port bind failed` in Test:
- Usually means that port is already in use or address is not bindable in current runtime.
## Security notes
- Keep default bind on `127.0.0.1` unless external access is required.
- Prefer firewall/reverse-proxy controls over exposing raw ports.
- Secrets are stored encrypted and are not echoed after save.
+3 -1
View File
@@ -30,10 +30,12 @@ Each source has its own root and trash behavior. ACLs are enforced per source.
- Source ID: unique slug used internally and in URLs.
- Enabled / Read only: disable or lock a source without deleting it.
- Delete permanently (skip trash): bypasses source trash and removes files immediately.
- Root path or prefix: scope the source to a subfolder (optional).
- Secrets: stored encrypted and never shown after save (leave blank to keep).
Note: Google Drive sources do not support Trash; deletes are permanent.
If trash is enabled, FileRise creates the source `trash` folder on demand during delete operations.
Note: Google Drive sources do not support Trash; deletes are always permanent.
---
+1
View File
@@ -24,6 +24,7 @@ Google Drive, OneDrive, and Dropbox require an OAuth app plus a refresh token.
- Use a dedicated service account for each source.
- Start with a small folder path for the first test.
- If Test fails, verify credentials, host/endpoint, and firewall rules.
- If your source root cannot host a `trash` folder, enable "Delete permanently (skip trash)" for that source.
## Related
+1
View File
@@ -46,6 +46,7 @@
- [Sources onboarding](Sources-Onboarding)
- [Search everywhere](Search-Everywhere)
- [Pro sources](Pro-Sources)
- [Pro gateway shares](Pro-Gateway-Shares)
- [Instance IDs](Instance-IDs)
### Developer
+2 -2
View File
@@ -26,7 +26,7 @@ ONLYOFFICE requires additional CSP rules. The admin panel provides a copy-ready
.htaccess edit (change url or copy directly from admin panel)
```
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com"
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com"
```
Nginx
@@ -36,7 +36,7 @@ proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
# Replace with an ONLYOFFICE-aware CSP at the proxy
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com" always;
```
---
+4 -2
View File
@@ -29,6 +29,8 @@ RewriteRule - - [L]
RewriteCond %{REQUEST_URI} !^/webdav\.php/ [NC]
RewriteRule "(^|/)\.(?!well-known/)" - [F]
RewriteRule ^portal/([A-Za-z0-9_-]+)$ portal.html?slug=$1 [L,QSA]
# Keep entrypoint consistent: serve /index.html through index.php.
RewriteRule ^index\.html$ index.php [L,QSA]
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
# - allow /api/*.php (API endpoints)
@@ -84,8 +86,8 @@ RewriteRule ^ - [E=IS_VER:1]
# HSTS only when HTTPS (safe for .htaccess)
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" env=HTTPS
# CSP — keep this SHA-256 in sync with your inline pre-theme script
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
# CSP baseline (pretheme bootstrap is external JS, so no inline script hash is required).
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
</IfModule>
# ---------------- Caching ----------------
+3 -3
View File
@@ -4,8 +4,8 @@
/**
* @OA\Post(
* path="/api/file/deleteFiles.php",
* summary="Delete files to Trash",
* description="Requires write access on the folder and (for non-admins) ownership of the files.",
* summary="Delete files (Trash or permanent)",
* description="Requires write access on the folder and (for non-admins) ownership of the files. Sources with Trash disabled (or Google Drive) delete permanently.",
* operationId="deleteFiles",
* tags={"Files"},
* security={{"cookieAuth": {}}},
@@ -32,4 +32,4 @@
require_once __DIR__ . '/../../../config/config.php';
$fileController = new \FileRise\Http\Controllers\FileController();
$fileController->deleteFiles();
$fileController->deleteFiles();
+57
View File
@@ -0,0 +1,57 @@
<?php
// public/api/pro/gateways/delete.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
exit;
}
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
\FileRise\Http\Controllers\AdminController::requireAuth();
\FileRise\Http\Controllers\AdminController::requireAdmin();
\FileRise\Http\Controllers\AdminController::requireCsrf();
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProGateways')) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Pro is not active']);
exit;
}
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid JSON body']);
exit;
}
$id = strtolower(trim((string)($body['id'] ?? '')));
if ($id === '') {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Missing gateway id']);
exit;
}
$ok = ProGateways::deleteGateway($id);
if (!$ok) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Failed to delete gateway share']);
exit;
}
echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Error deleting gateway share'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
+65
View File
@@ -0,0 +1,65 @@
<?php
// public/api/pro/gateways/list.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
exit;
}
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
\FileRise\Http\Controllers\AdminController::requireAuth();
\FileRise\Http\Controllers\AdminController::requireAdmin();
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProGateways')) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Pro is not active']);
exit;
}
$gateways = ProGateways::getAdminList();
$out = [];
foreach ($gateways as $g) {
if (!is_array($g)) continue;
$id = trim((string)($g['id'] ?? ''));
$snippets = $id !== '' ? ProGateways::buildSnippets($id, false) : null;
if (is_array($snippets)) {
$g['startCommand'] = $snippets['startCommand'] ?? null;
$g['dockerCompose'] = $snippets['dockerCompose'] ?? null;
$g['systemd'] = $snippets['systemd'] ?? null;
$g['snippets'] = [
'startCommand' => $snippets['startCommand'] ?? null,
'dockerCompose' => $snippets['dockerCompose'] ?? null,
'systemd' => $snippets['systemd'] ?? null,
];
} else {
$g['startCommand'] = $id !== '' ? ProGateways::buildStartCommand($id, false) : null;
$g['dockerCompose'] = null;
$g['systemd'] = null;
$g['snippets'] = [
'startCommand' => $g['startCommand'],
'dockerCompose' => null,
'systemd' => null,
];
}
$out[] = $g;
}
echo json_encode([
'ok' => true,
'gateways' => $out,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Error loading gateway shares'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
+93
View File
@@ -0,0 +1,93 @@
<?php
// public/api/pro/gateways/save.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
exit;
}
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
\FileRise\Http\Controllers\AdminController::requireAuth();
\FileRise\Http\Controllers\AdminController::requireAdmin();
\FileRise\Http\Controllers\AdminController::requireCsrf();
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProGateways')) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Pro is not active']);
exit;
}
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid JSON body']);
exit;
}
$gw = $body['gateway'] ?? $body;
if (!is_array($gw)) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Missing gateway payload']);
exit;
}
$sourceId = trim((string)($gw['sourceId'] ?? 'local'));
if (class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
if ($sourceId !== '' && strcasecmp($sourceId, 'local') !== 0) {
$src = SourceContext::getSourceById($sourceId);
if (!$src) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid sourceId']);
exit;
}
}
} else {
if ($sourceId !== '' && strcasecmp($sourceId, 'local') !== 0) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Sources are not enabled (only local sourceId is supported)']);
exit;
}
}
$actor = isset($_SESSION['username']) ? trim((string)$_SESSION['username']) : '';
$res = ProGateways::upsertGateway($gw, $actor);
if (empty($res['ok'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => (string)($res['error'] ?? 'Failed to save gateway share')]);
exit;
}
$gateway = $res['gateway'] ?? null;
$id = is_array($gateway) ? trim((string)($gateway['id'] ?? '')) : '';
$snippets = $id !== '' ? ProGateways::buildSnippets($id, false) : null;
$cmd = is_array($snippets) ? ($snippets['startCommand'] ?? null) : ($id !== '' ? ProGateways::buildStartCommand($id, false) : null);
echo json_encode([
'ok' => true,
'gateway' => $gateway,
'startCommand' => $cmd,
'dockerCompose' => is_array($snippets) ? ($snippets['dockerCompose'] ?? null) : null,
'systemd' => is_array($snippets) ? ($snippets['systemd'] ?? null) : null,
'snippets' => [
'startCommand' => $cmd,
'dockerCompose' => is_array($snippets) ? ($snippets['dockerCompose'] ?? null) : null,
'systemd' => is_array($snippets) ? ($snippets['systemd'] ?? null) : null,
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Error saving gateway share'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
+215
View File
@@ -0,0 +1,215 @@
<?php
// public/api/pro/gateways/test.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
require_once PROJECT_ROOT . '/src/lib/FS.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
exit;
}
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
\FileRise\Http\Controllers\AdminController::requireAuth();
\FileRise\Http\Controllers\AdminController::requireAdmin();
\FileRise\Http\Controllers\AdminController::requireCsrf();
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProGateways')) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Pro is not active']);
exit;
}
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid JSON body']);
exit;
}
$id = strtolower(trim((string)($body['id'] ?? '')));
if ($id === '') {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Missing gateway id']);
exit;
}
$stored = ProGateways::getStoredGateway($id);
if (!$stored) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'Gateway share not found']);
exit;
}
$warnings = [];
$errors = [];
$type = strtolower(trim((string)($stored['gatewayType'] ?? 'sftp')));
if (!in_array($type, ['sftp', 's3', 'mcp'], true)) {
$errors[] = 'Invalid gatewayType.';
}
$listenAddr = trim((string)($stored['listenAddr'] ?? '127.0.0.1'));
$port = (int)($stored['port'] ?? 0);
if ($port < 1024 || $port > 65535) {
$errors[] = 'Port must be 1024-65535.';
}
if ($listenAddr === '' || preg_match('/[\s\'"]/', $listenAddr)) {
$errors[] = 'Invalid listenAddr.';
}
// --- rclone presence (best-effort) ---
$rclonePath = '';
if (function_exists('shell_exec')) {
$out = @shell_exec('command -v rclone 2>/dev/null');
$rclonePath = is_string($out) ? trim($out) : '';
}
if ($rclonePath === '') {
foreach (['/usr/bin/rclone', '/usr/local/bin/rclone', '/opt/homebrew/bin/rclone'] as $p) {
if (is_file($p) && is_executable($p)) {
$rclonePath = $p;
break;
}
}
}
if ($rclonePath === '') {
$warnings[] = 'rclone not found on PATH (cannot verify).';
}
// --- port bind test (warn only; some environments restrict binding) ---
if (!$errors && $port >= 1024 && $port <= 65535) {
$uri = 'tcp://' . $listenAddr . ':' . $port;
$errno = 0;
$errstr = '';
$sock = @stream_socket_server($uri, $errno, $errstr);
if ($sock === false) {
$warnings[] = 'Port bind failed (port may be in use or address is not bindable).';
} else {
@fclose($sock);
}
}
// --- root boundary checks (local sources only) ---
$sourceId = trim((string)($stored['sourceId'] ?? 'local'));
$rootPath = trim((string)($stored['rootPath'] ?? 'root'));
$src = null;
if (class_exists('SourceContext') && SourceContext::sourcesEnabled() && $sourceId !== '' && strcasecmp($sourceId, 'local') !== 0) {
$src = SourceContext::getSourceById($sourceId);
if (!$src) {
$errors[] = 'Invalid sourceId.';
}
} else {
// Local-only mode (or local id)
$src = [
'id' => 'local',
'type' => 'local',
'config' => ['path' => (string)UPLOAD_DIR],
];
}
if ($src && is_array($src)) {
$srcType = strtolower((string)($src['type'] ?? ''));
if ($srcType === 'local') {
$cfg = isset($src['config']) && is_array($src['config']) ? $src['config'] : [];
$base = trim((string)($cfg['path'] ?? $cfg['root'] ?? UPLOAD_DIR));
if ($base === '') $base = (string)UPLOAD_DIR;
$base = rtrim($base, "/\\");
if (!is_dir($base)) {
$warnings[] = 'Local source path not found.';
} elseif (!is_readable($base)) {
$warnings[] = 'Local source path not readable.';
} else {
$baseReal = realpath($base);
if ($baseReal === false || $baseReal === '') {
$warnings[] = 'Local source path realpath() failed.';
} else {
$rel = str_replace('\\', '/', $rootPath);
$rel = trim($rel, '/');
if ($rel === '' || strcasecmp($rel, 'root') === 0) {
$target = $baseReal;
} else {
$target = $baseReal . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
}
$safe = FS::safeReal($baseReal, $target);
if ($safe === null) {
$errors[] = 'Root boundary check failed (path escapes base).';
}
}
}
} else {
$warnings[] = 'Non-local sources: boundary enforcement is not implemented for rclone command generation in v1.';
}
}
// --- credential presence checks ---
if ($type === 'sftp') {
$sftp = isset($stored['sftp']) && is_array($stored['sftp']) ? $stored['sftp'] : [];
$user = trim((string)($sftp['user'] ?? ''));
if ($user === '') {
$errors[] = 'SFTP user is missing.';
}
$hasPass = !empty($sftp['passEnc']);
$hasKeys = trim((string)($sftp['authorizedKeys'] ?? '')) !== '';
if (!$hasPass && !$hasKeys) {
$warnings[] = 'SFTP has no password or authorized keys configured.';
}
} elseif ($type === 's3') {
$s3 = isset($stored['s3']) && is_array($stored['s3']) ? $stored['s3'] : [];
$keys = isset($s3['keys']) && is_array($s3['keys']) ? $s3['keys'] : [];
if (!$keys) {
$errors[] = 'S3 gateway has no keypairs configured.';
} else {
$k0 = $keys[0] ?? null;
if (!is_array($k0) || empty($k0['accessKeyEnc']) || empty($k0['secretKeyEnc'])) {
$errors[] = 'S3 keypair is incomplete.';
}
}
} elseif ($type === 'mcp') {
$mcp = isset($stored['mcp']) && is_array($stored['mcp']) ? $stored['mcp'] : [];
if (empty($mcp['tokenEnc'])) {
$warnings[] = 'MCP token is missing.';
}
$warnings[] = 'MCP gateway server is not implemented in v1 bundle.';
}
$includeSecrets = !empty($body['includeSecrets']);
$snippets = ProGateways::buildSnippets($id, false);
$snippetsSecrets = $includeSecrets ? ProGateways::buildSnippets($id, true) : null;
$cmd = is_array($snippets) ? ($snippets['startCommand'] ?? null) : ProGateways::buildStartCommand($id, false);
$cmdSecrets = is_array($snippetsSecrets) ? ($snippetsSecrets['startCommand'] ?? null) : ($includeSecrets ? ProGateways::buildStartCommand($id, true) : null);
echo json_encode([
'ok' => empty($errors),
'errors' => $errors,
'warnings' => $warnings,
'startCommand' => $cmd,
'startCommandWithSecrets' => $cmdSecrets,
'dockerCompose' => is_array($snippets) ? ($snippets['dockerCompose'] ?? null) : null,
'systemd' => is_array($snippets) ? ($snippets['systemd'] ?? null) : null,
'dockerComposeWithSecrets' => is_array($snippetsSecrets) ? ($snippetsSecrets['dockerCompose'] ?? null) : null,
'systemdWithSecrets' => is_array($snippetsSecrets) ? ($snippetsSecrets['systemd'] ?? null) : null,
'snippets' => [
'startCommand' => $cmd,
'dockerCompose' => is_array($snippets) ? ($snippets['dockerCompose'] ?? null) : null,
'systemd' => is_array($snippets) ? ($snippets['systemd'] ?? null) : null,
],
'snippetsWithSecrets' => [
'startCommand' => $cmdSecrets,
'dockerCompose' => is_array($snippetsSecrets) ? ($snippetsSecrets['dockerCompose'] ?? null) : null,
'systemd' => is_array($snippetsSecrets) ? ($snippetsSecrets['systemd'] ?? null) : null,
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Error testing gateway share'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
+2 -7
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/SourcesConfig.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
@@ -21,12 +22,6 @@ try {
\FileRise\Http\Controllers\AdminController::requireAdmin();
\FileRise\Http\Controllers\AdminController::requireCsrf();
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProSources') || !fr_pro_api_level_at_least(FR_PRO_API_REQUIRE_SOURCES)) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Pro is not active']);
exit;
}
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
@@ -42,7 +37,7 @@ try {
exit;
}
$res = ProSources::deleteSource($id);
$res = SourcesConfig::deleteSource($id);
if (empty($res['ok'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => $res['error'] ?? 'Failed to delete source']);
+7 -7
View File
@@ -6,6 +6,7 @@ header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
require_once PROJECT_ROOT . '/src/lib/SourcesConfig.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
@@ -21,13 +22,7 @@ try {
\FileRise\Http\Controllers\AdminController::requireAuth();
\FileRise\Http\Controllers\AdminController::requireAdmin();
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProSources') || !fr_pro_api_level_at_least(FR_PRO_API_REQUIRE_SOURCES)) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Pro is not active']);
exit;
}
$cfg = ProSources::getAdminList();
$cfg = SourcesConfig::getAdminList();
$activeId = class_exists('SourceContext') ? SourceContext::getActiveId() : '';
echo json_encode([
@@ -35,6 +30,11 @@ try {
'enabled' => !empty($cfg['enabled']),
'sources' => $cfg['sources'] ?? [],
'activeId' => $activeId,
'available' => !empty($cfg['available']),
'proExtended' => !empty($cfg['proExtended']),
'allowedTypes' => $cfg['allowedTypes'] ?? [],
'coreTypes' => $cfg['coreTypes'] ?? [],
'proTypes' => $cfg['proTypes'] ?? [],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
+5 -10
View File
@@ -6,6 +6,7 @@ header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/StorageFactory.php';
require_once PROJECT_ROOT . '/src/lib/SourcesConfig.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
@@ -22,12 +23,6 @@ try {
\FileRise\Http\Controllers\AdminController::requireAdmin();
\FileRise\Http\Controllers\AdminController::requireCsrf();
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProSources') || !fr_pro_api_level_at_least(FR_PRO_API_REQUIRE_SOURCES)) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Pro is not active']);
exit;
}
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
@@ -41,7 +36,7 @@ try {
if (array_key_exists('enabled', $body)) {
$enabled = (bool)$body['enabled'];
$ok = ProSources::saveEnabled($enabled);
$ok = SourcesConfig::saveEnabled($enabled);
if (!$ok) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'Failed to save sources setting']);
@@ -51,7 +46,7 @@ try {
}
if (isset($body['source']) && is_array($body['source'])) {
$res = ProSources::upsertSource($body['source']);
$res = SourcesConfig::upsertSource($body['source']);
if (empty($res['ok'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => $res['error'] ?? 'Failed to save source']);
@@ -76,7 +71,7 @@ try {
if ($resultSource && !empty($resultSource['enabled'])) {
$autoTested = true;
$sourceId = trim((string)($resultSource['id'] ?? ''));
$sourceForTest = $sourceId !== '' ? ProSources::getSource($sourceId) : null;
$sourceForTest = $sourceId !== '' ? SourcesConfig::getSource($sourceId) : null;
$autoTestOk = false;
if (!$sourceForTest) {
@@ -146,7 +141,7 @@ try {
}
if (is_array($disableSource)) {
$disableSource['enabled'] = false;
$resDisable = ProSources::upsertSource($disableSource);
$resDisable = SourcesConfig::upsertSource($disableSource);
if (empty($resDisable['ok'])) {
$autoDisableFailed = true;
} else {
+3 -8
View File
@@ -7,6 +7,7 @@ header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
require_once PROJECT_ROOT . '/src/lib/SourcesConfig.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
@@ -22,12 +23,6 @@ try {
\FileRise\Http\Controllers\AdminController::requireAuth();
\FileRise\Http\Controllers\AdminController::requireCsrf();
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProSources') || !fr_pro_api_level_at_least(FR_PRO_API_REQUIRE_SOURCES)) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Pro is not active']);
exit;
}
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
@@ -43,14 +38,14 @@ try {
exit;
}
$cfg = ProSources::getConfig();
$cfg = SourcesConfig::getConfig();
if (empty($cfg['enabled'])) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Sources are not enabled']);
exit;
}
$source = ProSources::getSource($id);
$source = SourcesConfig::getSource($id);
if (!$source || empty($source['enabled'])) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'Source not found']);
+2 -7
View File
@@ -6,6 +6,7 @@ header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/StorageFactory.php';
require_once PROJECT_ROOT . '/src/lib/SourcesConfig.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
@@ -22,12 +23,6 @@ try {
\FileRise\Http\Controllers\AdminController::requireAdmin();
\FileRise\Http\Controllers\AdminController::requireCsrf();
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProSources') || !fr_pro_api_level_at_least(FR_PRO_API_REQUIRE_SOURCES)) {
http_response_code(403);
echo json_encode(['ok' => false, 'error' => 'Pro is not active']);
exit;
}
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
@@ -43,7 +38,7 @@ try {
exit;
}
$source = ProSources::getSource($id);
$source = SourcesConfig::getSource($id);
if (!$source) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'Source not found']);
+12 -15
View File
@@ -7,6 +7,7 @@ header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
require_once PROJECT_ROOT . '/src/lib/SourcesConfig.php';
try {
if (session_status() !== PHP_SESSION_ACTIVE) {
@@ -35,21 +36,7 @@ try {
} catch (Throwable $e) { /* ignore */ }
$activeId = class_exists('SourceContext') ? SourceContext::getActiveId() : '';
$proActive = defined('FR_PRO_ACTIVE')
&& FR_PRO_ACTIVE
&& class_exists('ProSources')
&& fr_pro_api_level_at_least(FR_PRO_API_REQUIRE_SOURCES);
if (!$proActive) {
echo json_encode([
'ok' => true,
'enabled' => false,
'sources' => [],
'activeId' => $activeId,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$cfg = ProSources::getPublicConfig();
$cfg = SourcesConfig::getPublicConfig();
$enabled = !empty($cfg['enabled']);
$sources = isset($cfg['sources']) && is_array($cfg['sources']) ? $cfg['sources'] : [];
@@ -59,6 +46,11 @@ try {
'enabled' => (bool)$enabled,
'sources' => [],
'activeId' => $activeId,
'available' => !empty($cfg['available']),
'proExtended' => !empty($cfg['proExtended']),
'allowedTypes' => $cfg['allowedTypes'] ?? [],
'coreTypes' => $cfg['coreTypes'] ?? [],
'proTypes' => $cfg['proTypes'] ?? [],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
@@ -86,6 +78,11 @@ try {
'enabled' => (bool)$enabled,
'sources' => $visible,
'activeId' => $activeId,
'available' => !empty($cfg['available']),
'proExtended' => !empty($cfg['proExtended']),
'allowedTypes' => $cfg['allowedTypes'] ?? [],
'coreTypes' => $cfg['coreTypes'] ?? [],
'proTypes' => $cfg['proTypes'] ?? [],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(500);
+2
View File
@@ -4239,6 +4239,8 @@ margin-bottom: 20px;
#adminPanelModal .sources-form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;}
#adminPanelModal .sources-form-inline{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:20px;}
#adminPanelModal .sources-form-inline .form-check{margin:0;}
#adminPanelModal .sources-flags-row{grid-column:1 / -1;align-items:flex-start;margin-top:6px;}
#adminPanelModal .sources-inline-help{flex:1 1 100%;margin:0;line-height:1.35;}
#adminPanelModal .sources-type-block{margin-top:8px;padding:8px;border:1px dashed var(--fr-border-light,#d8d8d8);border-radius:8px;background:#fff;}
#adminPanelModal .sources-hint-row{display:flex;justify-content:flex-end;margin:0 0 6px;}
#adminPanelModal .sources-hint-btn{position:relative;width:18px;height:18px;border-radius:999px;border:1px solid rgba(0,0,0,0.18);background:rgba(0,0,0,0.04);color:#475569;font-size:12px;font-weight:700;line-height:1;padding:0;cursor:pointer;}
+1 -1
View File
@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
<meta name="theme-color" content="#0b5ed7">
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
<script src="js/pretheme.js?v={{APP_QVER}}"></script>
<style id="pretheme-css">
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
</style>
+2 -4
View File
@@ -369,12 +369,10 @@ function attachOnlyOfficeCspHelper(container) {
`;
container.appendChild(cspHelp);
const INLINE_SHA = "sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=";
function buildCspApache(originRaw) {
const o = (originRaw || 'https://your-onlyoffice-server.example.com').replace(/\/+$/, '');
const api = `${o}/web-apps/apps/api/documents/api.js`;
return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`;
return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`;
}
function buildCspNginx(originRaw) {
@@ -386,7 +384,7 @@ function attachOnlyOfficeCspHelper(container) {
`base-uri 'self'; ` +
`frame-ancestors 'self'; ` +
`object-src 'none'; ` +
`script-src 'self' '${INLINE_SHA}' ${o} ${api}; ` +
`script-src 'self' ${o} ${api}; ` +
`style-src 'self' 'unsafe-inline'; ` +
`img-src 'self' data: blob:; ` +
`font-src 'self'; ` +
+957 -115
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -2301,6 +2301,7 @@ try {
// Activate pane on click
document.addEventListener('click', (e) => {
if (!window.dualPaneEnabled) return;
const pane = e.target && e.target.closest
? e.target.closest('.file-list-pane')
: null;
@@ -6892,6 +6893,13 @@ const subfoldersSorted = await sortSubfoldersForCurrentOrder(allSubfolders);
currentPage = totalPages;
window.currentPage = currentPage;
}
// Keep pane-local pagination state in sync so click-capture pane activation
// does not restore a stale page before pagination handlers run.
const tablePane = getPaneKeyForElement(fileListContent);
savePaneState(tablePane, {
currentPage,
currentSearchTerm: window.currentSearchTerm || ''
});
const startRow = (currentPage - 1) * itemsPerPageSetting;
const endRow = Math.min(startRow + itemsPerPageSetting, totalRows);
@@ -7356,6 +7364,12 @@ export function renderGalleryView(folder, container) {
currentPage = totalPages || 1;
window.currentPage = currentPage;
}
// Keep pane-local pagination state in sync for gallery mode as well.
const galleryPane = getPaneKeyForElement(fileListContent);
savePaneState(galleryPane, {
currentPage,
currentSearchTerm: window.currentSearchTerm || ''
});
// --- Top controls: search + pagination + items-per-page ---
let galleryHTML = buildSearchAndPaginationControls({
+24
View File
@@ -183,6 +183,28 @@ function getSourceTypeById(sourceId) {
return '';
}
function isTrashDisabledForSource(sourceId = '') {
const id = String(sourceId || getActiveSourceId() || '').trim();
if (!id) return false;
try {
if (typeof window.__frGetSourceMetaById === 'function') {
const meta = window.__frGetSourceMetaById(id);
if (meta && typeof meta === 'object' && Object.prototype.hasOwnProperty.call(meta, 'disableTrash')) {
return !!meta.disableTrash;
}
}
} catch (e) { /* ignore */ }
const sel = document.getElementById('sourceSelector');
if (sel) {
const opt = Array.from(sel.options).find(o => o.value === id);
if (opt) return opt.dataset?.sourceDisableTrash === '1';
}
return false;
}
function isFtpSourceId(sourceId = '') {
const type = String(getSourceTypeById(sourceId || getActiveSourceId()) || '').toLowerCase();
return type === 'ftp';
@@ -1837,6 +1859,8 @@ function placeRecycleBinNode() {
const isAdmin = localStorage.getItem('isAdmin') === '1' || localStorage.getItem('isAdmin') === 'true';
if (!isAdmin) return;
if (isTrashDisabledForSource()) return;
renderRecycleBinNode(window.recycleBinHasItems || false);
}
+60 -1
View File
@@ -253,6 +253,9 @@ const translations = {
"source_name": "Source Name",
"source_type": "Type",
"source_enabled": "Enabled",
"source_disable_trash": "Delete permanently",
"source_disable_trash_help": "Delete permanently toggle: ON skips Trash and deletes immediately. OFF moves files to Trash (FileRise creates the trash folder on demand).",
"source_trash_disabled_badge": "Trash off",
"source_local_path": "Local path",
"source_s3_bucket": "S3 bucket",
"source_s3_region": "S3 region (optional)",
@@ -303,13 +306,69 @@ const translations = {
"source_test_error": "Test failed",
"source_secret_note": "Secrets are never shown after saving. Leave blank to keep existing values.",
"source_hint_button": "Show setup hints",
"source_hint_local": "Use an absolute server path. Leave blank to use the default uploads root.",
"source_hint_local": "Use an absolute server path. Leave blank to use the default uploads root. Ensure the web user can read/write this path. If trash cannot be created, enable Delete permanently for this source.",
"source_hint_s3": "Bucket is required.\nRegion is optional (defaults to us-east-1).\nEndpoint and path-style are for S3-compatible providers.\nPrefix is optional.",
"source_hint_sftp": "Host and username are required.\nUse a password or private key.\nRoot is optional; blank uses the login directory.",
"source_hint_ftp": "Host and username are required.\nPassive mode is recommended.\nRoot is optional; blank uses the login directory.",
"source_hint_gdrive": "Create an OAuth client in Google Cloud.\nGet a refresh token with scope https://www.googleapis.com/auth/drive.\nRootId: drive.google.com/drive/folders/ID or blank for root.\nDriveId: set for shared drives.\nNative Docs/Sheets/Slides export as DOCX/XLSX/PPTX on download.",
"source_hint_webdav": "Base URL and username are required.\nRoot is optional; blank uses the server root.\nDisable TLS verification only for self-signed certs.",
"source_hint_smb": "Host, share, and username are required.\nDomain and root are optional.\nLeave SMB version on Auto unless your server requires a specific version.",
// Gateway Shares (Pro)
"gateway_shares": "Gateway Shares",
"gateway_locked_body": "Expose a scoped source root over SFTP, S3, or MCP with generated start commands and safety checks.",
"gateway_add": "Add Gateway",
"gateway_edit": "Edit Gateway",
"gateway_save": "Save Gateway",
"gateway_name": "Name",
"gateway_type": "Gateway Type",
"gateway_source_id": "Source ID",
"gateway_root_path": "Root Path",
"gateway_mode": "Mode",
"gateway_listen_addr": "Listen Address",
"gateway_port": "Port",
"gateway_bind": "Bind",
"gateway_snippets": "Gateway snippets",
"gateway_start_command": "Start command",
"gateway_docker_compose": "docker-compose snippet",
"gateway_systemd_unit": "systemd unit snippet",
"gateway_include_secrets": "Include secrets on test",
"gateway_secrets_warning": "Warning: snippets may include secrets. Handle output carefully.",
"gateway_test_output": "Test output",
"gateway_runtime_title": "Gateway runtime checklist",
"gateway_runtime_line_1": "1) Install rclone on the gateway host/container.",
"gateway_runtime_line_2": "2) For Docker, recommended: run rclone as a sidecar from the docker-compose snippet.",
"gateway_runtime_line_3": "3) If you run rclone inside the FileRise container, publish the gateway port (example: -p 2022:2022).",
"gateway_runtime_line_4": "4) Use listen address 0.0.0.0 for LAN access, then connect to host IP:port.",
"gateway_runtime_line_5": "5) Test validates config only; it does not start or stop gateway services.",
"gateway_runtime_docs": "Open Gateway setup guide",
"gateway_command": "Command",
"gateway_list_empty": "No gateway shares configured yet.",
"gateway_name_required": "Gateway name is required.",
"gateway_type_required": "Gateway type is required.",
"gateway_port_invalid": "Port must be between 1024 and 65535.",
"gateway_sftp_user": "SFTP user",
"gateway_sftp_pass": "SFTP password",
"gateway_sftp_authorized_keys": "Authorized keys",
"gateway_sftp_user_required": "SFTP user is required.",
"gateway_sftp_auth_required": "Provide SFTP password or authorized keys.",
"gateway_s3_access_key": "S3 access key",
"gateway_s3_secret_key": "S3 secret key",
"gateway_s3_keys_required": "S3 access key and secret key are required.",
"gateway_s3_keys_pair_required": "Provide both S3 access key and secret key.",
"gateway_mcp_token": "MCP token (optional)",
"gateway_mcp_token_hint": "Leave blank to keep existing token or auto-generate on create.",
"gateway_secret_note": "Secrets are never shown after saving. Leave secret fields blank to keep existing values.",
"gateway_saving": "Saving gateway...",
"gateway_saved": "Gateway share saved.",
"gateway_deleted": "Gateway share deleted.",
"gateway_delete_confirm": "Delete gateway \"{name}\"?",
"gateway_save_failed": "Failed to save gateway share.",
"gateway_delete_failed": "Failed to delete gateway share.",
"gateway_list_failed": "Failed to load gateway shares.",
"gateway_test_ok": "Gateway config validation passed (service not started).",
"gateway_test_failed": "Gateway test failed.",
"gateway_count": "{count} gateway share(s).",
"enabled": "Enabled",
"disabled": "Disabled",
+53
View File
@@ -0,0 +1,53 @@
// Apply theme colors before main CSS/JS to reduce flash on first paint.
(function () {
try {
var stored = localStorage.getItem('darkMode');
var isDark = (stored === null)
? !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
: (stored === '1' || stored === 'true');
var theme = isDark ? 'dark' : 'light';
var bg = isDark ? '#121212' : '#ffffff';
var root = document.documentElement;
function applyTheme(el) {
if (!el) return;
el.classList.toggle('dark-mode', isDark);
el.setAttribute('data-theme', theme);
el.style.colorScheme = theme;
}
applyTheme(root);
root.style.backgroundColor = bg;
root.style.setProperty('--pre-bg', bg);
function applyBodyTheme() {
var body = document.body;
if (!body) return false;
applyTheme(body);
return true;
}
if (!applyBodyTheme()) {
var applied = false;
var onBodyReady = function () {
if (applied) return;
applied = applyBodyTheme();
};
if (typeof MutationObserver === 'function') {
var observer = new MutationObserver(function () {
onBodyReady();
if (applied) observer.disconnect();
});
observer.observe(root, { childList: true });
}
document.addEventListener('DOMContentLoaded', onBodyReady, { once: true });
}
var metaTheme = document.querySelector('meta[name="theme-color"]');
if (metaTheme) metaTheme.setAttribute('content', bg);
} catch (e) {
// Ignore early bootstrap errors.
}
})();
+35 -3
View File
@@ -110,9 +110,40 @@ function getSourceTypeById(id) {
}
function getSourceMetaById(id) {
const key = String(id || '').trim();
if (!key) return { name: '', type: '', readOnly: false, disableTrash: false };
try {
const meta = window.__FR_SOURCE_META_MAP;
if (meta && Object.prototype.hasOwnProperty.call(meta, key)) {
const row = meta[key] || {};
return {
name: String(row.name || ''),
type: String(row.type || ''),
readOnly: !!row.readOnly,
disableTrash: !!row.disableTrash
};
}
} catch (e) { /* ignore */ }
const select = document.getElementById('sourceSelector');
if (select) {
const opt = Array.from(select.options).find(o => o.value === key);
if (opt) {
return {
name: String(opt.dataset?.sourceName || ''),
type: String(opt.dataset?.sourceType || ''),
readOnly: opt.dataset?.sourceReadOnly === '1',
disableTrash: opt.dataset?.sourceDisableTrash === '1'
};
}
}
return {
name: getSourceNameById(id),
type: getSourceTypeById(id)
name: getSourceNameById(key),
type: getSourceTypeById(key),
readOnly: false,
disableTrash: false
};
}
@@ -260,8 +291,9 @@ export async function initSourceSelector(opts = {}) {
opt.dataset.sourceName = name;
opt.dataset.sourceType = type;
opt.dataset.sourceReadOnly = src.readOnly ? '1' : '0';
opt.dataset.sourceDisableTrash = src.disableTrash ? '1' : '0';
nameMap[id] = name;
metaMap[id] = { name, type, readOnly: !!src.readOnly };
metaMap[id] = { name, type, readOnly: !!src.readOnly, disableTrash: !!src.disableTrash };
select.appendChild(opt);
});
+3 -9
View File
@@ -3,12 +3,13 @@
namespace FileRise\Domain;
use FileRise\Http\Controllers\AdminController;
use FileRise\Storage\SourcesConfig;
use ProAudit;
use ProSources;
// src/models/AdminModel.php
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/SourcesConfig.php';
class AdminModel
{
@@ -338,14 +339,7 @@ class AdminModel
'lockedByEnv' => $proSearchLockedByEnv,
];
if ($isProActive && class_exists('ProSources') && fr_pro_api_level_at_least(FR_PRO_API_REQUIRE_SOURCES)) {
$public['storageSources'] = ProSources::getPublicConfig();
} else {
$public['storageSources'] = [
'enabled' => false,
'sources' => [],
];
}
$public['storageSources'] = SourcesConfig::getPublicConfig();
$proAuditCfg = (isset($config['proAudit']) && is_array($config['proAudit']))
? $config['proAudit']
+8 -11
View File
@@ -823,16 +823,7 @@ class FileModel
$errors = [];
$storage = self::storage();
$isLocal = $storage->isLocal();
$skipTrash = false;
if (!$isLocal && class_exists('SourceContext')) {
$src = SourceContext::getActiveSource();
if (is_array($src)) {
$type = strtolower((string)($src['type'] ?? ''));
if ($type === 'gdrive') {
$skipTrash = true;
}
}
}
$skipTrash = class_exists('SourceContext') ? SourceContext::isTrashDisabled() : false;
list($uploadDir, $err) = self::resolveFolderPath($folder, false);
if ($err) {
@@ -847,7 +838,13 @@ class FileModel
if (!$skipTrash) {
$trashDir = rtrim(self::trashRoot(), '/\\') . DIRECTORY_SEPARATOR;
if ($storage->stat($trashDir) === null) {
$storage->mkdir($trashDir, 0755, true);
if (!$storage->mkdir($trashDir, 0755, true)) {
$detail = self::adapterErrorDetail($storage);
$msg = $detail !== ''
? ("Failed to create Trash folder: " . $detail)
: "Failed to create Trash folder. Check source permissions or enable delete permanently (skip trash) for this source.";
return ["error" => $msg];
}
}
$trashMetadataFile = $trashDir . "trash.json";
$trashJson = $storage->read($trashMetadataFile);
@@ -992,6 +992,190 @@ class AdminController
}
}
private static function decodeFrpPayload(string $license): ?array
{
$license = trim($license);
if ($license === '' || stripos($license, 'FRP1.') !== 0) {
return null;
}
$parts = explode('.', $license, 3);
if (count($parts) !== 3) {
return null;
}
$payloadPart = (string)$parts[1];
if ($payloadPart === '') {
return null;
}
$base64 = strtr($payloadPart, '-_', '+/');
$pad = strlen($base64) % 4;
if ($pad > 0) {
$base64 .= str_repeat('=', 4 - $pad);
}
$json = base64_decode($base64, true);
if (!is_string($json) || $json === '') {
return null;
}
$payload = json_decode($json, true);
return is_array($payload) ? $payload : null;
}
private static function normalizeFrpInstances(array $payload): array
{
$out = [];
if (isset($payload['instances']) && is_array($payload['instances'])) {
foreach ($payload['instances'] as $entry) {
$id = strtolower(trim((string)$entry));
if ($id !== '' && preg_match('/^[a-f0-9]{32}$/', $id)) {
$out[] = $id;
}
}
}
if (!$out) {
return [];
}
return array_values(array_unique($out));
}
private static function tryAutoBindInstance(string $license, string $instanceId): array
{
$result = [
'attempted' => false,
'bound' => false,
'changed' => false,
'message' => '',
'license' => $license,
];
$license = trim($license);
$instanceId = strtolower(trim($instanceId));
if ($license === '' || $instanceId === '' || !preg_match('/^[a-f0-9]{32}$/', $instanceId)) {
return $result;
}
$payload = self::decodeFrpPayload($license);
if (!is_array($payload)) {
return $result;
}
$plan = strtolower(trim((string)($payload['plan'] ?? '')));
if (!in_array($plan, ['personal_yearly', 'business_yearly'], true)) {
return $result;
}
$result['attempted'] = true;
$instances = self::normalizeFrpInstances($payload);
if (in_array($instanceId, $instances, true)) {
$result['bound'] = true;
$result['message'] = 'Instance ID already bound to this license.';
return $result;
}
$endpoint = defined('FR_PRO_BIND_INSTANCE_URL')
? trim((string)FR_PRO_BIND_INSTANCE_URL)
: 'https://filerise.net/pro/bind_instance.php';
if ($endpoint === '') {
$result['message'] = 'Auto-bind endpoint is not configured.';
return $result;
}
$postData = http_build_query([
'license' => $license,
'instanceId' => $instanceId,
]);
$status = 0;
$bodyText = '';
if (function_exists('curl_init')) {
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/x-www-form-urlencoded',
'Accept: application/json',
]);
curl_setopt($ch, CURLOPT_USERAGENT, 'FileRise-Core/1.0 (+https://filerise.net)');
$resp = curl_exec($ch);
if (is_string($resp)) {
$bodyText = $resp;
}
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
} else {
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", [
'Content-Type: application/x-www-form-urlencoded',
'Accept: application/json',
'User-Agent: FileRise-Core/1.0 (+https://filerise.net)',
]) . "\r\n",
'content' => $postData,
'timeout' => 15,
'ignore_errors' => true,
],
]);
$resp = @file_get_contents($endpoint, false, $ctx);
if (is_string($resp)) {
$bodyText = $resp;
}
if (isset($http_response_header) && is_array($http_response_header)) {
foreach ($http_response_header as $line) {
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $line, $m)) {
$status = (int)$m[1];
break;
}
}
}
}
$body = null;
if ($bodyText !== '') {
$decoded = json_decode($bodyText, true);
if (is_array($decoded)) {
$body = $decoded;
}
}
if ($status !== 200 || !is_array($body) || empty($body['ok'])) {
$code = is_array($body) ? trim((string)($body['code'] ?? '')) : '';
if ($code === 'instance_limit_reached') {
$result['message'] = 'License instance limit reached. Use filerise.net/pro/instances.php to manage IDs.';
return $result;
}
if ($code === 'license_superseded') {
$result['message'] = 'This license key has been superseded. Use your latest issued key.';
return $result;
}
$err = is_array($body) ? trim((string)($body['error'] ?? '')) : '';
$result['message'] = $err !== '' ? $err : 'Instance auto-bind was not completed.';
return $result;
}
$nextLicense = trim((string)($body['license'] ?? ''));
if ($nextLicense !== '' && stripos($nextLicense, 'FRP1.') === 0) {
$result['license'] = $nextLicense;
$result['changed'] = ($nextLicense !== $license);
}
$result['bound'] = !empty($body['bound']) || $result['changed'];
$msg = trim((string)($body['message'] ?? ''));
if ($msg === '') {
$msg = $result['changed']
? 'Instance ID was bound to this license automatically.'
: 'License saved.';
}
$result['message'] = $msg;
return $result;
}
public function setLicense(): void
{
// Always respond JSON
@@ -1012,6 +1196,11 @@ class AdminController
}
$license = isset($data['license']) ? trim((string)$data['license']) : '';
$instanceId = self::getInstanceId();
$autoBind = self::tryAutoBindInstance($license, $instanceId);
if (!empty($autoBind['changed']) && !empty($autoBind['license']) && is_string($autoBind['license'])) {
$license = trim((string)$autoBind['license']);
}
// Store license + updatedAt in JSON file
if (!defined('PRO_LICENSE_FILE')) {
@@ -1038,7 +1227,15 @@ class AdminController
return;
}
echo json_encode(['success' => true]);
echo json_encode([
'success' => true,
'autoBind' => [
'attempted' => !empty($autoBind['attempted']),
'bound' => !empty($autoBind['bound']),
'changed' => !empty($autoBind['changed']),
'message' => (string)($autoBind['message'] ?? ''),
],
]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
@@ -2462,6 +2659,12 @@ class AdminController
@unlink($zipPath);
@rmdir($workDir);
$label = $httpCode ? "HTTP {$httpCode}" : 'Download failed';
if ($msg === '') {
if ($snippet !== '' && preg_match('/^Invalid license:\\s*(.+)$/i', $snippet, $m)) {
$detail = trim((string)$m[1]);
$msg = $detail !== '' ? $detail : 'Invalid license for bundle download.';
}
}
if ($msg === '') {
// If we got HTML (common with Cloudflare "Just a moment..."), return a human message.
$looksHtml = ($snippet !== '' && preg_match('/^\\s*<(?:!doctype|html|head|body)\\b/i', $snippet));
+33 -24
View File
@@ -4,11 +4,12 @@ declare(strict_types=1);
namespace FileRise\Storage;
use ProSources;
use FileRise\Storage\SourcesConfig;
// src/lib/SourceContext.php
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/SourcesConfig.php';
final class SourceContext
{
@@ -39,21 +40,19 @@ final class SourceContext
$sessionId = (string)$_SESSION['active_source'];
}
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE && class_exists('ProSources')) {
$cfg = ProSources::getConfig();
$enabled = !empty($cfg['enabled']);
if ($enabled) {
$source = ProSources::getSource($sessionId);
if (!$source || (empty($source['enabled']) && !$allowDisabled)) {
$source = ProSources::getFirstEnabledSource();
}
if (!$source) {
$source = ProSources::getDefaultSource();
}
self::$activeSource = $source;
self::$activeId = (string)($source['id'] ?? self::DEFAULT_ID);
return;
$cfg = SourcesConfig::getConfig();
$enabled = !empty($cfg['enabled']);
if ($enabled) {
$source = SourcesConfig::getSource($sessionId);
if (!$source || (empty($source['enabled']) && !$allowDisabled)) {
$source = SourcesConfig::getFirstEnabledSource();
}
if (!$source) {
$source = SourcesConfig::getDefaultSource();
}
self::$activeSource = $source;
self::$activeId = (string)($source['id'] ?? self::DEFAULT_ID);
return;
}
self::$activeId = self::DEFAULT_ID;
@@ -63,6 +62,7 @@ final class SourceContext
'type' => 'local',
'enabled' => true,
'readOnly' => false,
'disableTrash' => false,
'config' => [
'path' => (string)UPLOAD_DIR,
],
@@ -72,11 +72,7 @@ final class SourceContext
public static function sourcesEnabled(): bool
{
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE && class_exists('ProSources')) {
$cfg = ProSources::getConfig();
return !empty($cfg['enabled']);
}
return false;
return SourcesConfig::sourcesEnabled();
}
public static function getActiveId(): string
@@ -105,22 +101,22 @@ final class SourceContext
public static function getSourceById(?string $id): ?array
{
if (!self::sourcesEnabled() || !class_exists('ProSources')) {
if (!self::sourcesEnabled()) {
return null;
}
$id = trim((string)$id);
if ($id === '') {
return null;
}
return ProSources::getSource($id);
return SourcesConfig::getSource($id);
}
public static function listAllSources(): array
{
if (!self::sourcesEnabled() || !class_exists('ProSources')) {
if (!self::sourcesEnabled()) {
return [self::getActiveSource()];
}
$cfg = ProSources::getConfig();
$cfg = SourcesConfig::getConfig();
$sources = isset($cfg['sources']) && is_array($cfg['sources']) ? $cfg['sources'] : [];
return $sources ?: [self::getActiveSource()];
}
@@ -131,6 +127,19 @@ final class SourceContext
return !empty($src['readOnly']);
}
public static function isTrashDisabled(): bool
{
$src = self::getActiveSource();
if (!is_array($src)) {
return false;
}
if (!empty($src['disableTrash'])) {
return true;
}
$type = strtolower((string)($src['type'] ?? ''));
return $type === 'gdrive';
}
public static function uploadRoot(): string
{
return self::uploadRootForSource(self::getActiveSource());
+498
View File
@@ -0,0 +1,498 @@
<?php
declare(strict_types=1);
namespace FileRise\Storage;
use ProSources;
require_once PROJECT_ROOT . '/config/config.php';
final class SourcesConfig
{
private const FILE_NAME = 'sources.json';
private const DEFAULT_ID = 'local';
private const CORE_TYPES = ['local', 'webdav'];
private const ALL_TYPES = ['local', 's3', 'sftp', 'ftp', 'webdav', 'smb', 'gdrive', 'onedrive', 'dropbox'];
private static function proSourcesAvailable(): bool
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProSources')) {
return false;
}
if (!defined('FR_PRO_API_REQUIRE_SOURCES') || !function_exists('fr_pro_api_level_at_least')) {
return true;
}
return fr_pro_api_level_at_least((int)FR_PRO_API_REQUIRE_SOURCES);
}
public static function isProExtendedAvailable(): bool
{
return self::proSourcesAvailable();
}
public static function allowedTypes(): array
{
return self::proSourcesAvailable() ? self::ALL_TYPES : self::CORE_TYPES;
}
public static function isTypeAllowed(string $type): bool
{
$type = strtolower(trim($type));
return in_array($type, self::allowedTypes(), true);
}
public static function capabilityInfo(): array
{
$pro = self::proSourcesAvailable();
return [
'available' => true,
'proExtended' => $pro,
'allowedTypes' => self::allowedTypes(),
'coreTypes' => self::CORE_TYPES,
'proTypes' => array_values(array_diff(self::ALL_TYPES, self::CORE_TYPES)),
];
}
private static function withCapabilities(array $cfg): array
{
return array_merge($cfg, self::capabilityInfo());
}
private static function baseDir(): string
{
$base = defined('FR_PRO_BUNDLE_DIR') ? rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") : '';
if ($base === '') {
$base = rtrim((string)USERS_DIR, "/\\") . DIRECTORY_SEPARATOR . 'pro';
}
return $base;
}
private static function filePath(): string
{
$base = self::baseDir();
return $base !== '' ? ($base . DIRECTORY_SEPARATOR . self::FILE_NAME) : '';
}
private static function ensureDir(): void
{
$base = self::baseDir();
if ($base !== '' && !is_dir($base)) {
@mkdir($base, 0755, true);
}
}
private static function decryptSecret(string $value): string
{
$value = trim($value);
if ($value === '') return '';
$plain = decryptData($value, $GLOBALS['encryptionKey']);
return ($plain === false || $plain === null) ? '' : (string)$plain;
}
private static function encryptSecret(string $value): string
{
$value = (string)$value;
if ($value === '') return '';
return encryptData($value, $GLOBALS['encryptionKey']);
}
private static function defaultLocalSource(): array
{
return [
'id' => self::DEFAULT_ID,
'name' => 'Local',
'type' => 'local',
'enabled' => true,
'readOnly' => false,
'disableTrash' => false,
'config' => [
'path' => (string)UPLOAD_DIR,
],
];
}
private static function rawSourceMap(array $raw): array
{
$sourcesRaw = isset($raw['sources']) && is_array($raw['sources']) ? $raw['sources'] : [];
$out = [];
foreach ($sourcesRaw as $key => $src) {
if (!is_array($src)) {
continue;
}
if (!isset($src['id']) && is_string($key)) {
$src['id'] = $key;
}
$id = trim((string)($src['id'] ?? ''));
if ($id === '' || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id)) {
continue;
}
$out[$id] = $src;
}
return $out;
}
private static function coreConfigFromRaw(array $raw): array
{
$enabled = !empty($raw['enabled']);
$sources = [];
foreach (self::rawSourceMap($raw) as $src) {
$normalized = self::normalizeSourceStored($src);
if ($normalized) {
$sources[$normalized['id']] = $normalized;
}
}
if (!isset($sources[self::DEFAULT_ID])) {
$sources[self::DEFAULT_ID] = self::normalizeSourceStored(self::defaultLocalSource());
}
return [
'enabled' => $enabled,
'sources' => $sources,
];
}
private static function normalizeSourceStored(array $src): ?array
{
$id = trim((string)($src['id'] ?? ''));
if ($id === '' || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id)) {
return null;
}
$type = strtolower((string)($src['type'] ?? 'local'));
if (!self::isTypeAllowed($type)) {
return null;
}
$name = trim((string)($src['name'] ?? ''));
if ($name === '') {
$name = ($id === self::DEFAULT_ID) ? 'Local' : $id;
}
$enabled = !isset($src['enabled']) || $src['enabled'] !== false;
$readOnly = !empty($src['readOnly']);
$disableTrash = !empty($src['disableTrash']);
$config = isset($src['config']) && is_array($src['config']) ? $src['config'] : [];
if ($type === 'local') {
$path = trim((string)($config['path'] ?? $config['root'] ?? ''));
if ($path === '') {
$path = (string)UPLOAD_DIR;
}
$configStore = [
'path' => $path,
];
} else {
$baseUrl = trim((string)($config['baseUrl'] ?? $config['url'] ?? ''));
$username = trim((string)($config['username'] ?? ''));
if ($baseUrl === '' || $username === '') {
return null;
}
$root = trim((string)($config['root'] ?? $config['path'] ?? ''));
$verifyTls = !isset($config['verifyTls']) || $config['verifyTls'] !== false;
$configStore = [
'baseUrl' => $baseUrl,
'username' => $username,
'root' => $root,
'verifyTls' => $verifyTls ? 1 : 0,
];
if (isset($config['passwordEnc'])) {
$configStore['passwordEnc'] = (string)$config['passwordEnc'];
}
}
return [
'id' => $id,
'name' => $name,
'type' => $type,
'enabled' => $enabled,
'readOnly' => $readOnly,
'disableTrash' => $disableTrash,
'config' => $configStore,
];
}
private static function buildSourceView(array $src, bool $includeSecrets, bool $adminView): array
{
$out = [
'id' => $src['id'],
'name' => $src['name'],
'type' => $src['type'],
'enabled' => !empty($src['enabled']),
'readOnly' => !empty($src['readOnly']),
'disableTrash' => !empty($src['disableTrash']),
];
$cfg = isset($src['config']) && is_array($src['config']) ? $src['config'] : [];
if ($src['type'] === 'local') {
$out['config'] = [
'path' => (string)($cfg['path'] ?? ''),
];
return $out;
}
if ($src['type'] === 'webdav') {
$config = [
'baseUrl' => (string)($cfg['baseUrl'] ?? $cfg['url'] ?? ''),
'username' => (string)($cfg['username'] ?? ''),
'root' => (string)($cfg['root'] ?? $cfg['path'] ?? ''),
'verifyTls' => !isset($cfg['verifyTls']) || $cfg['verifyTls'] !== false,
];
$hasPassword = !empty($cfg['passwordEnc']);
if ($includeSecrets) {
$config['password'] = $hasPassword ? self::decryptSecret((string)$cfg['passwordEnc']) : '';
} elseif ($adminView) {
$config['hasPassword'] = $hasPassword;
}
$out['config'] = $config;
return $out;
}
$out['config'] = [];
return $out;
}
public static function sourcesEnabled(): bool
{
return !empty(self::getConfig()['enabled']);
}
public static function getConfig(): array
{
if (self::proSourcesAvailable()) {
return self::withCapabilities(ProSources::getConfig());
}
$cfg = self::coreConfigFromRaw(self::loadRaw());
$out = [
'enabled' => !empty($cfg['enabled']),
'sources' => [],
];
foreach ($cfg['sources'] as $src) {
$out['sources'][] = self::buildSourceView($src, true, false);
}
return self::withCapabilities($out);
}
public static function getPublicConfig(): array
{
if (self::proSourcesAvailable()) {
return self::withCapabilities(ProSources::getPublicConfig());
}
$cfg = self::coreConfigFromRaw(self::loadRaw());
$out = [
'enabled' => !empty($cfg['enabled']),
'sources' => [],
];
foreach ($cfg['sources'] as $src) {
if (empty($src['enabled'])) {
continue;
}
$view = self::buildSourceView($src, false, false);
unset($view['config']);
$out['sources'][] = $view;
}
return self::withCapabilities($out);
}
public static function getAdminList(): array
{
if (self::proSourcesAvailable()) {
return self::withCapabilities(ProSources::getAdminList());
}
$cfg = self::coreConfigFromRaw(self::loadRaw());
$out = [
'enabled' => !empty($cfg['enabled']),
'sources' => [],
];
foreach ($cfg['sources'] as $src) {
$out['sources'][] = self::buildSourceView($src, false, true);
}
return self::withCapabilities($out);
}
public static function getSource(?string $id): ?array
{
if (self::proSourcesAvailable()) {
return ProSources::getSource($id);
}
$cfg = self::coreConfigFromRaw(self::loadRaw());
$id = trim((string)$id);
if ($id !== '' && isset($cfg['sources'][$id])) {
return self::buildSourceView($cfg['sources'][$id], true, false);
}
return null;
}
public static function getFirstEnabledSource(): ?array
{
if (self::proSourcesAvailable()) {
return ProSources::getFirstEnabledSource();
}
$cfg = self::coreConfigFromRaw(self::loadRaw());
foreach ($cfg['sources'] as $src) {
if (!empty($src['enabled'])) {
return self::buildSourceView($src, true, false);
}
}
return null;
}
public static function getDefaultSource(): array
{
if (self::proSourcesAvailable()) {
return ProSources::getDefaultSource();
}
return self::buildSourceView(self::normalizeSourceStored(self::defaultLocalSource()), true, false);
}
public static function saveEnabled(bool $enabled): bool
{
if (self::proSourcesAvailable()) {
return ProSources::saveEnabled($enabled);
}
$raw = self::loadRaw();
$raw['enabled'] = $enabled ? true : false;
return self::saveRaw($raw);
}
public static function upsertSource(array $source): array
{
if (self::proSourcesAvailable()) {
return ProSources::upsertSource($source);
}
$id = isset($source['id']) ? trim((string)$source['id']) : '';
if ($id === '' || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id)) {
return ['ok' => false, 'error' => 'Invalid source id'];
}
$rawType = strtolower(trim((string)($source['type'] ?? 'local')));
if (!self::isTypeAllowed($rawType)) {
return ['ok' => false, 'error' => 'Source type requires FileRise Pro'];
}
$raw = self::loadRaw();
$sourceMap = self::rawSourceMap($raw);
$existingRaw = isset($sourceMap[$id]) && is_array($sourceMap[$id]) ? $sourceMap[$id] : null;
$existing = is_array($existingRaw) ? self::normalizeSourceStored($existingRaw) : null;
$normalized = self::normalizeSourceStored($source);
if (!$normalized) {
return ['ok' => false, 'error' => 'Invalid source configuration'];
}
if (!array_key_exists('disableTrash', $source) && $existing && isset($existing['disableTrash'])) {
$normalized['disableTrash'] = !empty($existing['disableTrash']);
}
if ($normalized['type'] === 'webdav') {
$cfgStore = $normalized['config'];
$password = isset($source['config']['password']) ? trim((string)$source['config']['password']) : '';
if ($password !== '') {
$cfgStore['passwordEnc'] = self::encryptSecret($password);
} elseif ($existingRaw && isset($existingRaw['config']) && is_array($existingRaw['config']) && isset($existingRaw['config']['passwordEnc'])) {
$cfgStore['passwordEnc'] = (string)$existingRaw['config']['passwordEnc'];
}
if (empty($cfgStore['passwordEnc'])) {
return ['ok' => false, 'error' => 'WebDAV requires a password'];
}
$normalized['config'] = $cfgStore;
}
$sourceMap[$id] = $normalized;
$raw['sources'] = $sourceMap;
if (!self::saveRaw($raw)) {
return ['ok' => false, 'error' => 'Failed to save sources'];
}
return ['ok' => true, 'source' => self::buildSourceView($normalized, false, true)];
}
public static function deleteSource(string $id): array
{
if (self::proSourcesAvailable()) {
return ProSources::deleteSource($id);
}
$id = trim($id);
if ($id === '' || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id)) {
return ['ok' => false, 'error' => 'Invalid source id'];
}
if ($id === self::DEFAULT_ID) {
return ['ok' => false, 'error' => 'Cannot delete the default Local source'];
}
$raw = self::loadRaw();
$sourceMap = self::rawSourceMap($raw);
if (!isset($sourceMap[$id])) {
return ['ok' => true];
}
unset($sourceMap[$id]);
$raw['sources'] = $sourceMap;
if (!self::saveRaw($raw)) {
return ['ok' => false, 'error' => 'Failed to delete source'];
}
return ['ok' => true];
}
private static function loadRaw(): array
{
$path = self::filePath();
if ($path === '' || !is_file($path)) {
return ['enabled' => false, 'sources' => []];
}
$raw = @file_get_contents($path);
$data = is_string($raw) ? json_decode($raw, true) : null;
return is_array($data) ? $data : ['enabled' => false, 'sources' => []];
}
private static function saveRaw(array $cfg): bool
{
$path = self::filePath();
if ($path === '') return false;
self::ensureDir();
$json = json_encode($cfg, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
if ($json === false) return false;
$tmp = $path . '.tmp';
if (@file_put_contents($tmp, $json, LOCK_EX) === false) {
return false;
}
if (!@rename($tmp, $path)) {
@unlink($tmp);
return false;
}
@chmod($path, 0644);
return true;
}
}
+16 -12
View File
@@ -8,6 +8,8 @@ use FileRise\Storage\StorageAdapterInterface;
use FileRise\Storage\LocalFsAdapter;
use FileRise\Storage\ReadOnlyAdapter;
use FileRise\Storage\SourceContext;
use FileRise\Storage\SourcesConfig;
use FileRise\Storage\WebDavAdapter;
use ProDropboxAdapter;
use ProFtpAdapter;
use ProGDriveAdapter;
@@ -15,7 +17,6 @@ use ProOneDriveAdapter;
use ProS3Adapter;
use ProSftpAdapter;
use ProSmbAdapter;
use ProSources;
use ProWebDavAdapter;
// src/lib/StorageFactory.php
@@ -23,6 +24,8 @@ use ProWebDavAdapter;
require_once PROJECT_ROOT . '/src/lib/StorageAdapterInterface.php';
require_once PROJECT_ROOT . '/src/lib/LocalFsAdapter.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
require_once PROJECT_ROOT . '/src/lib/SourcesConfig.php';
require_once PROJECT_ROOT . '/src/lib/WebDavAdapter.php';
require_once PROJECT_ROOT . '/src/lib/ReadOnlyAdapter.php';
final class StorageFactory
@@ -85,13 +88,15 @@ final class StorageFactory
$adapter = ProFtpAdapter::fromConfig($cfg, $root);
}
} elseif ($type === 'webdav') {
if (!class_exists('ProWebDavAdapter') && defined('FR_PRO_BUNDLE_DIR') && FR_PRO_BUNDLE_DIR) {
if (class_exists(WebDavAdapter::class)) {
$adapter = WebDavAdapter::fromConfig($cfg, $root);
} elseif (!class_exists('ProWebDavAdapter') && defined('FR_PRO_BUNDLE_DIR') && FR_PRO_BUNDLE_DIR) {
$adapterPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . DIRECTORY_SEPARATOR . 'ProWebDavAdapter.php';
if (is_file($adapterPath)) {
require_once $adapterPath;
}
}
if (class_exists('ProWebDavAdapter')) {
if (!$adapter && class_exists('ProWebDavAdapter')) {
$adapter = ProWebDavAdapter::fromConfig($cfg, $root);
}
} elseif ($type === 'smb') {
@@ -155,16 +160,12 @@ final class StorageFactory
return self::createDefaultAdapter();
}
if (!class_exists('ProSources')) {
return self::createDefaultAdapter();
}
$source = ProSources::getSource($sourceId);
$source = SourcesConfig::getSource($sourceId);
if (!$source || empty($source['enabled'])) {
$source = ProSources::getFirstEnabledSource();
$source = SourcesConfig::getFirstEnabledSource();
}
if (!$source) {
$source = ProSources::getDefaultSource();
$source = SourcesConfig::getDefaultSource();
}
$type = strtolower((string)($source['type'] ?? 'local'));
@@ -208,13 +209,16 @@ final class StorageFactory
$adapter = ProFtpAdapter::fromConfig($source['config'] ?? [], $root);
}
} elseif ($type === 'webdav') {
if (!class_exists('ProWebDavAdapter') && defined('FR_PRO_BUNDLE_DIR') && FR_PRO_BUNDLE_DIR) {
if (class_exists(WebDavAdapter::class)) {
$root = SourceContext::uploadRootForId((string)($source['id'] ?? ''));
$adapter = WebDavAdapter::fromConfig($source['config'] ?? [], $root);
} elseif (!class_exists('ProWebDavAdapter') && defined('FR_PRO_BUNDLE_DIR') && FR_PRO_BUNDLE_DIR) {
$adapterPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . DIRECTORY_SEPARATOR . 'ProWebDavAdapter.php';
if (is_file($adapterPath)) {
require_once $adapterPath;
}
}
if (class_exists('ProWebDavAdapter')) {
if (!$adapter && class_exists('ProWebDavAdapter')) {
$root = SourceContext::uploadRootForId((string)($source['id'] ?? ''));
$adapter = ProWebDavAdapter::fromConfig($source['config'] ?? [], $root);
}
+496
View File
@@ -0,0 +1,496 @@
<?php
declare(strict_types=1);
namespace FileRise\Storage;
use Sabre\DAV\Client;
use Sabre\DAV\Xml\Property\ResourceType;
use Throwable;
require_once PROJECT_ROOT . '/src/lib/StorageAdapterInterface.php';
final class WebDavAdapter implements StorageAdapterInterface
{
private const WRITE_FALLBACK_MAX_BYTES = 5242880; // 5 MB
private Client $client;
private string $baseUri;
private string $basePath;
private string $username;
private string $password;
private string $localRoot;
private bool $verifyTls;
private int $timeout;
private string $lastError = '';
/** @var array<string, array{ts:int, children:array<string, array{type:string,size:int,mtime:int,mode:int}>>> */
private array $listCache = [];
private int $listCacheTtl = 5;
private function __construct(
string $baseUri,
string $username,
string $password,
string $localRoot,
bool $verifyTls,
int $timeout
) {
$this->baseUri = rtrim($baseUri, '/');
$basePath = (string)(parse_url($this->baseUri, PHP_URL_PATH) ?? '');
$this->basePath = rtrim($basePath, '/');
$this->username = $username;
$this->password = $password;
$this->localRoot = rtrim(str_replace('\\', '/', $localRoot), '/');
$this->verifyTls = $verifyTls;
$this->timeout = $timeout;
$settings = [
'baseUri' => $this->baseUri . '/',
'userName' => $username,
'password' => $password,
];
$this->client = new Client($settings);
if (!$verifyTls) {
$this->client->addCurlSetting(CURLOPT_SSL_VERIFYPEER, false);
$this->client->addCurlSetting(CURLOPT_SSL_VERIFYHOST, 0);
}
if ($timeout > 0) {
$this->client->addCurlSetting(CURLOPT_TIMEOUT, $timeout);
$this->client->addCurlSetting(CURLOPT_CONNECTTIMEOUT, min(10, $timeout));
}
}
public static function fromConfig(array $cfg, string $root): ?self
{
self::ensureSabreLoaded();
$baseUrl = trim((string)($cfg['baseUrl'] ?? $cfg['url'] ?? ''));
$username = trim((string)($cfg['username'] ?? ''));
if ($baseUrl === '' || $username === '') {
return null;
}
$password = (string)($cfg['password'] ?? '');
$rootPath = trim((string)($cfg['root'] ?? $cfg['path'] ?? ''));
$verifyTls = !isset($cfg['verifyTls']) || $cfg['verifyTls'] !== false;
$timeout = (int)($cfg['timeout'] ?? 20);
if ($timeout <= 0) {
$timeout = 20;
}
$baseUri = self::buildBaseUri($baseUrl, $rootPath);
return new self($baseUri, $username, $password, $root, $verifyTls, $timeout);
}
public function isLocal(): bool
{
return false;
}
public function testConnection(): bool
{
$status = $this->request('PROPFIND', $this->buildUrlForRelative(''), null, ['Depth' => '0']);
if ($status >= 200 && $status < 300) {
$this->lastError = '';
return true;
}
if ($status === 401 || $status === 403) {
$this->lastError = 'Auth failed (HTTP ' . $status . ')';
} elseif ($status > 0) {
$this->lastError = 'HTTP ' . $status;
} elseif ($this->lastError === '') {
$this->lastError = 'Connection failed';
}
return false;
}
public function getLastError(): string
{
return trim($this->lastError);
}
public function list(string $path): array
{
$props = $this->propFind($path, 1);
if (!$props) return [];
$parentRel = trim($this->relativePath($path), '/');
$items = [];
$children = [];
foreach ($props as $href => $prop) {
$rel = trim($this->hrefToRelative((string)$href), '/');
if ($rel === '' || $rel === $parentRel) continue;
$name = basename($rel);
if ($name === '' || $name === '.' || $name === '..') continue;
$items[] = $name;
$children[$name] = $this->propsToStat($prop);
}
$this->storeListCache($parentRel, $children);
return array_values(array_unique($items));
}
public function stat(string $path): ?array
{
$rel = $this->relativePath($path);
if ($rel !== '') {
$parentRel = trim(str_replace('\\', '/', dirname($rel)), '/');
if ($parentRel === '.' || $parentRel === '') {
$parentRel = '';
}
$base = basename($rel);
$cached = $this->getListCache($parentRel);
if ($cached !== null && isset($cached[$base])) {
return $cached[$base];
}
}
$props = $this->propFind($path, 0);
if (!$props) return null;
return $this->propsToStat($props);
}
public function read(string $path, ?int $length = null, int $offset = 0): string|false
{
$stream = $this->openReadStream($path, $length, $offset);
if ($stream === false) {
return false;
}
if (is_resource($stream)) {
$data = ($length !== null)
? stream_get_contents($stream, $length)
: stream_get_contents($stream);
fclose($stream);
return ($data === false) ? false : $data;
}
if (is_object($stream) && method_exists($stream, 'read')) {
$data = $stream->read($length ?? 0);
if (method_exists($stream, 'close')) {
$stream->close();
}
return $data;
}
return false;
}
public function openReadStream(string $path, ?int $length = null, int $offset = 0)
{
$url = $this->buildUrlForPath($path);
if ($url === '') return false;
$headers = [];
if ($this->username !== '') {
$headers[] = 'Authorization: Basic ' . base64_encode($this->username . ':' . $this->password);
}
if ($offset > 0 || $length !== null) {
$end = ($length !== null && $length > 0) ? ($offset + $length - 1) : '';
$headers[] = 'Range: bytes=' . $offset . '-' . $end;
}
$opts = [
'http' => [
'method' => 'GET',
'header' => implode("\r\n", $headers),
'ignore_errors' => true,
'timeout' => $this->timeout,
],
];
if (!$this->verifyTls) {
$opts['ssl'] = [
'verify_peer' => false,
'verify_peer_name' => false,
];
}
$context = stream_context_create($opts);
$fp = @fopen($url, 'rb', false, $context);
return $fp ?: false;
}
public function write(string $path, string $data, int $flags = 0): bool
{
if (!$this->ensureParentExists($path)) return false;
$url = $this->buildUrlForPath($path);
if ($url === '') return false;
$status = $this->request('PUT', $url, $data, []);
return $status >= 200 && $status < 300;
}
public function writeStream(string $path, $stream, ?int $length = null, ?string $mimeType = null): bool
{
if (!is_resource($stream)) return false;
if (!$this->ensureParentExists($path)) return false;
$url = $this->buildUrlForPath($path);
if ($url === '') return false;
$headers = [];
if ($mimeType) {
$headers['Content-Type'] = $mimeType;
}
$meta = @stream_get_meta_data($stream);
$seekable = is_array($meta) && !empty($meta['seekable']);
if ($length === null) {
$stat = @fstat($stream);
if (is_array($stat) && isset($stat['size'])) {
$length = (int)$stat['size'];
}
}
if ($length !== null && $length >= 0) {
$headers['Content-Length'] = (string)$length;
}
if ($seekable) {
@rewind($stream);
}
$status = $this->request('PUT', $url, $stream, $headers);
if ($status >= 200 && $status < 300) {
return true;
}
if ($seekable && $length !== null && $length <= self::WRITE_FALLBACK_MAX_BYTES) {
@rewind($stream);
$data = stream_get_contents($stream);
if ($data !== false) {
$status = $this->request('PUT', $url, $data, $headers);
return $status >= 200 && $status < 300;
}
}
return false;
}
public function move(string $from, string $to): bool
{
$src = $this->buildUrlForPath($from);
$dst = $this->buildUrlForPath($to);
if ($src === '' || $dst === '') return false;
$status = $this->request('MOVE', $src, null, [
'Destination' => $dst,
'Overwrite' => 'T',
]);
return $status >= 200 && $status < 300;
}
public function copy(string $from, string $to): bool
{
$src = $this->buildUrlForPath($from);
$dst = $this->buildUrlForPath($to);
if ($src === '' || $dst === '') return false;
$status = $this->request('COPY', $src, null, [
'Destination' => $dst,
'Overwrite' => 'T',
]);
return $status >= 200 && $status < 300;
}
public function delete(string $path): bool
{
$url = $this->buildUrlForPath($path);
if ($url === '') return false;
$status = $this->request('DELETE', $url, null, []);
return $status >= 200 && $status < 300;
}
public function mkdir(string $path, int $mode = 0775, bool $recursive = true): bool
{
$rel = trim($this->relativePath($path), '/');
if ($rel === '') return true;
$parts = array_values(array_filter(explode('/', $rel), fn($p) => $p !== ''));
if (!$parts) return true;
$acc = '';
foreach ($parts as $part) {
$acc = ($acc === '') ? $part : ($acc . '/' . $part);
if (!$recursive && $acc !== $rel) continue;
$url = $this->buildUrlForRelative($acc);
$status = $this->request('MKCOL', $url, null, []);
if ($status >= 200 && $status < 300) {
continue;
}
if ($status === 405) {
continue;
}
return false;
}
return true;
}
private static function ensureSabreLoaded(): void
{
if (!class_exists(Client::class)) {
$autoload = PROJECT_ROOT . '/vendor/autoload.php';
if (is_file($autoload)) {
require_once $autoload;
}
}
}
private static function buildBaseUri(string $baseUrl, string $rootPath): string
{
$base = rtrim($baseUrl, '/');
$root = trim($rootPath, '/');
if ($root === '') {
return $base;
}
$encoded = self::encodePath($root);
return $base . '/' . $encoded;
}
private static function encodePath(string $path): string
{
$trimmed = trim($path, '/');
if ($trimmed === '') return '';
$parts = array_map('rawurlencode', explode('/', $trimmed));
return implode('/', $parts);
}
private function buildUrlForRelative(string $rel): string
{
$rel = trim($rel, '/');
if ($rel === '') {
return $this->baseUri;
}
return $this->baseUri . '/' . self::encodePath($rel);
}
private function buildUrlForPath(string $path): string
{
$rel = $this->relativePath($path);
return $this->buildUrlForRelative($rel);
}
private function relativePath(string $path): string
{
$p = str_replace('\\', '/', $path);
$root = $this->localRoot;
if ($root !== '' && str_starts_with($p, $root)) {
$p = substr($p, strlen($root));
}
return ltrim($p, '/');
}
private function hrefToRelative(string $href): string
{
$path = (string)(parse_url($href, PHP_URL_PATH) ?? '');
$path = rawurldecode($path);
$base = $this->basePath;
if ($base !== '' && $base !== '/' && str_starts_with($path, $base)) {
$path = substr($path, strlen($base));
}
return ltrim($path, '/');
}
private function propFind(string $path, int $depth): array
{
$url = $this->buildUrlForPath($path);
if ($url === '') return [];
try {
return $this->client->propFind($url, [
'{DAV:}displayname',
'{DAV:}resourcetype',
'{DAV:}getcontentlength',
'{DAV:}getlastmodified',
], $depth);
} catch (Throwable $e) {
return [];
}
}
private function getListCache(string $rel): ?array
{
if (!isset($this->listCache[$rel])) {
return null;
}
$entry = $this->listCache[$rel];
$ts = (int)($entry['ts'] ?? 0);
if ($ts <= 0 || (time() - $ts) > $this->listCacheTtl) {
unset($this->listCache[$rel]);
return null;
}
$children = $entry['children'] ?? null;
return is_array($children) ? $children : null;
}
private function storeListCache(string $rel, array $children): void
{
$this->listCache[$rel] = [
'ts' => time(),
'children' => $children,
];
}
private function propsToStat(array $props): array
{
$type = 'file';
$resType = $props['{DAV:}resourcetype'] ?? null;
if ($this->isCollection($resType)) {
$type = 'dir';
}
$size = isset($props['{DAV:}getcontentlength']) ? (int)$props['{DAV:}getcontentlength'] : 0;
$mtimeRaw = (string)($props['{DAV:}getlastmodified'] ?? '');
$mtime = $mtimeRaw !== '' ? (int)(strtotime($mtimeRaw) ?: 0) : 0;
return [
'type' => $type,
'size' => $size,
'mtime' => $mtime,
'mode' => 0,
];
}
private function request(string $method, string $url, $body = null, array $headers = []): int
{
try {
$resp = $this->client->request($method, $url, $body, $headers);
$status = (int)($resp['statusCode'] ?? 0);
if ($status < 200 || $status >= 300) {
$msg = trim((string)($resp['body'] ?? ''));
if ($msg !== '') {
$msg = preg_replace('/\\s+/', ' ', $msg);
if (strlen($msg) > 240) {
$msg = substr($msg, 0, 240) . '...';
}
}
$this->lastError = $msg !== '' ? ('HTTP ' . $status . ': ' . $msg) : ('HTTP ' . $status);
} else {
$this->lastError = '';
}
return $status;
} catch (Throwable $e) {
$msg = trim($e->getMessage());
if ($msg !== '') {
$msg = preg_replace('/(https?:\\/\\/)([^\\s@]+@)/i', '$1', $msg);
$this->lastError = $msg;
} else {
$this->lastError = 'WebDAV request failed';
}
return 0;
}
}
private function isCollection($value): bool
{
if ($value instanceof ResourceType) {
return $value->is('{DAV:}collection');
}
if (is_array($value)) {
return in_array('{DAV:}collection', $value, true);
}
if (is_string($value)) {
return stripos($value, 'collection') !== false;
}
return false;
}
private function ensureParentExists(string $path): bool
{
$rel = trim($this->relativePath($path), '/');
if ($rel === '') return true;
$parent = trim(str_replace('\\', '/', dirname($rel)), '/');
if ($parent === '' || $parent === '.') {
return true;
}
$parentPath = $this->localRoot !== '' ? ($this->localRoot . '/' . $parent) : $parent;
return $this->mkdir($parentPath, 0775, true);
}
}
+4 -3
View File
@@ -5,13 +5,14 @@ declare(strict_types=1);
namespace FileRise\Support;
use FileRise\Storage\SourceContext;
use FileRise\Storage\SourcesConfig;
use RuntimeException;
use ProSources;
// src/lib/ACL.php
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
require_once PROJECT_ROOT . '/src/lib/SourcesConfig.php';
class ACL
{
@@ -80,8 +81,8 @@ class ACL
{
$user = (string)$user;
if (class_exists('SourceContext') && SourceContext::sourcesEnabled() && class_exists('ProSources')) {
$cfg = ProSources::getConfig();
if (class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
$cfg = SourcesConfig::getConfig();
$sources = isset($cfg['sources']) && is_array($cfg['sources']) ? $cfg['sources'] : [];
$changedAny = false;
foreach ($sources as $src) {
+11
View File
@@ -0,0 +1,11 @@
<?php
require_once __DIR__ . '/../FileRise/Storage/SourcesConfig.php';
$original = '\\FileRise\\Storage\\SourcesConfig';
$alias = 'SourcesConfig';
if (!class_exists($alias, false) && !interface_exists($alias, false)) {
if (class_exists($original, false) || interface_exists($original, false)) {
class_alias($original, $alias);
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
require_once __DIR__ . '/../FileRise/Storage/WebDavAdapter.php';
$original = '\\FileRise\\Storage\\WebDavAdapter';
$alias = 'WebDavAdapter';
if (!class_exists($alias, false) && !interface_exists($alias, false)) {
if (class_exists($original, false) || interface_exists($original, false)) {
class_alias($original, $alias);
}
}