mirror of
https://github.com/error311/FileRise.git
synced 2026-05-12 06:50:54 -05:00
release(v3.4.0): Core Sources (Local + WebDAV), Pro Gateway Shares admin/API, pretheme CSP cleanup, and pagination fix (closes #104)
- sources(core): add SourcesConfig + core WebDAV adapter (Local + WebDAV without Pro)
- sources(api/ui): migrate /api/pro/sources/* to SourcesConfig and expose capability metadata (allowedTypes/proExtended)
- admin: add per-source delete-permanently toggle + trash-off badges/hints
- pro: add Gateway Shares admin section + /api/pro/gateways/{list,save,test,delete}
- ui: fix pagination getting stuck on page 2 in table/gallery (pane state sync)
- frontend/security: move pretheme to external js/pretheme.js and remove inline CSP hash requirement
- licensing: attempt yearly-plan instance auto-bind on license save with clearer autoBind responses
This commit is contained in:
@@ -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)`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,7 +26,7 @@ ONLYOFFICE requires additional CSP rules. The admin panel provides a copy-ready
|
||||
|
||||
.htaccess edit (change url or copy directly from admin panel)
|
||||
```
|
||||
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com"
|
||||
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com"
|
||||
```
|
||||
|
||||
Nginx
|
||||
@@ -36,7 +36,7 @@ proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
|
||||
# Replace with an ONLYOFFICE-aware CSP at the proxy
|
||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com" always;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+4
-2
@@ -29,6 +29,8 @@ RewriteRule - - [L]
|
||||
RewriteCond %{REQUEST_URI} !^/webdav\.php/ [NC]
|
||||
RewriteRule "(^|/)\.(?!well-known/)" - [F]
|
||||
RewriteRule ^portal/([A-Za-z0-9_-]+)$ portal.html?slug=$1 [L,QSA]
|
||||
# Keep entrypoint consistent: serve /index.html through index.php.
|
||||
RewriteRule ^index\.html$ index.php [L,QSA]
|
||||
|
||||
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
|
||||
# - allow /api/*.php (API endpoints)
|
||||
@@ -84,8 +86,8 @@ RewriteRule ^ - [E=IS_VER:1]
|
||||
# HSTS only when HTTPS (safe for .htaccess)
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" env=HTTPS
|
||||
|
||||
# CSP — keep this SHA-256 in sync with your inline pre-theme script
|
||||
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
|
||||
# CSP baseline (pretheme bootstrap is external JS, so no inline script hash is required).
|
||||
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
|
||||
</IfModule>
|
||||
|
||||
# ---------------- Caching ----------------
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/file/deleteFiles.php",
|
||||
* summary="Delete files to Trash",
|
||||
* description="Requires write access on the folder and (for non-admins) ownership of the files.",
|
||||
* summary="Delete files (Trash or permanent)",
|
||||
* description="Requires write access on the folder and (for non-admins) ownership of the files. Sources with Trash disabled (or Google Drive) delete permanently.",
|
||||
* operationId="deleteFiles",
|
||||
* tags={"Files"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
@@ -32,4 +32,4 @@
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
|
||||
$fileController = new \FileRise\Http\Controllers\FileController();
|
||||
$fileController->deleteFiles();
|
||||
$fileController->deleteFiles();
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
// public/api/pro/gateways/delete.php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
|
||||
try {
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
\FileRise\Http\Controllers\AdminController::requireAuth();
|
||||
\FileRise\Http\Controllers\AdminController::requireAdmin();
|
||||
\FileRise\Http\Controllers\AdminController::requireCsrf();
|
||||
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProGateways')) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['ok' => false, 'error' => 'Pro is not active']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid JSON body']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = strtolower(trim((string)($body['id'] ?? '')));
|
||||
if ($id === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Missing gateway id']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ok = ProGateways::deleteGateway($id);
|
||||
if (!$ok) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => 'Failed to delete gateway share']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => 'Error deleting gateway share'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
// public/api/pro/gateways/list.php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
|
||||
|
||||
try {
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
\FileRise\Http\Controllers\AdminController::requireAuth();
|
||||
\FileRise\Http\Controllers\AdminController::requireAdmin();
|
||||
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProGateways')) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['ok' => false, 'error' => 'Pro is not active']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$gateways = ProGateways::getAdminList();
|
||||
$out = [];
|
||||
foreach ($gateways as $g) {
|
||||
if (!is_array($g)) continue;
|
||||
$id = trim((string)($g['id'] ?? ''));
|
||||
$snippets = $id !== '' ? ProGateways::buildSnippets($id, false) : null;
|
||||
if (is_array($snippets)) {
|
||||
$g['startCommand'] = $snippets['startCommand'] ?? null;
|
||||
$g['dockerCompose'] = $snippets['dockerCompose'] ?? null;
|
||||
$g['systemd'] = $snippets['systemd'] ?? null;
|
||||
$g['snippets'] = [
|
||||
'startCommand' => $snippets['startCommand'] ?? null,
|
||||
'dockerCompose' => $snippets['dockerCompose'] ?? null,
|
||||
'systemd' => $snippets['systemd'] ?? null,
|
||||
];
|
||||
} else {
|
||||
$g['startCommand'] = $id !== '' ? ProGateways::buildStartCommand($id, false) : null;
|
||||
$g['dockerCompose'] = null;
|
||||
$g['systemd'] = null;
|
||||
$g['snippets'] = [
|
||||
'startCommand' => $g['startCommand'],
|
||||
'dockerCompose' => null,
|
||||
'systemd' => null,
|
||||
];
|
||||
}
|
||||
$out[] = $g;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'gateways' => $out,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => 'Error loading gateway shares'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
// public/api/pro/gateways/save.php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
|
||||
|
||||
try {
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
\FileRise\Http\Controllers\AdminController::requireAuth();
|
||||
\FileRise\Http\Controllers\AdminController::requireAdmin();
|
||||
\FileRise\Http\Controllers\AdminController::requireCsrf();
|
||||
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProGateways')) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['ok' => false, 'error' => 'Pro is not active']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid JSON body']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$gw = $body['gateway'] ?? $body;
|
||||
if (!is_array($gw)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Missing gateway payload']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sourceId = trim((string)($gw['sourceId'] ?? 'local'));
|
||||
if (class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
|
||||
if ($sourceId !== '' && strcasecmp($sourceId, 'local') !== 0) {
|
||||
$src = SourceContext::getSourceById($sourceId);
|
||||
if (!$src) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid sourceId']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($sourceId !== '' && strcasecmp($sourceId, 'local') !== 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Sources are not enabled (only local sourceId is supported)']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$actor = isset($_SESSION['username']) ? trim((string)$_SESSION['username']) : '';
|
||||
|
||||
$res = ProGateways::upsertGateway($gw, $actor);
|
||||
if (empty($res['ok'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => (string)($res['error'] ?? 'Failed to save gateway share')]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$gateway = $res['gateway'] ?? null;
|
||||
$id = is_array($gateway) ? trim((string)($gateway['id'] ?? '')) : '';
|
||||
$snippets = $id !== '' ? ProGateways::buildSnippets($id, false) : null;
|
||||
$cmd = is_array($snippets) ? ($snippets['startCommand'] ?? null) : ($id !== '' ? ProGateways::buildStartCommand($id, false) : null);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'gateway' => $gateway,
|
||||
'startCommand' => $cmd,
|
||||
'dockerCompose' => is_array($snippets) ? ($snippets['dockerCompose'] ?? null) : null,
|
||||
'systemd' => is_array($snippets) ? ($snippets['systemd'] ?? null) : null,
|
||||
'snippets' => [
|
||||
'startCommand' => $cmd,
|
||||
'dockerCompose' => is_array($snippets) ? ($snippets['dockerCompose'] ?? null) : null,
|
||||
'systemd' => is_array($snippets) ? ($snippets['systemd'] ?? null) : null,
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => 'Error saving gateway share'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
// public/api/pro/gateways/test.php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/FS.php';
|
||||
|
||||
try {
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
\FileRise\Http\Controllers\AdminController::requireAuth();
|
||||
\FileRise\Http\Controllers\AdminController::requireAdmin();
|
||||
\FileRise\Http\Controllers\AdminController::requireCsrf();
|
||||
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProGateways')) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['ok' => false, 'error' => 'Pro is not active']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid JSON body']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = strtolower(trim((string)($body['id'] ?? '')));
|
||||
if ($id === '') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Missing gateway id']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stored = ProGateways::getStoredGateway($id);
|
||||
if (!$stored) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['ok' => false, 'error' => 'Gateway share not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$warnings = [];
|
||||
$errors = [];
|
||||
|
||||
$type = strtolower(trim((string)($stored['gatewayType'] ?? 'sftp')));
|
||||
if (!in_array($type, ['sftp', 's3', 'mcp'], true)) {
|
||||
$errors[] = 'Invalid gatewayType.';
|
||||
}
|
||||
|
||||
$listenAddr = trim((string)($stored['listenAddr'] ?? '127.0.0.1'));
|
||||
$port = (int)($stored['port'] ?? 0);
|
||||
if ($port < 1024 || $port > 65535) {
|
||||
$errors[] = 'Port must be 1024-65535.';
|
||||
}
|
||||
if ($listenAddr === '' || preg_match('/[\s\'"]/', $listenAddr)) {
|
||||
$errors[] = 'Invalid listenAddr.';
|
||||
}
|
||||
|
||||
// --- rclone presence (best-effort) ---
|
||||
$rclonePath = '';
|
||||
if (function_exists('shell_exec')) {
|
||||
$out = @shell_exec('command -v rclone 2>/dev/null');
|
||||
$rclonePath = is_string($out) ? trim($out) : '';
|
||||
}
|
||||
if ($rclonePath === '') {
|
||||
foreach (['/usr/bin/rclone', '/usr/local/bin/rclone', '/opt/homebrew/bin/rclone'] as $p) {
|
||||
if (is_file($p) && is_executable($p)) {
|
||||
$rclonePath = $p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($rclonePath === '') {
|
||||
$warnings[] = 'rclone not found on PATH (cannot verify).';
|
||||
}
|
||||
|
||||
// --- port bind test (warn only; some environments restrict binding) ---
|
||||
if (!$errors && $port >= 1024 && $port <= 65535) {
|
||||
$uri = 'tcp://' . $listenAddr . ':' . $port;
|
||||
$errno = 0;
|
||||
$errstr = '';
|
||||
$sock = @stream_socket_server($uri, $errno, $errstr);
|
||||
if ($sock === false) {
|
||||
$warnings[] = 'Port bind failed (port may be in use or address is not bindable).';
|
||||
} else {
|
||||
@fclose($sock);
|
||||
}
|
||||
}
|
||||
|
||||
// --- root boundary checks (local sources only) ---
|
||||
$sourceId = trim((string)($stored['sourceId'] ?? 'local'));
|
||||
$rootPath = trim((string)($stored['rootPath'] ?? 'root'));
|
||||
$src = null;
|
||||
if (class_exists('SourceContext') && SourceContext::sourcesEnabled() && $sourceId !== '' && strcasecmp($sourceId, 'local') !== 0) {
|
||||
$src = SourceContext::getSourceById($sourceId);
|
||||
if (!$src) {
|
||||
$errors[] = 'Invalid sourceId.';
|
||||
}
|
||||
} else {
|
||||
// Local-only mode (or local id)
|
||||
$src = [
|
||||
'id' => 'local',
|
||||
'type' => 'local',
|
||||
'config' => ['path' => (string)UPLOAD_DIR],
|
||||
];
|
||||
}
|
||||
|
||||
if ($src && is_array($src)) {
|
||||
$srcType = strtolower((string)($src['type'] ?? ''));
|
||||
if ($srcType === 'local') {
|
||||
$cfg = isset($src['config']) && is_array($src['config']) ? $src['config'] : [];
|
||||
$base = trim((string)($cfg['path'] ?? $cfg['root'] ?? UPLOAD_DIR));
|
||||
if ($base === '') $base = (string)UPLOAD_DIR;
|
||||
$base = rtrim($base, "/\\");
|
||||
if (!is_dir($base)) {
|
||||
$warnings[] = 'Local source path not found.';
|
||||
} elseif (!is_readable($base)) {
|
||||
$warnings[] = 'Local source path not readable.';
|
||||
} else {
|
||||
$baseReal = realpath($base);
|
||||
if ($baseReal === false || $baseReal === '') {
|
||||
$warnings[] = 'Local source path realpath() failed.';
|
||||
} else {
|
||||
$rel = str_replace('\\', '/', $rootPath);
|
||||
$rel = trim($rel, '/');
|
||||
if ($rel === '' || strcasecmp($rel, 'root') === 0) {
|
||||
$target = $baseReal;
|
||||
} else {
|
||||
$target = $baseReal . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
|
||||
}
|
||||
$safe = FS::safeReal($baseReal, $target);
|
||||
if ($safe === null) {
|
||||
$errors[] = 'Root boundary check failed (path escapes base).';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$warnings[] = 'Non-local sources: boundary enforcement is not implemented for rclone command generation in v1.';
|
||||
}
|
||||
}
|
||||
|
||||
// --- credential presence checks ---
|
||||
if ($type === 'sftp') {
|
||||
$sftp = isset($stored['sftp']) && is_array($stored['sftp']) ? $stored['sftp'] : [];
|
||||
$user = trim((string)($sftp['user'] ?? ''));
|
||||
if ($user === '') {
|
||||
$errors[] = 'SFTP user is missing.';
|
||||
}
|
||||
$hasPass = !empty($sftp['passEnc']);
|
||||
$hasKeys = trim((string)($sftp['authorizedKeys'] ?? '')) !== '';
|
||||
if (!$hasPass && !$hasKeys) {
|
||||
$warnings[] = 'SFTP has no password or authorized keys configured.';
|
||||
}
|
||||
} elseif ($type === 's3') {
|
||||
$s3 = isset($stored['s3']) && is_array($stored['s3']) ? $stored['s3'] : [];
|
||||
$keys = isset($s3['keys']) && is_array($s3['keys']) ? $s3['keys'] : [];
|
||||
if (!$keys) {
|
||||
$errors[] = 'S3 gateway has no keypairs configured.';
|
||||
} else {
|
||||
$k0 = $keys[0] ?? null;
|
||||
if (!is_array($k0) || empty($k0['accessKeyEnc']) || empty($k0['secretKeyEnc'])) {
|
||||
$errors[] = 'S3 keypair is incomplete.';
|
||||
}
|
||||
}
|
||||
} elseif ($type === 'mcp') {
|
||||
$mcp = isset($stored['mcp']) && is_array($stored['mcp']) ? $stored['mcp'] : [];
|
||||
if (empty($mcp['tokenEnc'])) {
|
||||
$warnings[] = 'MCP token is missing.';
|
||||
}
|
||||
$warnings[] = 'MCP gateway server is not implemented in v1 bundle.';
|
||||
}
|
||||
|
||||
$includeSecrets = !empty($body['includeSecrets']);
|
||||
$snippets = ProGateways::buildSnippets($id, false);
|
||||
$snippetsSecrets = $includeSecrets ? ProGateways::buildSnippets($id, true) : null;
|
||||
$cmd = is_array($snippets) ? ($snippets['startCommand'] ?? null) : ProGateways::buildStartCommand($id, false);
|
||||
$cmdSecrets = is_array($snippetsSecrets) ? ($snippetsSecrets['startCommand'] ?? null) : ($includeSecrets ? ProGateways::buildStartCommand($id, true) : null);
|
||||
|
||||
echo json_encode([
|
||||
'ok' => empty($errors),
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings,
|
||||
'startCommand' => $cmd,
|
||||
'startCommandWithSecrets' => $cmdSecrets,
|
||||
'dockerCompose' => is_array($snippets) ? ($snippets['dockerCompose'] ?? null) : null,
|
||||
'systemd' => is_array($snippets) ? ($snippets['systemd'] ?? null) : null,
|
||||
'dockerComposeWithSecrets' => is_array($snippetsSecrets) ? ($snippetsSecrets['dockerCompose'] ?? null) : null,
|
||||
'systemdWithSecrets' => is_array($snippetsSecrets) ? ($snippetsSecrets['systemd'] ?? null) : null,
|
||||
'snippets' => [
|
||||
'startCommand' => $cmd,
|
||||
'dockerCompose' => is_array($snippets) ? ($snippets['dockerCompose'] ?? null) : null,
|
||||
'systemd' => is_array($snippets) ? ($snippets['systemd'] ?? null) : null,
|
||||
],
|
||||
'snippetsWithSecrets' => [
|
||||
'startCommand' => $cmdSecrets,
|
||||
'dockerCompose' => is_array($snippetsSecrets) ? ($snippetsSecrets['dockerCompose'] ?? null) : null,
|
||||
'systemd' => is_array($snippetsSecrets) ? ($snippetsSecrets['systemd'] ?? null) : null,
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => 'Error testing gateway share'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4239,6 +4239,8 @@ margin-bottom: 20px;
|
||||
#adminPanelModal .sources-form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;}
|
||||
#adminPanelModal .sources-form-inline{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:20px;}
|
||||
#adminPanelModal .sources-form-inline .form-check{margin:0;}
|
||||
#adminPanelModal .sources-flags-row{grid-column:1 / -1;align-items:flex-start;margin-top:6px;}
|
||||
#adminPanelModal .sources-inline-help{flex:1 1 100%;margin:0;line-height:1.35;}
|
||||
#adminPanelModal .sources-type-block{margin-top:8px;padding:8px;border:1px dashed var(--fr-border-light,#d8d8d8);border-radius:8px;background:#fff;}
|
||||
#adminPanelModal .sources-hint-row{display:flex;justify-content:flex-end;margin:0 0 6px;}
|
||||
#adminPanelModal .sources-hint-btn{position:relative;width:18px;height:18px;border-radius:999px;border:1px solid rgba(0,0,0,0.18);background:rgba(0,0,0,0.04);color:#475569;font-size:12px;font-weight:700;line-height:1;padding:0;cursor:pointer;}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
|
||||
<meta name="theme-color" content="#0b5ed7">
|
||||
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
|
||||
<script src="js/pretheme.js?v={{APP_QVER}}"></script>
|
||||
<style id="pretheme-css">
|
||||
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
|
||||
</style>
|
||||
|
||||
@@ -369,12 +369,10 @@ function attachOnlyOfficeCspHelper(container) {
|
||||
`;
|
||||
container.appendChild(cspHelp);
|
||||
|
||||
const INLINE_SHA = "sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=";
|
||||
|
||||
function buildCspApache(originRaw) {
|
||||
const o = (originRaw || 'https://your-onlyoffice-server.example.com').replace(/\/+$/, '');
|
||||
const api = `${o}/web-apps/apps/api/documents/api.js`;
|
||||
return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`;
|
||||
return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`;
|
||||
}
|
||||
|
||||
function buildCspNginx(originRaw) {
|
||||
@@ -386,7 +384,7 @@ function attachOnlyOfficeCspHelper(container) {
|
||||
`base-uri 'self'; ` +
|
||||
`frame-ancestors 'self'; ` +
|
||||
`object-src 'none'; ` +
|
||||
`script-src 'self' '${INLINE_SHA}' ${o} ${api}; ` +
|
||||
`script-src 'self' ${o} ${api}; ` +
|
||||
`style-src 'self' 'unsafe-inline'; ` +
|
||||
`img-src 'self' data: blob:; ` +
|
||||
`font-src 'self'; ` +
|
||||
|
||||
+957
-115
File diff suppressed because it is too large
Load Diff
@@ -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({
|
||||
|
||||
@@ -183,6 +183,28 @@ function getSourceTypeById(sourceId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function isTrashDisabledForSource(sourceId = '') {
|
||||
const id = String(sourceId || getActiveSourceId() || '').trim();
|
||||
if (!id) return false;
|
||||
|
||||
try {
|
||||
if (typeof window.__frGetSourceMetaById === 'function') {
|
||||
const meta = window.__frGetSourceMetaById(id);
|
||||
if (meta && typeof meta === 'object' && Object.prototype.hasOwnProperty.call(meta, 'disableTrash')) {
|
||||
return !!meta.disableTrash;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
const sel = document.getElementById('sourceSelector');
|
||||
if (sel) {
|
||||
const opt = Array.from(sel.options).find(o => o.value === id);
|
||||
if (opt) return opt.dataset?.sourceDisableTrash === '1';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isFtpSourceId(sourceId = '') {
|
||||
const type = String(getSourceTypeById(sourceId || getActiveSourceId()) || '').toLowerCase();
|
||||
return type === 'ftp';
|
||||
@@ -1837,6 +1859,8 @@ function placeRecycleBinNode() {
|
||||
const isAdmin = localStorage.getItem('isAdmin') === '1' || localStorage.getItem('isAdmin') === 'true';
|
||||
if (!isAdmin) return;
|
||||
|
||||
if (isTrashDisabledForSource()) return;
|
||||
|
||||
renderRecycleBinNode(window.recycleBinHasItems || false);
|
||||
}
|
||||
|
||||
|
||||
+60
-1
@@ -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",
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
})();
|
||||
@@ -110,9 +110,40 @@ function getSourceTypeById(id) {
|
||||
}
|
||||
|
||||
function getSourceMetaById(id) {
|
||||
const key = String(id || '').trim();
|
||||
if (!key) return { name: '', type: '', readOnly: false, disableTrash: false };
|
||||
|
||||
try {
|
||||
const meta = window.__FR_SOURCE_META_MAP;
|
||||
if (meta && Object.prototype.hasOwnProperty.call(meta, key)) {
|
||||
const row = meta[key] || {};
|
||||
return {
|
||||
name: String(row.name || ''),
|
||||
type: String(row.type || ''),
|
||||
readOnly: !!row.readOnly,
|
||||
disableTrash: !!row.disableTrash
|
||||
};
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
const select = document.getElementById('sourceSelector');
|
||||
if (select) {
|
||||
const opt = Array.from(select.options).find(o => o.value === key);
|
||||
if (opt) {
|
||||
return {
|
||||
name: String(opt.dataset?.sourceName || ''),
|
||||
type: String(opt.dataset?.sourceType || ''),
|
||||
readOnly: opt.dataset?.sourceReadOnly === '1',
|
||||
disableTrash: opt.dataset?.sourceDisableTrash === '1'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: getSourceNameById(id),
|
||||
type: getSourceTypeById(id)
|
||||
name: getSourceNameById(key),
|
||||
type: getSourceTypeById(key),
|
||||
readOnly: false,
|
||||
disableTrash: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -260,8 +291,9 @@ export async function initSourceSelector(opts = {}) {
|
||||
opt.dataset.sourceName = name;
|
||||
opt.dataset.sourceType = type;
|
||||
opt.dataset.sourceReadOnly = src.readOnly ? '1' : '0';
|
||||
opt.dataset.sourceDisableTrash = src.disableTrash ? '1' : '0';
|
||||
nameMap[id] = name;
|
||||
metaMap[id] = { name, type, readOnly: !!src.readOnly };
|
||||
metaMap[id] = { name, type, readOnly: !!src.readOnly, disableTrash: !!src.disableTrash };
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,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']
|
||||
|
||||
@@ -823,16 +823,7 @@ class FileModel
|
||||
$errors = [];
|
||||
$storage = self::storage();
|
||||
$isLocal = $storage->isLocal();
|
||||
$skipTrash = false;
|
||||
if (!$isLocal && class_exists('SourceContext')) {
|
||||
$src = SourceContext::getActiveSource();
|
||||
if (is_array($src)) {
|
||||
$type = strtolower((string)($src['type'] ?? ''));
|
||||
if ($type === 'gdrive') {
|
||||
$skipTrash = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$skipTrash = class_exists('SourceContext') ? SourceContext::isTrashDisabled() : false;
|
||||
|
||||
list($uploadDir, $err) = self::resolveFolderPath($folder, false);
|
||||
if ($err) {
|
||||
@@ -847,7 +838,13 @@ class FileModel
|
||||
if (!$skipTrash) {
|
||||
$trashDir = rtrim(self::trashRoot(), '/\\') . DIRECTORY_SEPARATOR;
|
||||
if ($storage->stat($trashDir) === null) {
|
||||
$storage->mkdir($trashDir, 0755, true);
|
||||
if (!$storage->mkdir($trashDir, 0755, true)) {
|
||||
$detail = self::adapterErrorDetail($storage);
|
||||
$msg = $detail !== ''
|
||||
? ("Failed to create Trash folder: " . $detail)
|
||||
: "Failed to create Trash folder. Check source permissions or enable delete permanently (skip trash) for this source.";
|
||||
return ["error" => $msg];
|
||||
}
|
||||
}
|
||||
$trashMetadataFile = $trashDir . "trash.json";
|
||||
$trashJson = $storage->read($trashMetadataFile);
|
||||
|
||||
@@ -992,6 +992,190 @@ class AdminController
|
||||
}
|
||||
}
|
||||
|
||||
private static function decodeFrpPayload(string $license): ?array
|
||||
{
|
||||
$license = trim($license);
|
||||
if ($license === '' || stripos($license, 'FRP1.') !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = explode('.', $license, 3);
|
||||
if (count($parts) !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payloadPart = (string)$parts[1];
|
||||
if ($payloadPart === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$base64 = strtr($payloadPart, '-_', '+/');
|
||||
$pad = strlen($base64) % 4;
|
||||
if ($pad > 0) {
|
||||
$base64 .= str_repeat('=', 4 - $pad);
|
||||
}
|
||||
|
||||
$json = base64_decode($base64, true);
|
||||
if (!is_string($json) || $json === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = json_decode($json, true);
|
||||
return is_array($payload) ? $payload : null;
|
||||
}
|
||||
|
||||
private static function normalizeFrpInstances(array $payload): array
|
||||
{
|
||||
$out = [];
|
||||
if (isset($payload['instances']) && is_array($payload['instances'])) {
|
||||
foreach ($payload['instances'] as $entry) {
|
||||
$id = strtolower(trim((string)$entry));
|
||||
if ($id !== '' && preg_match('/^[a-f0-9]{32}$/', $id)) {
|
||||
$out[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$out) {
|
||||
return [];
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
private static function tryAutoBindInstance(string $license, string $instanceId): array
|
||||
{
|
||||
$result = [
|
||||
'attempted' => false,
|
||||
'bound' => false,
|
||||
'changed' => false,
|
||||
'message' => '',
|
||||
'license' => $license,
|
||||
];
|
||||
|
||||
$license = trim($license);
|
||||
$instanceId = strtolower(trim($instanceId));
|
||||
if ($license === '' || $instanceId === '' || !preg_match('/^[a-f0-9]{32}$/', $instanceId)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$payload = self::decodeFrpPayload($license);
|
||||
if (!is_array($payload)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$plan = strtolower(trim((string)($payload['plan'] ?? '')));
|
||||
if (!in_array($plan, ['personal_yearly', 'business_yearly'], true)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['attempted'] = true;
|
||||
$instances = self::normalizeFrpInstances($payload);
|
||||
if (in_array($instanceId, $instances, true)) {
|
||||
$result['bound'] = true;
|
||||
$result['message'] = 'Instance ID already bound to this license.';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$endpoint = defined('FR_PRO_BIND_INSTANCE_URL')
|
||||
? trim((string)FR_PRO_BIND_INSTANCE_URL)
|
||||
: 'https://filerise.net/pro/bind_instance.php';
|
||||
if ($endpoint === '') {
|
||||
$result['message'] = 'Auto-bind endpoint is not configured.';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$postData = http_build_query([
|
||||
'license' => $license,
|
||||
'instanceId' => $instanceId,
|
||||
]);
|
||||
|
||||
$status = 0;
|
||||
$bodyText = '';
|
||||
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($endpoint);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'Accept: application/json',
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_USERAGENT, 'FileRise-Core/1.0 (+https://filerise.net)');
|
||||
$resp = curl_exec($ch);
|
||||
if (is_string($resp)) {
|
||||
$bodyText = $resp;
|
||||
}
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
} else {
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => implode("\r\n", [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'Accept: application/json',
|
||||
'User-Agent: FileRise-Core/1.0 (+https://filerise.net)',
|
||||
]) . "\r\n",
|
||||
'content' => $postData,
|
||||
'timeout' => 15,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
$resp = @file_get_contents($endpoint, false, $ctx);
|
||||
if (is_string($resp)) {
|
||||
$bodyText = $resp;
|
||||
}
|
||||
if (isset($http_response_header) && is_array($http_response_header)) {
|
||||
foreach ($http_response_header as $line) {
|
||||
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $line, $m)) {
|
||||
$status = (int)$m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$body = null;
|
||||
if ($bodyText !== '') {
|
||||
$decoded = json_decode($bodyText, true);
|
||||
if (is_array($decoded)) {
|
||||
$body = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if ($status !== 200 || !is_array($body) || empty($body['ok'])) {
|
||||
$code = is_array($body) ? trim((string)($body['code'] ?? '')) : '';
|
||||
if ($code === 'instance_limit_reached') {
|
||||
$result['message'] = 'License instance limit reached. Use filerise.net/pro/instances.php to manage IDs.';
|
||||
return $result;
|
||||
}
|
||||
if ($code === 'license_superseded') {
|
||||
$result['message'] = 'This license key has been superseded. Use your latest issued key.';
|
||||
return $result;
|
||||
}
|
||||
$err = is_array($body) ? trim((string)($body['error'] ?? '')) : '';
|
||||
$result['message'] = $err !== '' ? $err : 'Instance auto-bind was not completed.';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$nextLicense = trim((string)($body['license'] ?? ''));
|
||||
if ($nextLicense !== '' && stripos($nextLicense, 'FRP1.') === 0) {
|
||||
$result['license'] = $nextLicense;
|
||||
$result['changed'] = ($nextLicense !== $license);
|
||||
}
|
||||
$result['bound'] = !empty($body['bound']) || $result['changed'];
|
||||
$msg = trim((string)($body['message'] ?? ''));
|
||||
if ($msg === '') {
|
||||
$msg = $result['changed']
|
||||
? 'Instance ID was bound to this license automatically.'
|
||||
: 'License saved.';
|
||||
}
|
||||
$result['message'] = $msg;
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function setLicense(): void
|
||||
{
|
||||
// Always respond JSON
|
||||
@@ -1012,6 +1196,11 @@ class AdminController
|
||||
}
|
||||
|
||||
$license = isset($data['license']) ? trim((string)$data['license']) : '';
|
||||
$instanceId = self::getInstanceId();
|
||||
$autoBind = self::tryAutoBindInstance($license, $instanceId);
|
||||
if (!empty($autoBind['changed']) && !empty($autoBind['license']) && is_string($autoBind['license'])) {
|
||||
$license = trim((string)$autoBind['license']);
|
||||
}
|
||||
|
||||
// Store license + updatedAt in JSON file
|
||||
if (!defined('PRO_LICENSE_FILE')) {
|
||||
@@ -1038,7 +1227,15 @@ class AdminController
|
||||
return;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'autoBind' => [
|
||||
'attempted' => !empty($autoBind['attempted']),
|
||||
'bound' => !empty($autoBind['bound']),
|
||||
'changed' => !empty($autoBind['changed']),
|
||||
'message' => (string)($autoBind['message'] ?? ''),
|
||||
],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
@@ -2462,6 +2659,12 @@ class AdminController
|
||||
@unlink($zipPath);
|
||||
@rmdir($workDir);
|
||||
$label = $httpCode ? "HTTP {$httpCode}" : 'Download failed';
|
||||
if ($msg === '') {
|
||||
if ($snippet !== '' && preg_match('/^Invalid license:\\s*(.+)$/i', $snippet, $m)) {
|
||||
$detail = trim((string)$m[1]);
|
||||
$msg = $detail !== '' ? $detail : 'Invalid license for bundle download.';
|
||||
}
|
||||
}
|
||||
if ($msg === '') {
|
||||
// If we got HTML (common with Cloudflare "Just a moment..."), return a human message.
|
||||
$looksHtml = ($snippet !== '' && preg_match('/^\\s*<(?:!doctype|html|head|body)\\b/i', $snippet));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FileRise\Storage;
|
||||
|
||||
use ProSources;
|
||||
|
||||
require_once PROJECT_ROOT . '/config/config.php';
|
||||
|
||||
final class SourcesConfig
|
||||
{
|
||||
private const FILE_NAME = 'sources.json';
|
||||
private const DEFAULT_ID = 'local';
|
||||
private const CORE_TYPES = ['local', 'webdav'];
|
||||
private const ALL_TYPES = ['local', 's3', 'sftp', 'ftp', 'webdav', 'smb', 'gdrive', 'onedrive', 'dropbox'];
|
||||
|
||||
private static function proSourcesAvailable(): bool
|
||||
{
|
||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProSources')) {
|
||||
return false;
|
||||
}
|
||||
if (!defined('FR_PRO_API_REQUIRE_SOURCES') || !function_exists('fr_pro_api_level_at_least')) {
|
||||
return true;
|
||||
}
|
||||
return fr_pro_api_level_at_least((int)FR_PRO_API_REQUIRE_SOURCES);
|
||||
}
|
||||
|
||||
public static function isProExtendedAvailable(): bool
|
||||
{
|
||||
return self::proSourcesAvailable();
|
||||
}
|
||||
|
||||
public static function allowedTypes(): array
|
||||
{
|
||||
return self::proSourcesAvailable() ? self::ALL_TYPES : self::CORE_TYPES;
|
||||
}
|
||||
|
||||
public static function isTypeAllowed(string $type): bool
|
||||
{
|
||||
$type = strtolower(trim($type));
|
||||
return in_array($type, self::allowedTypes(), true);
|
||||
}
|
||||
|
||||
public static function capabilityInfo(): array
|
||||
{
|
||||
$pro = self::proSourcesAvailable();
|
||||
return [
|
||||
'available' => true,
|
||||
'proExtended' => $pro,
|
||||
'allowedTypes' => self::allowedTypes(),
|
||||
'coreTypes' => self::CORE_TYPES,
|
||||
'proTypes' => array_values(array_diff(self::ALL_TYPES, self::CORE_TYPES)),
|
||||
];
|
||||
}
|
||||
|
||||
private static function withCapabilities(array $cfg): array
|
||||
{
|
||||
return array_merge($cfg, self::capabilityInfo());
|
||||
}
|
||||
|
||||
private static function baseDir(): string
|
||||
{
|
||||
$base = defined('FR_PRO_BUNDLE_DIR') ? rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") : '';
|
||||
if ($base === '') {
|
||||
$base = rtrim((string)USERS_DIR, "/\\") . DIRECTORY_SEPARATOR . 'pro';
|
||||
}
|
||||
return $base;
|
||||
}
|
||||
|
||||
private static function filePath(): string
|
||||
{
|
||||
$base = self::baseDir();
|
||||
return $base !== '' ? ($base . DIRECTORY_SEPARATOR . self::FILE_NAME) : '';
|
||||
}
|
||||
|
||||
private static function ensureDir(): void
|
||||
{
|
||||
$base = self::baseDir();
|
||||
if ($base !== '' && !is_dir($base)) {
|
||||
@mkdir($base, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static function decryptSecret(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') return '';
|
||||
$plain = decryptData($value, $GLOBALS['encryptionKey']);
|
||||
return ($plain === false || $plain === null) ? '' : (string)$plain;
|
||||
}
|
||||
|
||||
private static function encryptSecret(string $value): string
|
||||
{
|
||||
$value = (string)$value;
|
||||
if ($value === '') return '';
|
||||
return encryptData($value, $GLOBALS['encryptionKey']);
|
||||
}
|
||||
|
||||
private static function defaultLocalSource(): array
|
||||
{
|
||||
return [
|
||||
'id' => self::DEFAULT_ID,
|
||||
'name' => 'Local',
|
||||
'type' => 'local',
|
||||
'enabled' => true,
|
||||
'readOnly' => false,
|
||||
'disableTrash' => false,
|
||||
'config' => [
|
||||
'path' => (string)UPLOAD_DIR,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private static function rawSourceMap(array $raw): array
|
||||
{
|
||||
$sourcesRaw = isset($raw['sources']) && is_array($raw['sources']) ? $raw['sources'] : [];
|
||||
$out = [];
|
||||
|
||||
foreach ($sourcesRaw as $key => $src) {
|
||||
if (!is_array($src)) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($src['id']) && is_string($key)) {
|
||||
$src['id'] = $key;
|
||||
}
|
||||
$id = trim((string)($src['id'] ?? ''));
|
||||
if ($id === '' || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id)) {
|
||||
continue;
|
||||
}
|
||||
$out[$id] = $src;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function coreConfigFromRaw(array $raw): array
|
||||
{
|
||||
$enabled = !empty($raw['enabled']);
|
||||
$sources = [];
|
||||
|
||||
foreach (self::rawSourceMap($raw) as $src) {
|
||||
$normalized = self::normalizeSourceStored($src);
|
||||
if ($normalized) {
|
||||
$sources[$normalized['id']] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($sources[self::DEFAULT_ID])) {
|
||||
$sources[self::DEFAULT_ID] = self::normalizeSourceStored(self::defaultLocalSource());
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'sources' => $sources,
|
||||
];
|
||||
}
|
||||
|
||||
private static function normalizeSourceStored(array $src): ?array
|
||||
{
|
||||
$id = trim((string)($src['id'] ?? ''));
|
||||
if ($id === '' || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = strtolower((string)($src['type'] ?? 'local'));
|
||||
if (!self::isTypeAllowed($type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = trim((string)($src['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = ($id === self::DEFAULT_ID) ? 'Local' : $id;
|
||||
}
|
||||
|
||||
$enabled = !isset($src['enabled']) || $src['enabled'] !== false;
|
||||
$readOnly = !empty($src['readOnly']);
|
||||
$disableTrash = !empty($src['disableTrash']);
|
||||
|
||||
$config = isset($src['config']) && is_array($src['config']) ? $src['config'] : [];
|
||||
if ($type === 'local') {
|
||||
$path = trim((string)($config['path'] ?? $config['root'] ?? ''));
|
||||
if ($path === '') {
|
||||
$path = (string)UPLOAD_DIR;
|
||||
}
|
||||
$configStore = [
|
||||
'path' => $path,
|
||||
];
|
||||
} else {
|
||||
$baseUrl = trim((string)($config['baseUrl'] ?? $config['url'] ?? ''));
|
||||
$username = trim((string)($config['username'] ?? ''));
|
||||
if ($baseUrl === '' || $username === '') {
|
||||
return null;
|
||||
}
|
||||
$root = trim((string)($config['root'] ?? $config['path'] ?? ''));
|
||||
$verifyTls = !isset($config['verifyTls']) || $config['verifyTls'] !== false;
|
||||
$configStore = [
|
||||
'baseUrl' => $baseUrl,
|
||||
'username' => $username,
|
||||
'root' => $root,
|
||||
'verifyTls' => $verifyTls ? 1 : 0,
|
||||
];
|
||||
if (isset($config['passwordEnc'])) {
|
||||
$configStore['passwordEnc'] = (string)$config['passwordEnc'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'type' => $type,
|
||||
'enabled' => $enabled,
|
||||
'readOnly' => $readOnly,
|
||||
'disableTrash' => $disableTrash,
|
||||
'config' => $configStore,
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildSourceView(array $src, bool $includeSecrets, bool $adminView): array
|
||||
{
|
||||
$out = [
|
||||
'id' => $src['id'],
|
||||
'name' => $src['name'],
|
||||
'type' => $src['type'],
|
||||
'enabled' => !empty($src['enabled']),
|
||||
'readOnly' => !empty($src['readOnly']),
|
||||
'disableTrash' => !empty($src['disableTrash']),
|
||||
];
|
||||
|
||||
$cfg = isset($src['config']) && is_array($src['config']) ? $src['config'] : [];
|
||||
if ($src['type'] === 'local') {
|
||||
$out['config'] = [
|
||||
'path' => (string)($cfg['path'] ?? ''),
|
||||
];
|
||||
return $out;
|
||||
}
|
||||
|
||||
if ($src['type'] === 'webdav') {
|
||||
$config = [
|
||||
'baseUrl' => (string)($cfg['baseUrl'] ?? $cfg['url'] ?? ''),
|
||||
'username' => (string)($cfg['username'] ?? ''),
|
||||
'root' => (string)($cfg['root'] ?? $cfg['path'] ?? ''),
|
||||
'verifyTls' => !isset($cfg['verifyTls']) || $cfg['verifyTls'] !== false,
|
||||
];
|
||||
$hasPassword = !empty($cfg['passwordEnc']);
|
||||
|
||||
if ($includeSecrets) {
|
||||
$config['password'] = $hasPassword ? self::decryptSecret((string)$cfg['passwordEnc']) : '';
|
||||
} elseif ($adminView) {
|
||||
$config['hasPassword'] = $hasPassword;
|
||||
}
|
||||
|
||||
$out['config'] = $config;
|
||||
return $out;
|
||||
}
|
||||
|
||||
$out['config'] = [];
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function sourcesEnabled(): bool
|
||||
{
|
||||
return !empty(self::getConfig()['enabled']);
|
||||
}
|
||||
|
||||
public static function getConfig(): array
|
||||
{
|
||||
if (self::proSourcesAvailable()) {
|
||||
return self::withCapabilities(ProSources::getConfig());
|
||||
}
|
||||
|
||||
$cfg = self::coreConfigFromRaw(self::loadRaw());
|
||||
$out = [
|
||||
'enabled' => !empty($cfg['enabled']),
|
||||
'sources' => [],
|
||||
];
|
||||
|
||||
foreach ($cfg['sources'] as $src) {
|
||||
$out['sources'][] = self::buildSourceView($src, true, false);
|
||||
}
|
||||
|
||||
return self::withCapabilities($out);
|
||||
}
|
||||
|
||||
public static function getPublicConfig(): array
|
||||
{
|
||||
if (self::proSourcesAvailable()) {
|
||||
return self::withCapabilities(ProSources::getPublicConfig());
|
||||
}
|
||||
|
||||
$cfg = self::coreConfigFromRaw(self::loadRaw());
|
||||
$out = [
|
||||
'enabled' => !empty($cfg['enabled']),
|
||||
'sources' => [],
|
||||
];
|
||||
|
||||
foreach ($cfg['sources'] as $src) {
|
||||
if (empty($src['enabled'])) {
|
||||
continue;
|
||||
}
|
||||
$view = self::buildSourceView($src, false, false);
|
||||
unset($view['config']);
|
||||
$out['sources'][] = $view;
|
||||
}
|
||||
|
||||
return self::withCapabilities($out);
|
||||
}
|
||||
|
||||
public static function getAdminList(): array
|
||||
{
|
||||
if (self::proSourcesAvailable()) {
|
||||
return self::withCapabilities(ProSources::getAdminList());
|
||||
}
|
||||
|
||||
$cfg = self::coreConfigFromRaw(self::loadRaw());
|
||||
$out = [
|
||||
'enabled' => !empty($cfg['enabled']),
|
||||
'sources' => [],
|
||||
];
|
||||
|
||||
foreach ($cfg['sources'] as $src) {
|
||||
$out['sources'][] = self::buildSourceView($src, false, true);
|
||||
}
|
||||
|
||||
return self::withCapabilities($out);
|
||||
}
|
||||
|
||||
public static function getSource(?string $id): ?array
|
||||
{
|
||||
if (self::proSourcesAvailable()) {
|
||||
return ProSources::getSource($id);
|
||||
}
|
||||
|
||||
$cfg = self::coreConfigFromRaw(self::loadRaw());
|
||||
$id = trim((string)$id);
|
||||
if ($id !== '' && isset($cfg['sources'][$id])) {
|
||||
return self::buildSourceView($cfg['sources'][$id], true, false);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function getFirstEnabledSource(): ?array
|
||||
{
|
||||
if (self::proSourcesAvailable()) {
|
||||
return ProSources::getFirstEnabledSource();
|
||||
}
|
||||
|
||||
$cfg = self::coreConfigFromRaw(self::loadRaw());
|
||||
foreach ($cfg['sources'] as $src) {
|
||||
if (!empty($src['enabled'])) {
|
||||
return self::buildSourceView($src, true, false);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function getDefaultSource(): array
|
||||
{
|
||||
if (self::proSourcesAvailable()) {
|
||||
return ProSources::getDefaultSource();
|
||||
}
|
||||
|
||||
return self::buildSourceView(self::normalizeSourceStored(self::defaultLocalSource()), true, false);
|
||||
}
|
||||
|
||||
public static function saveEnabled(bool $enabled): bool
|
||||
{
|
||||
if (self::proSourcesAvailable()) {
|
||||
return ProSources::saveEnabled($enabled);
|
||||
}
|
||||
|
||||
$raw = self::loadRaw();
|
||||
$raw['enabled'] = $enabled ? true : false;
|
||||
return self::saveRaw($raw);
|
||||
}
|
||||
|
||||
public static function upsertSource(array $source): array
|
||||
{
|
||||
if (self::proSourcesAvailable()) {
|
||||
return ProSources::upsertSource($source);
|
||||
}
|
||||
|
||||
$id = isset($source['id']) ? trim((string)$source['id']) : '';
|
||||
if ($id === '' || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id)) {
|
||||
return ['ok' => false, 'error' => 'Invalid source id'];
|
||||
}
|
||||
|
||||
$rawType = strtolower(trim((string)($source['type'] ?? 'local')));
|
||||
if (!self::isTypeAllowed($rawType)) {
|
||||
return ['ok' => false, 'error' => 'Source type requires FileRise Pro'];
|
||||
}
|
||||
|
||||
$raw = self::loadRaw();
|
||||
$sourceMap = self::rawSourceMap($raw);
|
||||
$existingRaw = isset($sourceMap[$id]) && is_array($sourceMap[$id]) ? $sourceMap[$id] : null;
|
||||
$existing = is_array($existingRaw) ? self::normalizeSourceStored($existingRaw) : null;
|
||||
|
||||
$normalized = self::normalizeSourceStored($source);
|
||||
if (!$normalized) {
|
||||
return ['ok' => false, 'error' => 'Invalid source configuration'];
|
||||
}
|
||||
|
||||
if (!array_key_exists('disableTrash', $source) && $existing && isset($existing['disableTrash'])) {
|
||||
$normalized['disableTrash'] = !empty($existing['disableTrash']);
|
||||
}
|
||||
|
||||
if ($normalized['type'] === 'webdav') {
|
||||
$cfgStore = $normalized['config'];
|
||||
$password = isset($source['config']['password']) ? trim((string)$source['config']['password']) : '';
|
||||
|
||||
if ($password !== '') {
|
||||
$cfgStore['passwordEnc'] = self::encryptSecret($password);
|
||||
} elseif ($existingRaw && isset($existingRaw['config']) && is_array($existingRaw['config']) && isset($existingRaw['config']['passwordEnc'])) {
|
||||
$cfgStore['passwordEnc'] = (string)$existingRaw['config']['passwordEnc'];
|
||||
}
|
||||
|
||||
if (empty($cfgStore['passwordEnc'])) {
|
||||
return ['ok' => false, 'error' => 'WebDAV requires a password'];
|
||||
}
|
||||
|
||||
$normalized['config'] = $cfgStore;
|
||||
}
|
||||
|
||||
$sourceMap[$id] = $normalized;
|
||||
$raw['sources'] = $sourceMap;
|
||||
|
||||
if (!self::saveRaw($raw)) {
|
||||
return ['ok' => false, 'error' => 'Failed to save sources'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'source' => self::buildSourceView($normalized, false, true)];
|
||||
}
|
||||
|
||||
public static function deleteSource(string $id): array
|
||||
{
|
||||
if (self::proSourcesAvailable()) {
|
||||
return ProSources::deleteSource($id);
|
||||
}
|
||||
|
||||
$id = trim($id);
|
||||
if ($id === '' || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id)) {
|
||||
return ['ok' => false, 'error' => 'Invalid source id'];
|
||||
}
|
||||
if ($id === self::DEFAULT_ID) {
|
||||
return ['ok' => false, 'error' => 'Cannot delete the default Local source'];
|
||||
}
|
||||
|
||||
$raw = self::loadRaw();
|
||||
$sourceMap = self::rawSourceMap($raw);
|
||||
if (!isset($sourceMap[$id])) {
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
unset($sourceMap[$id]);
|
||||
$raw['sources'] = $sourceMap;
|
||||
|
||||
if (!self::saveRaw($raw)) {
|
||||
return ['ok' => false, 'error' => 'Failed to delete source'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
private static function loadRaw(): array
|
||||
{
|
||||
$path = self::filePath();
|
||||
if ($path === '' || !is_file($path)) {
|
||||
return ['enabled' => false, 'sources' => []];
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
$data = is_string($raw) ? json_decode($raw, true) : null;
|
||||
return is_array($data) ? $data : ['enabled' => false, 'sources' => []];
|
||||
}
|
||||
|
||||
private static function saveRaw(array $cfg): bool
|
||||
{
|
||||
$path = self::filePath();
|
||||
if ($path === '') return false;
|
||||
self::ensureDir();
|
||||
|
||||
$json = json_encode($cfg, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) return false;
|
||||
|
||||
$tmp = $path . '.tmp';
|
||||
if (@file_put_contents($tmp, $json, LOCK_EX) === false) {
|
||||
return false;
|
||||
}
|
||||
if (!@rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
return false;
|
||||
}
|
||||
@chmod($path, 0644);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FileRise\Storage;
|
||||
|
||||
use Sabre\DAV\Client;
|
||||
use Sabre\DAV\Xml\Property\ResourceType;
|
||||
use Throwable;
|
||||
|
||||
require_once PROJECT_ROOT . '/src/lib/StorageAdapterInterface.php';
|
||||
|
||||
final class WebDavAdapter implements StorageAdapterInterface
|
||||
{
|
||||
private const WRITE_FALLBACK_MAX_BYTES = 5242880; // 5 MB
|
||||
|
||||
private Client $client;
|
||||
private string $baseUri;
|
||||
private string $basePath;
|
||||
private string $username;
|
||||
private string $password;
|
||||
private string $localRoot;
|
||||
private bool $verifyTls;
|
||||
private int $timeout;
|
||||
private string $lastError = '';
|
||||
/** @var array<string, array{ts:int, children:array<string, array{type:string,size:int,mtime:int,mode:int}>>> */
|
||||
private array $listCache = [];
|
||||
private int $listCacheTtl = 5;
|
||||
|
||||
private function __construct(
|
||||
string $baseUri,
|
||||
string $username,
|
||||
string $password,
|
||||
string $localRoot,
|
||||
bool $verifyTls,
|
||||
int $timeout
|
||||
) {
|
||||
$this->baseUri = rtrim($baseUri, '/');
|
||||
$basePath = (string)(parse_url($this->baseUri, PHP_URL_PATH) ?? '');
|
||||
$this->basePath = rtrim($basePath, '/');
|
||||
$this->username = $username;
|
||||
$this->password = $password;
|
||||
$this->localRoot = rtrim(str_replace('\\', '/', $localRoot), '/');
|
||||
$this->verifyTls = $verifyTls;
|
||||
$this->timeout = $timeout;
|
||||
|
||||
$settings = [
|
||||
'baseUri' => $this->baseUri . '/',
|
||||
'userName' => $username,
|
||||
'password' => $password,
|
||||
];
|
||||
|
||||
$this->client = new Client($settings);
|
||||
if (!$verifyTls) {
|
||||
$this->client->addCurlSetting(CURLOPT_SSL_VERIFYPEER, false);
|
||||
$this->client->addCurlSetting(CURLOPT_SSL_VERIFYHOST, 0);
|
||||
}
|
||||
if ($timeout > 0) {
|
||||
$this->client->addCurlSetting(CURLOPT_TIMEOUT, $timeout);
|
||||
$this->client->addCurlSetting(CURLOPT_CONNECTTIMEOUT, min(10, $timeout));
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromConfig(array $cfg, string $root): ?self
|
||||
{
|
||||
self::ensureSabreLoaded();
|
||||
$baseUrl = trim((string)($cfg['baseUrl'] ?? $cfg['url'] ?? ''));
|
||||
$username = trim((string)($cfg['username'] ?? ''));
|
||||
if ($baseUrl === '' || $username === '') {
|
||||
return null;
|
||||
}
|
||||
$password = (string)($cfg['password'] ?? '');
|
||||
$rootPath = trim((string)($cfg['root'] ?? $cfg['path'] ?? ''));
|
||||
$verifyTls = !isset($cfg['verifyTls']) || $cfg['verifyTls'] !== false;
|
||||
$timeout = (int)($cfg['timeout'] ?? 20);
|
||||
if ($timeout <= 0) {
|
||||
$timeout = 20;
|
||||
}
|
||||
|
||||
$baseUri = self::buildBaseUri($baseUrl, $rootPath);
|
||||
return new self($baseUri, $username, $password, $root, $verifyTls, $timeout);
|
||||
}
|
||||
|
||||
public function isLocal(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function testConnection(): bool
|
||||
{
|
||||
$status = $this->request('PROPFIND', $this->buildUrlForRelative(''), null, ['Depth' => '0']);
|
||||
if ($status >= 200 && $status < 300) {
|
||||
$this->lastError = '';
|
||||
return true;
|
||||
}
|
||||
if ($status === 401 || $status === 403) {
|
||||
$this->lastError = 'Auth failed (HTTP ' . $status . ')';
|
||||
} elseif ($status > 0) {
|
||||
$this->lastError = 'HTTP ' . $status;
|
||||
} elseif ($this->lastError === '') {
|
||||
$this->lastError = 'Connection failed';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getLastError(): string
|
||||
{
|
||||
return trim($this->lastError);
|
||||
}
|
||||
|
||||
public function list(string $path): array
|
||||
{
|
||||
$props = $this->propFind($path, 1);
|
||||
if (!$props) return [];
|
||||
$parentRel = trim($this->relativePath($path), '/');
|
||||
$items = [];
|
||||
$children = [];
|
||||
foreach ($props as $href => $prop) {
|
||||
$rel = trim($this->hrefToRelative((string)$href), '/');
|
||||
if ($rel === '' || $rel === $parentRel) continue;
|
||||
$name = basename($rel);
|
||||
if ($name === '' || $name === '.' || $name === '..') continue;
|
||||
$items[] = $name;
|
||||
$children[$name] = $this->propsToStat($prop);
|
||||
}
|
||||
$this->storeListCache($parentRel, $children);
|
||||
return array_values(array_unique($items));
|
||||
}
|
||||
|
||||
public function stat(string $path): ?array
|
||||
{
|
||||
$rel = $this->relativePath($path);
|
||||
if ($rel !== '') {
|
||||
$parentRel = trim(str_replace('\\', '/', dirname($rel)), '/');
|
||||
if ($parentRel === '.' || $parentRel === '') {
|
||||
$parentRel = '';
|
||||
}
|
||||
$base = basename($rel);
|
||||
$cached = $this->getListCache($parentRel);
|
||||
if ($cached !== null && isset($cached[$base])) {
|
||||
return $cached[$base];
|
||||
}
|
||||
}
|
||||
|
||||
$props = $this->propFind($path, 0);
|
||||
if (!$props) return null;
|
||||
|
||||
return $this->propsToStat($props);
|
||||
}
|
||||
|
||||
public function read(string $path, ?int $length = null, int $offset = 0): string|false
|
||||
{
|
||||
$stream = $this->openReadStream($path, $length, $offset);
|
||||
if ($stream === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_resource($stream)) {
|
||||
$data = ($length !== null)
|
||||
? stream_get_contents($stream, $length)
|
||||
: stream_get_contents($stream);
|
||||
fclose($stream);
|
||||
return ($data === false) ? false : $data;
|
||||
}
|
||||
|
||||
if (is_object($stream) && method_exists($stream, 'read')) {
|
||||
$data = $stream->read($length ?? 0);
|
||||
if (method_exists($stream, 'close')) {
|
||||
$stream->close();
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function openReadStream(string $path, ?int $length = null, int $offset = 0)
|
||||
{
|
||||
$url = $this->buildUrlForPath($path);
|
||||
if ($url === '') return false;
|
||||
|
||||
$headers = [];
|
||||
if ($this->username !== '') {
|
||||
$headers[] = 'Authorization: Basic ' . base64_encode($this->username . ':' . $this->password);
|
||||
}
|
||||
if ($offset > 0 || $length !== null) {
|
||||
$end = ($length !== null && $length > 0) ? ($offset + $length - 1) : '';
|
||||
$headers[] = 'Range: bytes=' . $offset . '-' . $end;
|
||||
}
|
||||
|
||||
$opts = [
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => implode("\r\n", $headers),
|
||||
'ignore_errors' => true,
|
||||
'timeout' => $this->timeout,
|
||||
],
|
||||
];
|
||||
if (!$this->verifyTls) {
|
||||
$opts['ssl'] = [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$context = stream_context_create($opts);
|
||||
$fp = @fopen($url, 'rb', false, $context);
|
||||
return $fp ?: false;
|
||||
}
|
||||
|
||||
public function write(string $path, string $data, int $flags = 0): bool
|
||||
{
|
||||
if (!$this->ensureParentExists($path)) return false;
|
||||
$url = $this->buildUrlForPath($path);
|
||||
if ($url === '') return false;
|
||||
$status = $this->request('PUT', $url, $data, []);
|
||||
return $status >= 200 && $status < 300;
|
||||
}
|
||||
|
||||
public function writeStream(string $path, $stream, ?int $length = null, ?string $mimeType = null): bool
|
||||
{
|
||||
if (!is_resource($stream)) return false;
|
||||
if (!$this->ensureParentExists($path)) return false;
|
||||
$url = $this->buildUrlForPath($path);
|
||||
if ($url === '') return false;
|
||||
$headers = [];
|
||||
if ($mimeType) {
|
||||
$headers['Content-Type'] = $mimeType;
|
||||
}
|
||||
$meta = @stream_get_meta_data($stream);
|
||||
$seekable = is_array($meta) && !empty($meta['seekable']);
|
||||
if ($length === null) {
|
||||
$stat = @fstat($stream);
|
||||
if (is_array($stat) && isset($stat['size'])) {
|
||||
$length = (int)$stat['size'];
|
||||
}
|
||||
}
|
||||
if ($length !== null && $length >= 0) {
|
||||
$headers['Content-Length'] = (string)$length;
|
||||
}
|
||||
if ($seekable) {
|
||||
@rewind($stream);
|
||||
}
|
||||
$status = $this->request('PUT', $url, $stream, $headers);
|
||||
if ($status >= 200 && $status < 300) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($seekable && $length !== null && $length <= self::WRITE_FALLBACK_MAX_BYTES) {
|
||||
@rewind($stream);
|
||||
$data = stream_get_contents($stream);
|
||||
if ($data !== false) {
|
||||
$status = $this->request('PUT', $url, $data, $headers);
|
||||
return $status >= 200 && $status < 300;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function move(string $from, string $to): bool
|
||||
{
|
||||
$src = $this->buildUrlForPath($from);
|
||||
$dst = $this->buildUrlForPath($to);
|
||||
if ($src === '' || $dst === '') return false;
|
||||
$status = $this->request('MOVE', $src, null, [
|
||||
'Destination' => $dst,
|
||||
'Overwrite' => 'T',
|
||||
]);
|
||||
return $status >= 200 && $status < 300;
|
||||
}
|
||||
|
||||
public function copy(string $from, string $to): bool
|
||||
{
|
||||
$src = $this->buildUrlForPath($from);
|
||||
$dst = $this->buildUrlForPath($to);
|
||||
if ($src === '' || $dst === '') return false;
|
||||
$status = $this->request('COPY', $src, null, [
|
||||
'Destination' => $dst,
|
||||
'Overwrite' => 'T',
|
||||
]);
|
||||
return $status >= 200 && $status < 300;
|
||||
}
|
||||
|
||||
public function delete(string $path): bool
|
||||
{
|
||||
$url = $this->buildUrlForPath($path);
|
||||
if ($url === '') return false;
|
||||
$status = $this->request('DELETE', $url, null, []);
|
||||
return $status >= 200 && $status < 300;
|
||||
}
|
||||
|
||||
public function mkdir(string $path, int $mode = 0775, bool $recursive = true): bool
|
||||
{
|
||||
$rel = trim($this->relativePath($path), '/');
|
||||
if ($rel === '') return true;
|
||||
$parts = array_values(array_filter(explode('/', $rel), fn($p) => $p !== ''));
|
||||
if (!$parts) return true;
|
||||
|
||||
$acc = '';
|
||||
foreach ($parts as $part) {
|
||||
$acc = ($acc === '') ? $part : ($acc . '/' . $part);
|
||||
if (!$recursive && $acc !== $rel) continue;
|
||||
$url = $this->buildUrlForRelative($acc);
|
||||
$status = $this->request('MKCOL', $url, null, []);
|
||||
if ($status >= 200 && $status < 300) {
|
||||
continue;
|
||||
}
|
||||
if ($status === 405) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function ensureSabreLoaded(): void
|
||||
{
|
||||
if (!class_exists(Client::class)) {
|
||||
$autoload = PROJECT_ROOT . '/vendor/autoload.php';
|
||||
if (is_file($autoload)) {
|
||||
require_once $autoload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function buildBaseUri(string $baseUrl, string $rootPath): string
|
||||
{
|
||||
$base = rtrim($baseUrl, '/');
|
||||
$root = trim($rootPath, '/');
|
||||
if ($root === '') {
|
||||
return $base;
|
||||
}
|
||||
$encoded = self::encodePath($root);
|
||||
return $base . '/' . $encoded;
|
||||
}
|
||||
|
||||
private static function encodePath(string $path): string
|
||||
{
|
||||
$trimmed = trim($path, '/');
|
||||
if ($trimmed === '') return '';
|
||||
$parts = array_map('rawurlencode', explode('/', $trimmed));
|
||||
return implode('/', $parts);
|
||||
}
|
||||
|
||||
private function buildUrlForRelative(string $rel): string
|
||||
{
|
||||
$rel = trim($rel, '/');
|
||||
if ($rel === '') {
|
||||
return $this->baseUri;
|
||||
}
|
||||
return $this->baseUri . '/' . self::encodePath($rel);
|
||||
}
|
||||
|
||||
private function buildUrlForPath(string $path): string
|
||||
{
|
||||
$rel = $this->relativePath($path);
|
||||
return $this->buildUrlForRelative($rel);
|
||||
}
|
||||
|
||||
private function relativePath(string $path): string
|
||||
{
|
||||
$p = str_replace('\\', '/', $path);
|
||||
$root = $this->localRoot;
|
||||
if ($root !== '' && str_starts_with($p, $root)) {
|
||||
$p = substr($p, strlen($root));
|
||||
}
|
||||
return ltrim($p, '/');
|
||||
}
|
||||
|
||||
private function hrefToRelative(string $href): string
|
||||
{
|
||||
$path = (string)(parse_url($href, PHP_URL_PATH) ?? '');
|
||||
$path = rawurldecode($path);
|
||||
$base = $this->basePath;
|
||||
if ($base !== '' && $base !== '/' && str_starts_with($path, $base)) {
|
||||
$path = substr($path, strlen($base));
|
||||
}
|
||||
return ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function propFind(string $path, int $depth): array
|
||||
{
|
||||
$url = $this->buildUrlForPath($path);
|
||||
if ($url === '') return [];
|
||||
try {
|
||||
return $this->client->propFind($url, [
|
||||
'{DAV:}displayname',
|
||||
'{DAV:}resourcetype',
|
||||
'{DAV:}getcontentlength',
|
||||
'{DAV:}getlastmodified',
|
||||
], $depth);
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function getListCache(string $rel): ?array
|
||||
{
|
||||
if (!isset($this->listCache[$rel])) {
|
||||
return null;
|
||||
}
|
||||
$entry = $this->listCache[$rel];
|
||||
$ts = (int)($entry['ts'] ?? 0);
|
||||
if ($ts <= 0 || (time() - $ts) > $this->listCacheTtl) {
|
||||
unset($this->listCache[$rel]);
|
||||
return null;
|
||||
}
|
||||
$children = $entry['children'] ?? null;
|
||||
return is_array($children) ? $children : null;
|
||||
}
|
||||
|
||||
private function storeListCache(string $rel, array $children): void
|
||||
{
|
||||
$this->listCache[$rel] = [
|
||||
'ts' => time(),
|
||||
'children' => $children,
|
||||
];
|
||||
}
|
||||
|
||||
private function propsToStat(array $props): array
|
||||
{
|
||||
$type = 'file';
|
||||
$resType = $props['{DAV:}resourcetype'] ?? null;
|
||||
if ($this->isCollection($resType)) {
|
||||
$type = 'dir';
|
||||
}
|
||||
|
||||
$size = isset($props['{DAV:}getcontentlength']) ? (int)$props['{DAV:}getcontentlength'] : 0;
|
||||
$mtimeRaw = (string)($props['{DAV:}getlastmodified'] ?? '');
|
||||
$mtime = $mtimeRaw !== '' ? (int)(strtotime($mtimeRaw) ?: 0) : 0;
|
||||
|
||||
return [
|
||||
'type' => $type,
|
||||
'size' => $size,
|
||||
'mtime' => $mtime,
|
||||
'mode' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function request(string $method, string $url, $body = null, array $headers = []): int
|
||||
{
|
||||
try {
|
||||
$resp = $this->client->request($method, $url, $body, $headers);
|
||||
$status = (int)($resp['statusCode'] ?? 0);
|
||||
if ($status < 200 || $status >= 300) {
|
||||
$msg = trim((string)($resp['body'] ?? ''));
|
||||
if ($msg !== '') {
|
||||
$msg = preg_replace('/\\s+/', ' ', $msg);
|
||||
if (strlen($msg) > 240) {
|
||||
$msg = substr($msg, 0, 240) . '...';
|
||||
}
|
||||
}
|
||||
$this->lastError = $msg !== '' ? ('HTTP ' . $status . ': ' . $msg) : ('HTTP ' . $status);
|
||||
} else {
|
||||
$this->lastError = '';
|
||||
}
|
||||
return $status;
|
||||
} catch (Throwable $e) {
|
||||
$msg = trim($e->getMessage());
|
||||
if ($msg !== '') {
|
||||
$msg = preg_replace('/(https?:\\/\\/)([^\\s@]+@)/i', '$1', $msg);
|
||||
$this->lastError = $msg;
|
||||
} else {
|
||||
$this->lastError = 'WebDAV request failed';
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function isCollection($value): bool
|
||||
{
|
||||
if ($value instanceof ResourceType) {
|
||||
return $value->is('{DAV:}collection');
|
||||
}
|
||||
if (is_array($value)) {
|
||||
return in_array('{DAV:}collection', $value, true);
|
||||
}
|
||||
if (is_string($value)) {
|
||||
return stripos($value, 'collection') !== false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function ensureParentExists(string $path): bool
|
||||
{
|
||||
$rel = trim($this->relativePath($path), '/');
|
||||
if ($rel === '') return true;
|
||||
$parent = trim(str_replace('\\', '/', dirname($rel)), '/');
|
||||
if ($parent === '' || $parent === '.') {
|
||||
return true;
|
||||
}
|
||||
$parentPath = $this->localRoot !== '' ? ($this->localRoot . '/' . $parent) : $parent;
|
||||
return $this->mkdir($parentPath, 0775, true);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../FileRise/Storage/SourcesConfig.php';
|
||||
|
||||
$original = '\\FileRise\\Storage\\SourcesConfig';
|
||||
$alias = 'SourcesConfig';
|
||||
if (!class_exists($alias, false) && !interface_exists($alias, false)) {
|
||||
if (class_exists($original, false) || interface_exists($original, false)) {
|
||||
class_alias($original, $alias);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../FileRise/Storage/WebDavAdapter.php';
|
||||
|
||||
$original = '\\FileRise\\Storage\\WebDavAdapter';
|
||||
$alias = 'WebDavAdapter';
|
||||
if (!class_exists($alias, false) && !interface_exists($alias, false)) {
|
||||
if (class_exists($original, false) || interface_exists($original, false)) {
|
||||
class_alias($original, $alias);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user