From ab2f519cbe57edb7e8faaf621b2d7528250b5c8b Mon Sep 17 00:00:00 2001 From: Ryan Date: Sun, 15 Feb 2026 02:33:27 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 74 ++ docs/wiki/Admin-Panel.md | 2 +- docs/wiki/Pro-Gateway-Shares.md | 68 ++ docs/wiki/Pro-Sources.md | 4 +- docs/wiki/Sources-Onboarding.md | 1 + docs/wiki/_Sidebar.md | 1 + docs/wiki/onlyoffice.md | 4 +- public/.htaccess | 6 +- public/api/file/deleteFiles.php | 6 +- public/api/pro/gateways/delete.php | 57 + public/api/pro/gateways/list.php | 65 + public/api/pro/gateways/save.php | 93 ++ public/api/pro/gateways/test.php | 215 ++++ public/api/pro/sources/delete.php | 9 +- public/api/pro/sources/list.php | 14 +- public/api/pro/sources/save.php | 15 +- public/api/pro/sources/select.php | 11 +- public/api/pro/sources/test.php | 9 +- public/api/pro/sources/visible.php | 27 +- public/css/styles.css | 2 + public/index.html | 2 +- public/js/adminOnlyOffice.js | 6 +- public/js/adminPanel.js | 1072 +++++++++++++++-- public/js/fileListView.js | 14 + public/js/folderManager.js | 24 + public/js/i18n.js | 61 +- public/js/pretheme.js | 53 + public/js/sourceManager.js | 38 +- src/FileRise/Domain/AdminModel.php | 12 +- src/FileRise/Domain/FileModel.php | 19 +- .../Http/Controllers/AdminController.php | 205 +++- src/FileRise/Storage/SourceContext.php | 57 +- src/FileRise/Storage/SourcesConfig.php | 498 ++++++++ src/FileRise/Storage/StorageFactory.php | 28 +- src/FileRise/Storage/WebDavAdapter.php | 496 ++++++++ src/FileRise/Support/ACL.php | 7 +- src/lib/SourcesConfig.php | 11 + src/lib/WebDavAdapter.php | 11 + 38 files changed, 3050 insertions(+), 247 deletions(-) create mode 100644 docs/wiki/Pro-Gateway-Shares.md create mode 100644 public/api/pro/gateways/delete.php create mode 100644 public/api/pro/gateways/list.php create mode 100644 public/api/pro/gateways/save.php create mode 100644 public/api/pro/gateways/test.php create mode 100644 public/js/pretheme.js create mode 100644 src/FileRise/Storage/SourcesConfig.php create mode 100644 src/FileRise/Storage/WebDavAdapter.php create mode 100644 src/lib/SourcesConfig.php create mode 100644 src/lib/WebDavAdapter.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f4934fd..00bad2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)` diff --git a/docs/wiki/Admin-Panel.md b/docs/wiki/Admin-Panel.md index ccd9763..8c06da9 100644 --- a/docs/wiki/Admin-Panel.md +++ b/docs/wiki/Admin-Panel.md @@ -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 diff --git a/docs/wiki/Pro-Gateway-Shares.md b/docs/wiki/Pro-Gateway-Shares.md new file mode 100644 index 0000000..16492da --- /dev/null +++ b/docs/wiki/Pro-Gateway-Shares.md @@ -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. diff --git a/docs/wiki/Pro-Sources.md b/docs/wiki/Pro-Sources.md index 496b1c9..3680078 100644 --- a/docs/wiki/Pro-Sources.md +++ b/docs/wiki/Pro-Sources.md @@ -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. --- diff --git a/docs/wiki/Sources-Onboarding.md b/docs/wiki/Sources-Onboarding.md index 2bea20d..43a320f 100644 --- a/docs/wiki/Sources-Onboarding.md +++ b/docs/wiki/Sources-Onboarding.md @@ -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 diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index b259553..f02bf90 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -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 diff --git a/docs/wiki/onlyoffice.md b/docs/wiki/onlyoffice.md index 8398162..1345476 100644 --- a/docs/wiki/onlyoffice.md +++ b/docs/wiki/onlyoffice.md @@ -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; ``` --- diff --git a/public/.htaccess b/public/.htaccess index 713dd98..4de0b16 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -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'" # ---------------- Caching ---------------- diff --git a/public/api/file/deleteFiles.php b/public/api/file/deleteFiles.php index 77a9388..1ec739e 100644 --- a/public/api/file/deleteFiles.php +++ b/public/api/file/deleteFiles.php @@ -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(); \ No newline at end of file +$fileController->deleteFiles(); diff --git a/public/api/pro/gateways/delete.php b/public/api/pro/gateways/delete.php new file mode 100644 index 0000000..3e3b56d --- /dev/null +++ b/public/api/pro/gateways/delete.php @@ -0,0 +1,57 @@ + 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); +} + diff --git a/public/api/pro/gateways/list.php b/public/api/pro/gateways/list.php new file mode 100644 index 0000000..f091b86 --- /dev/null +++ b/public/api/pro/gateways/list.php @@ -0,0 +1,65 @@ + 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); +} diff --git a/public/api/pro/gateways/save.php b/public/api/pro/gateways/save.php new file mode 100644 index 0000000..32957c3 --- /dev/null +++ b/public/api/pro/gateways/save.php @@ -0,0 +1,93 @@ + 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); +} diff --git a/public/api/pro/gateways/test.php b/public/api/pro/gateways/test.php new file mode 100644 index 0000000..c0d58e6 --- /dev/null +++ b/public/api/pro/gateways/test.php @@ -0,0 +1,215 @@ + 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); +} diff --git a/public/api/pro/sources/delete.php b/public/api/pro/sources/delete.php index f55d50e..075347f 100644 --- a/public/api/pro/sources/delete.php +++ b/public/api/pro/sources/delete.php @@ -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']); diff --git a/public/api/pro/sources/list.php b/public/api/pro/sources/list.php index acd79bc..4367771 100644 --- a/public/api/pro/sources/list.php +++ b/public/api/pro/sources/list.php @@ -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); diff --git a/public/api/pro/sources/save.php b/public/api/pro/sources/save.php index 5188230..316a147 100644 --- a/public/api/pro/sources/save.php +++ b/public/api/pro/sources/save.php @@ -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 { diff --git a/public/api/pro/sources/select.php b/public/api/pro/sources/select.php index be2f0da..a30771a 100644 --- a/public/api/pro/sources/select.php +++ b/public/api/pro/sources/select.php @@ -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']); diff --git a/public/api/pro/sources/test.php b/public/api/pro/sources/test.php index d144805..6383894 100644 --- a/public/api/pro/sources/test.php +++ b/public/api/pro/sources/test.php @@ -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']); diff --git a/public/api/pro/sources/visible.php b/public/api/pro/sources/visible.php index 7de4c3b..f46c601 100644 --- a/public/api/pro/sources/visible.php +++ b/public/api/pro/sources/visible.php @@ -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); diff --git a/public/css/styles.css b/public/css/styles.css index b6bb397..cab4a43 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -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;} diff --git a/public/index.html b/public/index.html index 9da721b..b107b5d 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,7 @@ FileRise - + diff --git a/public/js/adminOnlyOffice.js b/public/js/adminOnlyOffice.js index 7a66b8c..a8420db 100644 --- a/public/js/adminOnlyOffice.js +++ b/public/js/adminOnlyOffice.js @@ -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'; ` + diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js index a135024..4cf6cf7 100644 --- a/public/js/adminPanel.js +++ b/public/js/adminPanel.js @@ -26,7 +26,7 @@ export { const version = window.APP_VERSION || "dev"; // Hard-coded *FOR NOW* latest FileRise Pro bundle version for UI hints only. // Update this when I cut a new Pro ZIP. -const PRO_LATEST_BUNDLE_VERSION = 'v1.7.0'; +const PRO_LATEST_BUNDLE_VERSION = 'v1.8.0'; const PRO_API_LEVELS = { diskUsage: 2, search: 3, @@ -39,6 +39,8 @@ const PRO_API_MIN_VERSION_LABELS = { audit: '1.4.0', sources: '1.5.0' }; +const CORE_SOURCE_TYPES = ['local', 'webdav']; +const ALL_SOURCE_TYPES = ['local', 's3', 'sftp', 'ftp', 'webdav', 'smb', 'gdrive', 'onedrive', 'dropbox']; const CORE_REQUIRED_PRO_API_LEVEL = Math.max(...Object.values(PRO_API_LEVELS)); const DEFAULT_HEADER_TITLE = 'FileRise'; const PRO_DEFAULT_HEADER_TITLE = 'FileRise Pro'; @@ -1171,112 +1173,37 @@ function initVirusLogSection({ isPro }) { })(); } -function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSourcesApiOk }) { +function initSourcesSection({ + modalEl, + sourcesEnabled, + sourcesCfg, + isPro, + proSourcesApiOk, + sourcesAllowedTypes = CORE_SOURCE_TYPES, + sourcesProExtended = false +}) { const container = document.getElementById('sourcesContent'); if (!container || container.__initialized) return; container.__initialized = true; - if (!isPro) { - const isDark = document.body.classList.contains('dark-mode'); - const overlayBg = isDark ? 'rgba(15, 23, 42, 0.9)' : 'rgba(255, 255, 255, 0.92)'; - const overlayText = isDark ? '#f8fafc' : '#111827'; - const overlaySubtext = isDark ? 'rgba(226, 232, 240, 0.9)' : '#4b5563'; - const overlayBorder = isDark ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)'; - const overlayShadow = isDark ? '0 10px 28px rgba(0,0,0,0.5)' : '0 10px 24px rgba(0,0,0,0.18)'; - const title = tf('sources_pro_locked_title', 'Sources are a Pro feature'); - const body = tf( - 'sources_pro_locked_body', - 'Connect remote storage and manage it like local — switch sources, move/copy between them, and keep separate trash per source. Upgrade to FileRise Pro to add S3, SFTP, FTP, WebDAV, SMB, additional Local, Google Drive, Dropbox and OneDrive sources.' - ); - const help = tf('sources_help', 'Sources are separate roots; users only see sources they can access.'); - const adapterHint = tf( - 'sources_adapter_hint', - 'Adapters: local, S3, SFTP, FTP, WebDAV, SMB, Google Drive, Dropbox, OneDrive.' - ); - - container.innerHTML = ` -
-
-
-
- - ${escapeHTML(tf('sources', 'Sources'))} - Pro - -
${escapeHTML(help)}
-
-
-
-
-
-
-
-
- - -
- ${escapeHTML(help)} -
- -
- - -
- -
- - - - - - - - - - - - - -
${escapeHTML(tf('source_name', 'Source Name'))}${escapeHTML(tf('source_type', 'Type'))}${escapeHTML(tf('status', 'Status'))}
Locallocal${escapeHTML(tf('enabled', 'Enabled'))}
Archives3${escapeHTML(tf('disabled', 'Disabled'))}
Mediasmb${escapeHTML(tf('enabled', 'Enabled'))}
-
- -
- ${escapeHTML(adapterHint)} -
-
-
- -
-
-
- Pro - ${escapeHTML(title)} -
-
- ${escapeHTML(body)} -
-
-
-
-
- `; - return; - } - if (!proSourcesApiOk) { - const msg = tf( - 'sources_pro_bundle_outdated', - `Please upgrade to FileRise Pro v${PRO_API_MIN_VERSION_LABELS.sources}+ to use Sources.` - ); - container.innerHTML = `
${escapeHTML(msg)}
`; + if (sourcesCfg && sourcesCfg.available === false) { + container.innerHTML = `
${escapeHTML(tf('sources_unavailable', 'Sources are unavailable on this build.'))}
`; return; } + const normalizedAllowedTypes = Array.isArray(sourcesAllowedTypes) + ? sourcesAllowedTypes.map(v => String(v || '').trim().toLowerCase()).filter(Boolean) + : []; + const filteredAllowed = normalizedAllowedTypes.filter(type => ALL_SOURCE_TYPES.includes(type)); + const effectiveAllowedTypes = filteredAllowed.length ? filteredAllowed : CORE_SOURCE_TYPES.slice(); + const allowedTypeSet = new Set(effectiveAllowedTypes); + const limitedAdaptersHint = (!sourcesProExtended && effectiveAllowedTypes.length < ALL_SOURCE_TYPES.length) + ? tf( + 'sources_core_types_hint', + 'Core supports Local and WebDAV sources. Upgrade to FileRise Pro for S3, SFTP, FTP, SMB, Google Drive, Dropbox, and OneDrive.' + ) + : ''; + container.innerHTML = `
@@ -1329,8 +1256,11 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou + + ${escapeHTML(limitedAdaptersHint)} +
-
+
+
+ + +
+ + ${tf('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).')} +
- +
@@ -1678,6 +1617,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou const sourceTypeEl = container.querySelector('#sourceType'); const sourceEnabledEl = container.querySelector('#sourceEnabled'); const sourceReadOnlyEl = container.querySelector('#sourceReadOnly'); + const sourceDisableTrashEl = container.querySelector('#sourceDisableTrash'); const localPathEl = container.querySelector('#sourceLocalPath'); const s3BucketEl = container.querySelector('#sourceS3Bucket'); const s3RegionEl = container.querySelector('#sourceS3Region'); @@ -1731,7 +1671,27 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou const dropboxRootPathEl = container.querySelector('#sourceDropboxRootPath'); const dropboxTeamMemberIdEl = container.querySelector('#sourceDropboxTeamMemberId'); const dropboxRootNamespaceIdEl = container.querySelector('#sourceDropboxRootNamespaceId'); + const sourceTypeHintEl = container.querySelector('#sourceTypeAvailabilityHint'); const typeBlocks = Array.from(container.querySelectorAll('.sources-type-block')); + if (sourceTypeEl) { + Array.from(sourceTypeEl.options).forEach(opt => { + const type = String(opt.value || '').trim().toLowerCase(); + if (!allowedTypeSet.has(type)) { + opt.remove(); + } + }); + if (!sourceTypeEl.options.length) { + const opt = document.createElement('option'); + opt.value = 'local'; + opt.textContent = 'local'; + sourceTypeEl.appendChild(opt); + allowedTypeSet.add('local'); + } + } + + if (sourceTypeHintEl && !limitedAdaptersHint) { + sourceTypeHintEl.hidden = true; + } let editingId = ''; let state = { @@ -1816,11 +1776,29 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou }; const setType = (type) => { - const t = (type || 'local').toLowerCase(); + let t = (type || 'local').toLowerCase(); + if (!allowedTypeSet.has(t)) { + t = sourceTypeEl?.options?.[0]?.value + ? String(sourceTypeEl.options[0].value).toLowerCase() + : 'local'; + } sourceTypeEl.value = t; typeBlocks.forEach(block => { block.hidden = block.getAttribute('data-type') !== t; }); + if (sourceDisableTrashEl) { + const forcePermanentDelete = (t === 'gdrive'); + sourceDisableTrashEl.disabled = forcePermanentDelete; + if (forcePermanentDelete) { + sourceDisableTrashEl.checked = true; + sourceDisableTrashEl.title = tf( + 'source_gdrive_trash_note', + 'Trash is not supported on Google Drive sources; deletes are permanent.' + ); + } else { + sourceDisableTrashEl.removeAttribute('title'); + } + } }; const resetSecrets = () => { @@ -1857,6 +1835,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou if (sourceNameEl) sourceNameEl.value = ''; if (sourceEnabledEl) sourceEnabledEl.checked = true; if (sourceReadOnlyEl) sourceReadOnlyEl.checked = false; + if (sourceDisableTrashEl) sourceDisableTrashEl.checked = false; if (localPathEl) localPathEl.value = ''; if (s3BucketEl) s3BucketEl.value = ''; if (s3RegionEl) s3RegionEl.value = ''; @@ -1935,6 +1914,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou if (sourceNameEl) sourceNameEl.value = src.name || ''; if (sourceEnabledEl) sourceEnabledEl.checked = src.enabled !== false; if (sourceReadOnlyEl) sourceReadOnlyEl.checked = !!src.readOnly; + if (sourceDisableTrashEl) sourceDisableTrashEl.checked = !!src.disableTrash; const type = (src.type || 'local').toLowerCase(); setType(type); const cfg = src.config || {}; @@ -2012,7 +1992,8 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou const enabledText = (src.enabled === false) ? tf('disabled', 'Disabled') : tf('enabled', 'Enabled'); const flags = [ enabledText, - (src.readOnly ? t('read_only') : '') + (src.readOnly ? t('read_only') : ''), + (src.disableTrash ? tf('source_trash_disabled_badge', 'Trash off') : '') ].filter(Boolean); const badges = flags.map(flag => `${esc(flag)}`).join(''); const badgeWrap = badges ? `${badges}` : ''; @@ -2058,7 +2039,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou }; const loadSources = async () => { - if (!isPro || !proSourcesApiOk || window.__FR_IS_PRO === false) { + if (sourcesCfg && sourcesCfg.available === false) { return; } setStatus(tf('loading', 'Loading...')); @@ -2235,6 +2216,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou const type = (sourceTypeEl?.value || '').trim().toLowerCase(); const enabled = !!sourceEnabledEl?.checked; const readOnly = !!sourceReadOnlyEl?.checked; + const disableTrash = !!sourceDisableTrashEl?.checked; if (!id || !name || !type) { showToast(t('admin_source_required_fields'), 'error'); @@ -2419,7 +2401,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou } const payload = { - source: { id, name, type, enabled, readOnly, config } + source: { id, name, type, enabled, readOnly, disableTrash, config } }; const existingIdx = (state.sources || []).findIndex(s => String(s.id || '') === id); @@ -2427,7 +2409,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou const optimisticEnabled = isNewSource ? false : ((state.sources[existingIdx]?.enabled) !== false); - const optimisticSource = { id, name, type, enabled: optimisticEnabled, readOnly }; + const optimisticSource = { id, name, type, enabled: optimisticEnabled, readOnly, disableTrash }; if (isNewSource) { state.sources = [...(state.sources || []), optimisticSource]; @@ -2470,7 +2452,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou } else if (enabled) { const testOk = await runSourceTest({ id }); if (testOk === false) { - const disablePayload = { source: { id, name, type, enabled: false, readOnly, config } }; + const disablePayload = { source: { id, name, type, enabled: false, readOnly, disableTrash, config } }; try { const disableRes = await fetch(withBase('/api/pro/sources/save.php'), { method: 'POST', @@ -2520,6 +2502,824 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou loadSources(); } +function initGatewaysSection({ isPro }) { + const container = document.getElementById('gatewaysContent'); + if (!container || container.__initialized) return; + container.__initialized = true; + + if (!isPro) { + container.innerHTML = ` +
+
+ + ${escapeHTML(tf('gateway_shares', 'Gateway Shares'))} + Pro + +
+
+
+ ${escapeHTML(tf('gateway_locked_body', 'Expose a scoped source root over SFTP, S3, or MCP with generated start commands and safety checks.'))} +
+
+
+ `; + return; + } + + container.innerHTML = ` +
+
+ + +
+ +
+
${escapeHTML(tf('gateway_runtime_title', 'Gateway runtime checklist'))}
+
${escapeHTML(tf('gateway_runtime_line_1', '1) Install rclone on the gateway host/container.'))}
+
${escapeHTML(tf('gateway_runtime_line_2', '2) For Docker, recommended: run rclone as a sidecar from the docker-compose snippet.'))}
+
${escapeHTML(tf('gateway_runtime_line_3', '3) If you run rclone inside the FileRise container, publish the gateway port (example: -p 2022:2022).'))}
+
${escapeHTML(tf('gateway_runtime_line_4', '4) Use listen address 0.0.0.0 for LAN access, then connect to host IP:port.'))}
+
${escapeHTML(tf('gateway_runtime_line_5', '5) Test validates config only; it does not start or stop gateway services.'))}
+ + ${escapeHTML(tf('gateway_runtime_docs', 'Open Gateway setup guide'))} + +
+ +
+
+ +
+ +
+
${escapeHTML(tf('gateway_add', 'Add Gateway'))}
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+ ${escapeHTML(tf('gateway_secret_note', 'Secrets are never shown after saving. Leave secret fields blank to keep existing values.'))} +
+ + + + + +
+ + +
+
+ +
+
+ +
+ + +
+
+

+      
+ +
+
+ +
+
+ + +
+ +
+
+

+      
+
+ `; + + const state = { + gateways: [], + editingId: '', + previewGatewayId: '', + sourceOptions: [], + lastTestResult: null, + }; + + const getCsrf = () => + (document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken || ''); + + const statusEl = container.querySelector('#gwStatus'); + const listEl = container.querySelector('#gwList'); + const formTitleEl = container.querySelector('#gwFormTitle'); + const commandEl = container.querySelector('#gwSnippetPreview'); + const snippetTypeEl = container.querySelector('#gwSnippetType'); + const copySnippetBtn = container.querySelector('#gwCopySnippetBtn'); + const testOutEl = container.querySelector('#gwTestOutput'); + const includeSecretsEl = container.querySelector('#gwIncludeSecrets'); + const copyTestBtn = container.querySelector('#gwCopyTestBtn'); + const nameEl = container.querySelector('#gwName'); + const typeEl = container.querySelector('#gwType'); + const sourceIdEl = container.querySelector('#gwSourceId'); + const rootPathEl = container.querySelector('#gwRootPath'); + const modeEl = container.querySelector('#gwMode'); + const listenEl = container.querySelector('#gwListenAddr'); + const portEl = container.querySelector('#gwPort'); + const enabledEl = container.querySelector('#gwEnabled'); + const sftpUserEl = container.querySelector('#gwSftpUser'); + const sftpPassEl = container.querySelector('#gwSftpPass'); + const sftpKeysEl = container.querySelector('#gwSftpAuthorizedKeys'); + const s3AccessEl = container.querySelector('#gwS3AccessKey'); + const s3SecretEl = container.querySelector('#gwS3SecretKey'); + const mcpTokenEl = container.querySelector('#gwMcpToken'); + const saveBtn = container.querySelector('#gwSaveBtn'); + const resetBtn = container.querySelector('#gwResetBtn'); + const refreshBtn = container.querySelector('#gwRefreshBtn'); + const addBtn = container.querySelector('#gwAddBtn'); + + const setStatus = (msg, tone = 'muted') => { + if (!statusEl) return; + statusEl.classList.remove('text-muted', 'text-danger', 'text-success', 'text-warning'); + if (tone === 'danger') statusEl.classList.add('text-danger'); + else if (tone === 'success') statusEl.classList.add('text-success'); + else if (tone === 'warning') statusEl.classList.add('text-warning'); + else statusEl.classList.add('text-muted'); + statusEl.textContent = msg || ''; + }; + + const setCommand = (text) => { + if (!commandEl) return; + commandEl.textContent = String(text || ''); + }; + + const setTestOutput = (text) => { + if (!testOutEl) return; + testOutEl.textContent = String(text || ''); + }; + + const selectedSnippetKind = () => { + return String(snippetTypeEl?.value || 'command'); + }; + + const snippetFromGateway = (gw, kind = selectedSnippetKind(), includeSecrets = false) => { + if (!gw || typeof gw !== 'object') return ''; + const k = String(kind || 'command').toLowerCase(); + const defaultSnippets = gw?.snippets && typeof gw.snippets === 'object' ? gw.snippets : {}; + const secretsSnippets = gw?.snippetsWithSecrets && typeof gw.snippetsWithSecrets === 'object' + ? gw.snippetsWithSecrets + : {}; + const pick = includeSecrets ? secretsSnippets : defaultSnippets; + + if (k === 'docker') { + return String( + pick.dockerCompose + || (includeSecrets ? gw.dockerComposeWithSecrets : '') + || defaultSnippets.dockerCompose + || gw.dockerCompose + || '' + ); + } + if (k === 'systemd') { + return String( + pick.systemd + || (includeSecrets ? gw.systemdWithSecrets : '') + || defaultSnippets.systemd + || gw.systemd + || '' + ); + } + return String( + pick.startCommand + || (includeSecrets ? gw.startCommandWithSecrets : '') + || defaultSnippets.startCommand + || gw.startCommand + || '' + ); + }; + + const copyText = async (value) => { + const text = String(value || ''); + if (!text) return false; + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return true; + } + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', 'readonly'); + ta.style.position = 'absolute'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { + return document.execCommand('copy'); + } finally { + document.body.removeChild(ta); + } + }; + + const activeGateway = () => { + const id = state.editingId || state.previewGatewayId; + if (!id) return null; + return (state.gateways || []).find((x) => String(x.id || '') === id) || null; + }; + + const refreshSnippetPreview = () => { + const includeSecrets = !!includeSecretsEl?.checked; + const payload = state.lastTestResult || activeGateway(); + setCommand(snippetFromGateway(payload, selectedSnippetKind(), includeSecrets)); + }; + + const ensureSourceOption = (id, label = '') => { + const sourceId = String(id || '').trim(); + if (!sourceId) return; + const existing = (state.sourceOptions || []).find((s) => String(s.id || '') === sourceId); + if (existing) return; + const sourceLabel = String(label || sourceId); + state.sourceOptions = [...(state.sourceOptions || []), { id: sourceId, label: sourceLabel }]; + }; + + const renderSourceOptions = (preferredId = '') => { + if (!sourceIdEl) return; + let options = Array.isArray(state.sourceOptions) ? state.sourceOptions.slice() : []; + if (!options.length) { + options = [{ id: 'local', label: 'local - Local' }]; + } + if (!options.some((s) => String(s.id || '') === 'local')) { + options.unshift({ id: 'local', label: 'local - Local' }); + } + + sourceIdEl.innerHTML = ''; + options.forEach((src) => { + const id = String(src?.id || '').trim(); + if (!id) return; + const option = document.createElement('option'); + option.value = id; + option.textContent = String(src?.label || id); + sourceIdEl.appendChild(option); + }); + + const chosen = String(preferredId || sourceIdEl.value || 'local').trim() || 'local'; + const hasChosen = options.some((s) => String(s.id || '') === chosen); + sourceIdEl.value = hasChosen ? chosen : 'local'; + }; + + const loadSourceOptions = async () => { + try { + const res = await fetch(withBase('/api/pro/sources/list.php'), { + method: 'GET', + credentials: 'include', + headers: { 'Accept': 'application/json' }, + }); + const data = await safeJson(res); + if (!data || data.ok !== true) { + throw new Error(data?.error || 'Failed to load sources.'); + } + + const sources = Array.isArray(data.sources) ? data.sources : []; + const options = []; + sources.forEach((src) => { + const id = String(src?.id || '').trim(); + if (!id) return; + const name = String(src?.name || id).trim() || id; + const type = String(src?.type || '').trim(); + const enabled = src?.enabled !== false; + const status = enabled ? '' : ` (${tf('disabled', 'Disabled')})`; + const typePart = type ? ` [${type}]` : ''; + options.push({ id, label: `${id} - ${name}${typePart}${status}` }); + }); + if (!options.some((s) => s.id === 'local')) { + options.unshift({ id: 'local', label: 'local - Local' }); + } + state.sourceOptions = options; + renderSourceOptions(sourceIdEl?.value || 'local'); + } catch (err) { + state.sourceOptions = [{ id: 'local', label: 'local - Local' }]; + renderSourceOptions(sourceIdEl?.value || 'local'); + } + }; + + const setType = (type) => { + const t = String(type || 'sftp').toLowerCase(); + container.querySelectorAll('[data-gw-type]').forEach((el) => { + el.hidden = el.getAttribute('data-gw-type') !== t; + }); + }; + + const resetForm = () => { + state.editingId = ''; + state.previewGatewayId = ''; + state.lastTestResult = null; + if (formTitleEl) formTitleEl.textContent = tf('gateway_add', 'Add Gateway'); + if (nameEl) nameEl.value = ''; + if (typeEl) typeEl.value = 'sftp'; + renderSourceOptions('local'); + if (rootPathEl) rootPathEl.value = 'root'; + if (modeEl) modeEl.value = 'ro'; + if (listenEl) listenEl.value = '127.0.0.1'; + if (portEl) portEl.value = ''; + if (enabledEl) enabledEl.checked = true; + if (sftpUserEl) sftpUserEl.value = ''; + if (sftpPassEl) sftpPassEl.value = ''; + if (sftpKeysEl) sftpKeysEl.value = ''; + if (s3AccessEl) s3AccessEl.value = ''; + if (s3SecretEl) s3SecretEl.value = ''; + if (mcpTokenEl) mcpTokenEl.value = ''; + setType('sftp'); + refreshSnippetPreview(); + }; + + const fillForm = (gw) => { + if (!gw || typeof gw !== 'object') return; + state.editingId = String(gw.id || ''); + state.previewGatewayId = state.editingId; + state.lastTestResult = null; + if (formTitleEl) formTitleEl.textContent = tf('gateway_edit', 'Edit Gateway'); + if (nameEl) nameEl.value = String(gw.name || ''); + if (typeEl) typeEl.value = String(gw.gatewayType || 'sftp').toLowerCase(); + ensureSourceOption(gw.sourceId, `${String(gw.sourceId || 'local')} - ${String(gw.sourceId || 'local')}`); + renderSourceOptions(String(gw.sourceId || 'local')); + if (rootPathEl) rootPathEl.value = String(gw.rootPath || 'root'); + if (modeEl) modeEl.value = String(gw.mode || 'ro').toLowerCase() === 'rw' ? 'rw' : 'ro'; + if (listenEl) listenEl.value = String(gw.listenAddr || '127.0.0.1'); + if (portEl) portEl.value = gw.port ? String(gw.port) : ''; + if (enabledEl) enabledEl.checked = !!gw.enabled; + + if (sftpUserEl) sftpUserEl.value = String(gw?.sftp?.user || ''); + if (sftpPassEl) sftpPassEl.value = ''; + if (sftpKeysEl) sftpKeysEl.value = ''; + if (s3AccessEl) s3AccessEl.value = ''; + if (s3SecretEl) s3SecretEl.value = ''; + if (mcpTokenEl) mcpTokenEl.value = ''; + + setType(gw.gatewayType || 'sftp'); + refreshSnippetPreview(); + setTestOutput(''); + }; + + const renderList = () => { + if (!listEl) return; + const rows = Array.isArray(state.gateways) ? state.gateways : []; + if (!rows.length) { + listEl.innerHTML = `
${escapeHTML(tf('gateway_list_empty', 'No gateway shares configured yet.'))}
`; + return; + } + + const statusText = (row) => row.enabled ? tf('enabled', 'Enabled') : tf('disabled', 'Disabled'); + const modeText = (row) => (String(row.mode || 'ro').toLowerCase() === 'rw' ? 'rw' : 'ro'); + const bindText = (row) => `${String(row.listenAddr || '127.0.0.1')}:${String(row.port || '')}`; + const actions = (row) => ` +
+ + + + +
+ `; + + listEl.innerHTML = ` +
+ + + + + + + + + + + + + + + ${rows.map((row) => ` + + + + + + + + + + + `).join('')} + +
${escapeHTML(tf('gateway_name', 'Name'))}${escapeHTML(tf('gateway_type', 'Gateway Type'))}${escapeHTML(tf('gateway_source_id', 'Source ID'))}${escapeHTML(tf('gateway_root_path', 'Root Path'))}${escapeHTML(tf('gateway_mode', 'Mode'))}${escapeHTML(tf('gateway_bind', 'Bind'))}${escapeHTML(tf('status', 'Status'))}${escapeHTML(tf('actions', 'Actions'))}
${escapeHTML(String(row.name || row.id || ''))}${escapeHTML(String(row.gatewayType || ''))}${escapeHTML(String(row.sourceId || ''))}${escapeHTML(String(row.rootPath || 'root'))}${escapeHTML(modeText(row))}${escapeHTML(bindText(row))}${escapeHTML(statusText(row))}${actions(row)}
+
+ `; + }; + + const buildPayload = () => { + const name = String(nameEl?.value || '').trim(); + const gatewayType = String(typeEl?.value || 'sftp').trim().toLowerCase(); + const sourceId = String(sourceIdEl?.value || 'local').trim() || 'local'; + const rootPath = String(rootPathEl?.value || 'root').trim() || 'root'; + const mode = String(modeEl?.value || 'ro').trim().toLowerCase() === 'rw' ? 'rw' : 'ro'; + const listenAddr = String(listenEl?.value || '127.0.0.1').trim() || '127.0.0.1'; + const enabled = !!enabledEl?.checked; + + if (!name) { + throw new Error(tf('gateway_name_required', 'Gateway name is required.')); + } + if (!gatewayType || !['sftp', 's3', 'mcp'].includes(gatewayType)) { + throw new Error(tf('gateway_type_required', 'Gateway type is required.')); + } + + const payload = { + name, + gatewayType, + sourceId, + rootPath, + mode, + listenAddr, + enabled, + }; + + const portRaw = String(portEl?.value || '').trim(); + if (portRaw !== '') { + const p = parseInt(portRaw, 10); + if (!Number.isFinite(p) || p < 1024 || p > 65535) { + throw new Error(tf('gateway_port_invalid', 'Port must be between 1024 and 65535.')); + } + payload.port = p; + } + + if (state.editingId) { + payload.id = state.editingId; + } + + if (gatewayType === 'sftp') { + const user = String(sftpUserEl?.value || '').trim(); + const pass = String(sftpPassEl?.value || '').trim(); + const authorizedKeys = String(sftpKeysEl?.value || '').trim(); + if (!user) { + throw new Error(tf('gateway_sftp_user_required', 'SFTP user is required.')); + } + if (!state.editingId && !pass && !authorizedKeys) { + throw new Error(tf('gateway_sftp_auth_required', 'Provide SFTP password or authorized keys.')); + } + payload.sftp = { user }; + if (pass) payload.sftp.pass = pass; + if (authorizedKeys) payload.sftp.authorizedKeys = authorizedKeys; + } else if (gatewayType === 's3') { + const accessKey = String(s3AccessEl?.value || '').trim(); + const secretKey = String(s3SecretEl?.value || '').trim(); + if (!state.editingId && (!accessKey || !secretKey)) { + throw new Error(tf('gateway_s3_keys_required', 'S3 access key and secret key are required.')); + } + if (accessKey || secretKey) { + if (!accessKey || !secretKey) { + throw new Error(tf('gateway_s3_keys_pair_required', 'Provide both S3 access key and secret key.')); + } + payload.s3 = { keys: [{ accessKey, secretKey }] }; + } + } else if (gatewayType === 'mcp') { + const token = String(mcpTokenEl?.value || '').trim(); + if (token) { + payload.mcp = { token }; + } + } + + return payload; + }; + + const loadGateways = async () => { + try { + setStatus(tf('loading', 'Loading…')); + const res = await fetch(withBase('/api/pro/gateways/list.php'), { + method: 'GET', + credentials: 'include', + headers: { 'Accept': 'application/json' }, + }); + const data = await safeJson(res); + if (!res.ok || !data || data.ok !== true) { + throw new Error(data?.error || tf('gateway_list_failed', 'Failed to load gateway shares.')); + } + state.gateways = Array.isArray(data.gateways) ? data.gateways : []; + renderList(); + if (state.editingId || state.previewGatewayId) { + const current = activeGateway(); + if (!current) { + state.previewGatewayId = ''; + state.lastTestResult = null; + } + refreshSnippetPreview(); + } + const countMsg = tf('gateway_count', '{count} gateway share(s).').replace('{count}', String(state.gateways.length)); + setStatus(countMsg, 'muted'); + } catch (err) { + console.warn('Gateway list failed', err); + state.gateways = []; + renderList(); + setStatus(err?.message || tf('gateway_list_failed', 'Failed to load gateway shares.'), 'danger'); + } + }; + + const runGatewayTest = async (id) => { + if (!id) return; + try { + setTestOutput(tf('source_test_running', 'Testing...')); + const includeSecrets = !!includeSecretsEl?.checked; + const res = await fetch(withBase('/api/pro/gateways/test.php'), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrf(), + 'Accept': 'application/json', + }, + body: JSON.stringify({ id, includeSecrets }), + }); + const data = await safeJson(res); + if (!res.ok || !data) { + throw new Error(data?.error || tf('gateway_test_failed', 'Gateway test failed.')); + } + + const lines = []; + if (data.ok === true) { + lines.push(tf('gateway_test_ok', 'Gateway test passed.')); + } else { + lines.push(tf('gateway_test_failed', 'Gateway test failed.')); + } + const errors = Array.isArray(data.errors) ? data.errors : []; + const warnings = Array.isArray(data.warnings) ? data.warnings : []; + if (errors.length) { + lines.push(''); + lines.push('Errors:'); + errors.forEach((e) => lines.push(`- ${String(e)}`)); + } + if (warnings.length) { + lines.push(''); + lines.push('Warnings:'); + warnings.forEach((w) => lines.push(`- ${String(w)}`)); + } + if (includeSecrets) { + lines.push(''); + lines.push(tf('gateway_secrets_warning', 'Warning: snippets may include secrets. Handle output carefully.')); + } + setTestOutput(lines.join('\n')); + state.previewGatewayId = String(id); + state.lastTestResult = data; + refreshSnippetPreview(); + if (data.ok === true) { + showToast(tf('gateway_test_ok', 'Gateway test passed.')); + } else { + showToast((errors[0] || tf('gateway_test_failed', 'Gateway test failed.')), 'error'); + } + } catch (err) { + const msg = err?.message || tf('gateway_test_failed', 'Gateway test failed.'); + setTestOutput(msg); + showToast(msg, 'error'); + } + }; + + const saveGateway = async () => { + try { + const gateway = buildPayload(); + setStatus(tf('gateway_saving', 'Saving gateway...')); + const res = await fetch(withBase('/api/pro/gateways/save.php'), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrf(), + 'Accept': 'application/json', + }, + body: JSON.stringify({ gateway }), + }); + const data = await safeJson(res); + if (!res.ok || !data || data.ok !== true) { + throw new Error(data?.error || tf('gateway_save_failed', 'Failed to save gateway share.')); + } + state.lastTestResult = null; + state.previewGatewayId = String(data?.gateway?.id || state.previewGatewayId || ''); + setCommand(snippetFromGateway(data, selectedSnippetKind(), false)); + setTestOutput(''); + showToast(tf('gateway_saved', 'Gateway share saved.')); + setStatus(tf('gateway_saved', 'Gateway share saved.'), 'success'); + await loadGateways(); + resetForm(); + } catch (err) { + const msg = err?.message || tf('gateway_save_failed', 'Failed to save gateway share.'); + setStatus(msg, 'danger'); + showToast(msg, 'error'); + } + }; + + const deleteGateway = async (gw) => { + if (!gw || !gw.id) return; + const label = String(gw.name || gw.id); + const ok = window.confirm( + tf('gateway_delete_confirm', 'Delete gateway "{name}"?').replace('{name}', label) + ); + if (!ok) return; + + try { + const res = await fetch(withBase('/api/pro/gateways/delete.php'), { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': getCsrf(), + 'Accept': 'application/json', + }, + body: JSON.stringify({ id: gw.id }), + }); + const data = await safeJson(res); + if (!res.ok || !data || data.ok !== true) { + throw new Error(data?.error || tf('gateway_delete_failed', 'Failed to delete gateway share.')); + } + if (state.editingId && state.editingId === String(gw.id)) { + resetForm(); + } else if (state.previewGatewayId && state.previewGatewayId === String(gw.id)) { + state.previewGatewayId = ''; + state.lastTestResult = null; + } + refreshSnippetPreview(); + setTestOutput(''); + showToast(tf('gateway_deleted', 'Gateway share deleted.')); + await loadGateways(); + } catch (err) { + showToast(err?.message || tf('gateway_delete_failed', 'Failed to delete gateway share.'), 'error'); + } + }; + + if (typeEl) { + typeEl.addEventListener('change', () => setType(typeEl.value)); + } + if (snippetTypeEl) { + snippetTypeEl.addEventListener('change', refreshSnippetPreview); + } + if (includeSecretsEl) { + includeSecretsEl.addEventListener('change', refreshSnippetPreview); + } + if (copySnippetBtn) { + copySnippetBtn.addEventListener('click', async () => { + try { + const ok = await copyText(commandEl?.textContent || ''); + if (!ok) { + throw new Error(tf('copy_failed', 'Copy failed.')); + } + showToast(tf('copied_to_clipboard', 'Copied to clipboard.')); + } catch (err) { + showToast(err?.message || tf('copy_failed', 'Copy failed.'), 'error'); + } + }); + } + if (copyTestBtn) { + copyTestBtn.addEventListener('click', async () => { + try { + const ok = await copyText(testOutEl?.textContent || ''); + if (!ok) { + throw new Error(tf('copy_failed', 'Copy failed.')); + } + showToast(tf('copied_to_clipboard', 'Copied to clipboard.')); + } catch (err) { + showToast(err?.message || tf('copy_failed', 'Copy failed.'), 'error'); + } + }); + } + if (refreshBtn) { + refreshBtn.addEventListener('click', () => { + Promise.allSettled([ + loadSourceOptions(), + loadGateways(), + ]); + }); + } + if (addBtn) { + addBtn.addEventListener('click', () => { + resetForm(); + }); + } + if (resetBtn) { + resetBtn.addEventListener('click', () => { + resetForm(); + }); + } + if (saveBtn) { + saveBtn.addEventListener('click', () => { + saveGateway(); + }); + } + if (listEl) { + listEl.addEventListener('click', (e) => { + const btn = e.target.closest('button[data-action]'); + if (!btn) return; + const id = String(btn.getAttribute('data-id') || ''); + const action = String(btn.getAttribute('data-action') || ''); + const gw = (state.gateways || []).find((x) => String(x.id || '') === id); + if (!gw) return; + + if (action === 'edit') { + fillForm(gw); + return; + } + if (action === 'command') { + state.previewGatewayId = String(gw.id || ''); + state.lastTestResult = null; + refreshSnippetPreview(); + setTestOutput(''); + return; + } + if (action === 'test') { + runGatewayTest(id); + return; + } + if (action === 'delete') { + deleteGateway(gw); + } + }); + } + + resetForm(); + setCommand(''); + setTestOutput(''); + Promise.allSettled([ + loadSourceOptions(), + loadGateways(), + ]); +} + function onShareFolderToggle(row, checked) { const manage = qs(row, 'input[data-cap="manage"]'); const viewAll = qs(row, 'input[data-cap="view"]'); @@ -4249,7 +5049,13 @@ export function openAdminPanel() { ? config.storageSources : {}; const sourcesEnabled = !!sourcesCfg.enabled; - const showSourcesSection = true; + const sourcesAvailable = (sourcesCfg.available !== false); + const sourcesProExtended = !!sourcesCfg.proExtended || proSourcesApiOk; + const sourcesAllowedTypes = Array.isArray(sourcesCfg.allowedTypes) && sourcesCfg.allowedTypes.length + ? sourcesCfg.allowedTypes.map(v => String(v || '').trim().toLowerCase()).filter(Boolean) + : (sourcesProExtended ? ALL_SOURCE_TYPES.slice() : CORE_SOURCE_TYPES.slice()); + const showSourcesSection = !!sourcesAvailable; + const showGatewaysSection = true; const brandingCfg = config.branding || {}; const brandingCustomLogoUrl = brandingCfg.customLogoUrl || ""; const brandingHeaderBgLight = brandingCfg.headerBgLight || ""; @@ -4344,11 +5150,15 @@ export function openAdminPanel() { { id: "storage", label: tf("storage_usage", "Storage / Disk Usage") } ]; if (showSourcesSection) { - const sourcesLabel = !isPro - ? `${tf("sources", "Sources")}Pro` - : tf("sources", "Sources"); + const sourcesLabel = tf("sources", "Sources"); sections.push({ id: "sources", label: sourcesLabel }); } + if (showGatewaysSection) { + const gatewaysLabel = !isPro + ? `${tf("gateway_shares", "Gateway Shares")}Pro` + : tf("gateway_shares", "Gateway Shares"); + sections.push({ id: "gateways", label: gatewaysLabel }); + } sections.push( { id: "proFeatures", label: "Pro Features" }, { id: "pro", label: "FileRise Pro" }, @@ -4485,7 +5295,14 @@ export function openAdminPanel() { sourcesEnabled, sourcesCfg, isPro, - proSourcesApiOk + proSourcesApiOk, + sourcesAllowedTypes, + sourcesProExtended + }); + } + if (showGatewaysSection) { + initGatewaysSection({ + isPro }); } @@ -6141,11 +6958,32 @@ ${t("shared_max_upload_size_bytes")} return; } - showToast(t('admin_license_saved')); + const autoBind = (data && typeof data.autoBind === 'object' && data.autoBind) + ? data.autoBind + : {}; + const autoBindAttempted = autoBind.attempted === true; + const autoBindBound = autoBind.bound === true; + const autoBindMessage = String(autoBind.message || '').trim(); + + if (autoBindAttempted) { + if (autoBindBound) { + const msg = autoBindMessage || t('admin_license_saved'); + showToast(msg); + setStatus(msg, 'success'); + } else { + const msg = autoBindMessage || 'License saved, but instance auto-bind was not completed.'; + showToast(msg, 'error'); + setStatus(msg, 'warning'); + } + } else { + showToast(t('admin_license_saved')); + } if (!isPro) { const ok = await showCustomConfirmModal( - 'Download and install the latest Pro bundle now?' + (autoBindAttempted && !autoBindBound) + ? 'License saved, but instance binding was not completed automatically. Download and install the latest Pro bundle now anyway?' + : 'Download and install the latest Pro bundle now?' ); if (ok) { setStatus('Downloading and installing latest Pro bundle...', 'muted'); @@ -6201,6 +7039,10 @@ ${t("shared_max_upload_size_bytes")} } } + if (autoBindAttempted && !autoBindBound && isPro) { + return; + } + window.location.reload(); } catch (e) { console.error(e); diff --git a/public/js/fileListView.js b/public/js/fileListView.js index 2cc1690..bd9764e 100644 --- a/public/js/fileListView.js +++ b/public/js/fileListView.js @@ -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({ diff --git a/public/js/folderManager.js b/public/js/folderManager.js index 5c20540..19d74e3 100644 --- a/public/js/folderManager.js +++ b/public/js/folderManager.js @@ -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); } diff --git a/public/js/i18n.js b/public/js/i18n.js index 5eab535..8a90418 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -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", diff --git a/public/js/pretheme.js b/public/js/pretheme.js new file mode 100644 index 0000000..be19f85 --- /dev/null +++ b/public/js/pretheme.js @@ -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. + } +})(); diff --git a/public/js/sourceManager.js b/public/js/sourceManager.js index d73037d..4983e30 100644 --- a/public/js/sourceManager.js +++ b/public/js/sourceManager.js @@ -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); }); diff --git a/src/FileRise/Domain/AdminModel.php b/src/FileRise/Domain/AdminModel.php index f2eb0d1..07d7a80 100644 --- a/src/FileRise/Domain/AdminModel.php +++ b/src/FileRise/Domain/AdminModel.php @@ -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'] diff --git a/src/FileRise/Domain/FileModel.php b/src/FileRise/Domain/FileModel.php index a2497c1..57df8d0 100644 --- a/src/FileRise/Domain/FileModel.php +++ b/src/FileRise/Domain/FileModel.php @@ -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); diff --git a/src/FileRise/Http/Controllers/AdminController.php b/src/FileRise/Http/Controllers/AdminController.php index d77282f..88b63db 100644 --- a/src/FileRise/Http/Controllers/AdminController.php +++ b/src/FileRise/Http/Controllers/AdminController.php @@ -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)); diff --git a/src/FileRise/Storage/SourceContext.php b/src/FileRise/Storage/SourceContext.php index 21b869a..9a2bf14 100644 --- a/src/FileRise/Storage/SourceContext.php +++ b/src/FileRise/Storage/SourceContext.php @@ -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()); diff --git a/src/FileRise/Storage/SourcesConfig.php b/src/FileRise/Storage/SourcesConfig.php new file mode 100644 index 0000000..8992f35 --- /dev/null +++ b/src/FileRise/Storage/SourcesConfig.php @@ -0,0 +1,498 @@ + 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; + } +} diff --git a/src/FileRise/Storage/StorageFactory.php b/src/FileRise/Storage/StorageFactory.php index 6e66a4d..5ef5615 100644 --- a/src/FileRise/Storage/StorageFactory.php +++ b/src/FileRise/Storage/StorageFactory.php @@ -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); } diff --git a/src/FileRise/Storage/WebDavAdapter.php b/src/FileRise/Storage/WebDavAdapter.php new file mode 100644 index 0000000..2f85db5 --- /dev/null +++ b/src/FileRise/Storage/WebDavAdapter.php @@ -0,0 +1,496 @@ +>> */ + 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); + } +} diff --git a/src/FileRise/Support/ACL.php b/src/FileRise/Support/ACL.php index 8aa0f52..036f30c 100644 --- a/src/FileRise/Support/ACL.php +++ b/src/FileRise/Support/ACL.php @@ -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) { diff --git a/src/lib/SourcesConfig.php b/src/lib/SourcesConfig.php new file mode 100644 index 0000000..d3a6d57 --- /dev/null +++ b/src/lib/SourcesConfig.php @@ -0,0 +1,11 @@ +