From d00db803c3d288a18b960b80cc3cc4a2fca5d95a Mon Sep 17 00:00:00 2001 From: Ryan Date: Mon, 3 Nov 2025 16:39:48 -0500 Subject: [PATCH] release(v1.8.0): feat(onlyoffice): first-class ONLYOFFICE integration (view/edit), admin UI, API, CSP helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #37 — implements ONLYOFFICE integration suggested in the discussion; video progress saving will be tracked separately. --- CHANGELOG.md | 40 +++ README.md | 60 +++- config/config.php | 7 + public/.htaccess | 75 +++-- public/api/onlyoffice/callback.php | 13 + public/api/onlyoffice/config.php | 17 + public/api/onlyoffice/signed-download.php | 15 + public/api/onlyoffice/status.php | 13 + public/css/styles.css | 4 +- public/js/adminPanel.js | 307 ++++++++++++++++- public/js/fileEditor.js | 138 +++++++- public/js/fileListView.js | 80 +++-- src/controllers/AdminController.php | 81 ++++- src/controllers/OnlyOfficeController.php | 383 ++++++++++++++++++++++ src/models/AdminModel.php | 112 +++++-- 15 files changed, 1240 insertions(+), 105 deletions(-) create mode 100644 public/api/onlyoffice/callback.php create mode 100644 public/api/onlyoffice/config.php create mode 100644 public/api/onlyoffice/signed-download.php create mode 100644 public/api/onlyoffice/status.php create mode 100644 src/controllers/OnlyOfficeController.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 07849c9..ae943c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## Changes 11/3/2025 (v1.8.0) + +release(v1.8.0): feat(onlyoffice): first-class ONLYOFFICE integration (view/edit), admin UI, API, CSP helpers + +Refs #37 — implements ONLYOFFICE integration suggested in the discussion; video progress saving will be tracked separately. + +Adds secure, ACL-aware ONLYOFFICE support throughout FileRise: + +- **Backend / API** + - New OnlyOfficeController with supported extensions (doc/xls/ppt/pdf etc.), status/config endpoints, and signed download flow. + - New endpoints: + - GET /api/onlyoffice/status.php — reports availability + supported exts. + - GET /api/onlyoffice/config.php — returns DocEditor config (signed URLs, callback). + - GET /api/onlyoffice/signed-download.php — serves signed blobs to DS. + - Effective config/overrides: env/constant wins; supports docsOrigin, publicOrigin, and jwtSecret; status gated on presence of origin+secret. + - Public origin resolution (BASE_URL/proxy aware) for absolute URLs. + +- **Admin config / UI** + - AdminPanel gets a new “ONLYOFFICE” section with Enable toggle, Document Server Origin, masked JWT Secret, and “Replace” control. + - Built-in connection tester (status, secret presence, callback ping, api.js load, iframe embed) + CSP helper (Apache & Nginx snippets) + +- **Frontend integration** + - fileEditor detects OO capability via /api/onlyoffice/status and routes supported types to the DocEditor; loads DocsAPI dynamically. + - editFile() short-circuits to openOnlyOffice when applicable; includes live dark/light theme sync where supported. + - fileListView pulls status once on load to drive UI decisions (e.g., editing affordances). + +- **AdminModel / config** + - Adds onlyoffice {enabled, docsOrigin, publicOrigin} defaults and update path, with jwtSecret persisted (kept unless explicitly replaced). + - Optional constants in config.php to override and debug. + +- **Security & UX notes** + - Editor access remains ACL-checked (read/edit) and uses absolute, signed URLs surfaced via controller. + - Admin UI never echoes secrets; “Replace” toggles explicit updates only. + - CSP helper makes it straightforward to permit api.js + iframe + XHR to your DS. + +- **Docs/Styling** + - Minor CSS touch-ups around hover states and modal layout. + +--- + ## Changes 11/2/2025 (v1.7.5) release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50) diff --git a/README.md b/README.md index bc08f13..e240477 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-❤-red)](https://github.com/sponsors/error311) [![Support on Ko-fi](https://img.shields.io/badge/Ko--fi-Buy%20me%20a%20coffee-orange)](https://ko-fi.com/error311) -**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [FAQ](#faq--troubleshooting) +**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [ONLYOFFICE](#quick-start-onlyoffice-optional) • [FAQ](#faq--troubleshooting) **Elevate your File Management** – A modern, self-hosted web file manager. Upload, organize, and share files or folders through a sleek, responsive web interface. @@ -21,6 +21,8 @@ Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted. +New: Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **PowerPoint (PPTX)** — directly in **FileRise** using your self-hosted **ONLYOFFICE Document Server** (optional). Open **ODT/ODS/ODP**, and view **PDFs** inline. Where supported by your Document Server, users can add **comments/annotations** to documents (and PDFs). Everything is enforced by the same per-folder ACLs across the UI and WebDAV. + > ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade. **10/25/2025 Video demo:** @@ -74,6 +76,8 @@ With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2 - 📝 **Built-in Editor & Preview:** Inline preview for images, video, audio, and PDFs. CodeMirror-based editor for text/code with syntax highlighting and line numbers. +- - 🧩 **Office Docs (ONLYOFFICE, optional):** View/edit DOCX, XLSX, PPTX (and ODT/ODS/ODP, PDF view) using your self-hosted ONLYOFFICE Document Server. Enforced by the same ACLs as the web UI & WebDAV. + - 🏷️ **Tags & Search:** Add color-coded tags and search by name, tag, uploader, or content. Advanced fuzzy search indexes metadata and file contents. - 🔒 **Authentication & SSO:** Username/password, optional TOTP 2FA, and OIDC (Google, Authentik, Keycloak). @@ -342,8 +346,48 @@ https://your-host/webdav.php/ --- +## Quick start: ONLYOFFICE (optional) + +FileRise can open & edit office docs using your **self-hosted ONLYOFFICE Document Server**. + +**What you need** + +- A reachable ONLYOFFICE Document Server (Community/Enterprise). +- A shared **JWT secret** used by FileRise and your Document Server. + +**Setup (2–3 minutes)** + +1. In FileRise go to **Admin → ONLYOFFICE** and: + - ✅ Enable ONLYOFFICE + - 🔗 Set **Document Server Origin** (e.g., `https://docs.example.com`) + - 🔑 Enter **JWT Secret** (click “Replace” to set) +2. (Recommended) Click **Run tests** in the ONLYOFFICE card: + - Checks FileRise status, callback reachability, `api.js` load, and iframe embed. +3. Update your **Content-Security-Policy** to allow the DS origin. + The Admin panel shows a ready-to-copy line for Apache & Nginx. Example: + + **Apache** + + ```apache + Header always set Content-Security-Policy "default-src 'self'; frame-src 'self' https://docs.example.com; script-src 'self' https://docs.example.com https://docs.example.com/web-apps/apps/api/documents/api.js; connect-src 'self' https://docs.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'" + ``` + + **Nginx** + + ```add_header Content-Security-Policy "default-src 'self'; frame-src 'self' https://docs.example.com; script-src 'self' https://docs.example.com https://docs.example.com/web-apps/apps/api/documents/api.js; connect-src 'self' https://docs.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'" always; + ``` + + **Notes** + - If your site is https://, your Document Server must also be https:// (or the browser will block it as mixed content). + - Editor access respects FileRise ACLs (view/edit/share) exactly like the rest of the app. + +--- + ## FAQ / Troubleshooting +- **ONLYOFFICE editor won’t load / blank frame:** Verify CSP allows your DS origin (`script-src`, `frame-src`, `connect-src`) and that the DS is reachable over HTTPS if your site is HTTPS. +- **“Disabled — check JWT Secret / Origin” in tests:** In **Admin → ONLYOFFICE**, set the Document Server Origin and click “Replace” to save a JWT secret. Then re-run tests. + - **“Upload failed” or large files not uploading:** Ensure `TOTAL_UPLOAD_SIZE` in config and PHP’s `post_max_size` / `upload_max_filesize` are set high enough. For extremely large files, you might need to increase `max_execution_time` or rely on resumable uploads in smaller chunks. - **How to enable HTTPS?** FileRise doesn’t terminate TLS itself. Run it behind a reverse proxy (Nginx, Caddy, Apache with SSL) or use a companion like nginx-proxy or Caddy in Docker. Set `SECURE="true"` in Docker so FileRise generates HTTPS links. @@ -399,6 +443,20 @@ Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you! ## Dependencies +### ONLYOFFICE integration + +FileRise can open office documents using a self-hosted ONLYOFFICE Document Server. + +- **We do not bundle ONLYOFFICE.** Admins point FileRise to an existing ONLYOFFICE Docs server and (optionally) set a JWT secret in **Admin > ONLYOFFICE**. +- **Licensing:** ONLYOFFICE Document Server (Community Edition) is released under the GNU AGPL v3. Enterprise editions are commercially licensed. When you deploy ONLYOFFICE, you are responsible for complying with the license of the edition you use. + – Project page & license: (AGPL-3.0) +- **FileRise license unaffected:** FileRise communicates with ONLYOFFICE over standard HTTP and loads `api.js` from the configured Document Server at runtime; FileRise does not redistribute ONLYOFFICE code. +- **Trademarks:** ONLYOFFICE is a trademark of Ascensio System SIA. FileRise is not affiliated with or endorsed by ONLYOFFICE. + +#### Security / CSP + +If you enable ONLYOFFICE, allow its origin in your CSP (`script-src`, `frame-src`, `connect-src`). The Admin panel shows a ready-to-copy line for Apache/Nginx. + ### PHP Libraries - **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0) diff --git a/config/config.php b/config/config.php index 5f489bf..109647c 100644 --- a/config/config.php +++ b/config/config.php @@ -25,6 +25,13 @@ if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true); if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false); define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json'); define('ACL_INHERIT_ON_CREATE', true); +// ONLYOFFICE integration overrides (uncomment and set as needed) +/* +define('ONLYOFFICE_ENABLED', false); +define('ONLYOFFICE_JWT_SECRET', 'test123456'); +define('ONLYOFFICE_DOCS_ORIGIN', 'http://192.168.1.61'); // your Document Server +define('ONLYOFFICE_DEBUG', true); +*/ // Encryption helpers function encryptData($data, $encryptionKey) diff --git a/public/.htaccess b/public/.htaccess index bbd42d1..e8a1636 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -1,38 +1,46 @@ # -------------------------------- -# Base: safe in most environments +# FileRise portable .htaccess # -------------------------------- Options -Indexes DirectoryIndex index.html - + # Block dotfiles like .env, .git, etc., but allow ACME under .well-known + Require all denied +# ---------------- Rewrites ---------------- + RewriteEngine On + # Never redirect local/dev hosts RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC] RewriteRule ^ - [L] -# --- HTTPS redirect --- -# Use ONE of these blocks. +# Let ACME http-01 pass BEFORE any redirect (needed for auto-renew) +RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/ +RewriteRule - - [L] -# A) Direct TLS on this server (enable this if Apache terminates HTTPS here) -#RewriteCond %{HTTPS} off +# HTTPS redirect (enable ONE of these, comment the other) + +# A) Direct TLS on this server +#RewriteCond %{HTTPS} !=on #RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] -# B) Behind a reverse proxy/CDN that sets X-Forwarded-Proto +# B) Behind reverse proxy that sets X-Forwarded-Proto #RewriteCond %{HTTP:X-Forwarded-Proto} =http [OR] #RewriteCond %{HTTP:X-Forwarded-Proto} ^$ #RewriteCond %{HTTPS} !=on #RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] -# Don't interfere with ACME/http-01 if you do your own certs -#RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/ -#RewriteRule - - [L] +# Mark versioned assets (?v=...) with env flag for caching rules below +RewriteCond %{QUERY_STRING} (^|&)v= [NC] +RewriteRule ^ - [E=IS_VER:1] + -# --- MIME types (fonts/SVG/ESM) --- +# ---------------- MIME types ---------------- AddType font/woff2 .woff2 AddType font/woff .woff @@ -40,7 +48,7 @@ RewriteRule ^ - [L] AddType application/javascript .mjs -# --- Security headers --- +# ---------------- Security headers ---------------- Header always set X-Frame-Options "SAMEORIGIN" Header always set X-XSS-Protection "1; mode=block" @@ -51,59 +59,54 @@ RewriteRule ^ - [L] Header always set Expect-CT "max-age=86400, enforce" Header always set Cross-Origin-Resource-Policy "same-origin" Header always set X-Permitted-Cross-Domain-Policies "none" - # HSTS only when actually on HTTPS - Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" "expr=%{HTTPS} == 'on'" - # CSP (modules, blobs, workers, etc.) + # 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'" -# --- Caching (query-string based, no env vars needed) --- +# ---------------- Caching ---------------- - # HTML/PHP: no cache (only if PHP didn’t already set it) + # HTML/PHP: no cache Header setifempty Cache-Control "no-cache, no-store, must-revalidate" Header setifempty Pragma "no-cache" Header setifempty Expires "0" - # version.js: always non-cacheable + # version.js: never cache Header set Cache-Control "no-cache, no-store, must-revalidate" Header set Pragma "no-cache" Header set Expires "0" - # Unversioned JS/CSS: 1 hour + # JS/CSS: long cache if ?v= present, else 1h - Header set Cache-Control "public, max-age=3600, must-revalidate" "expr=%{QUERY_STRING} !~ /(^|&)v=/" + Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER + Header set Cache-Control "public, max-age=3600, must-revalidate" env=!IS_VER - # Unversioned static (images/fonts): 7 days + # Images/fonts: long cache if ?v= present, else 7d - Header set Cache-Control "public, max-age=604800" "expr=%{QUERY_STRING} !~ /(^|&)v=/" + Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER + Header set Cache-Control "public, max-age=604800" env=!IS_VER - - # --- Versioned assets (?v=...) : 1 year + immutable (override anything else) --- - - - # Only when query string has v= - Header unset Cache-Control "expr=%{QUERY_STRING} =~ /(^|&)v=/" - Header unset Expires "expr=%{QUERY_STRING} =~ /(^|&)v=/" - Header set Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable" "expr=%{QUERY_STRING} =~ /(^|&)v=/" - - -# --- Compression --- +# ---------------- Compression ---------------- - BrotliCompressionQuality 5 + # Do NOT set BrotliCompressionQuality in .htaccess (vhost/server only) AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml -# --- Disable TRACE --- +# ---------------- Disable TRACE ---------------- + RewriteCond %{REQUEST_METHOD} ^TRACE -RewriteRule .* - [F] \ No newline at end of file +RewriteRule .* - [F] + \ No newline at end of file diff --git a/public/api/onlyoffice/callback.php b/public/api/onlyoffice/callback.php new file mode 100644 index 0000000..f672099 --- /dev/null +++ b/public/api/onlyoffice/callback.php @@ -0,0 +1,13 @@ +callback(); \ No newline at end of file diff --git a/public/api/onlyoffice/config.php b/public/api/onlyoffice/config.php new file mode 100644 index 0000000..de70837 --- /dev/null +++ b/public/api/onlyoffice/config.php @@ -0,0 +1,17 @@ +config(); \ No newline at end of file diff --git a/public/api/onlyoffice/signed-download.php b/public/api/onlyoffice/signed-download.php new file mode 100644 index 0000000..566aa4f --- /dev/null +++ b/public/api/onlyoffice/signed-download.php @@ -0,0 +1,15 @@ +signedDownload(); \ No newline at end of file diff --git a/public/api/onlyoffice/status.php b/public/api/onlyoffice/status.php new file mode 100644 index 0000000..ffc8b3c --- /dev/null +++ b/public/api/onlyoffice/status.php @@ -0,0 +1,13 @@ +status(); \ No newline at end of file diff --git a/public/css/styles.css b/public/css/styles.css index d76c0c1..2b39410 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -969,7 +969,7 @@ body { align-items: stretch; }.file-list-actions .action-btn { width: 100%; - height: 10px !important; + }.modal-content { width: 95%; margin: 20% auto; @@ -996,7 +996,7 @@ body { #copySelectedBtn:hover, #moveSelectedBtn:hover, #downloadZipBtn:hover, - #extractZipBtn:hover + #extractZipBtn:hover, #customChooseBtn:hover { transform: scale(1.08); box-shadow: 0 2px 10px rgba(0,0,0,.12); diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js index a3e06af..5330c28 100644 --- a/public/js/adminPanel.js +++ b/public/js/adminPanel.js @@ -491,6 +491,7 @@ export function openAdminPanel() { { id: "headerSettings", label: t("header_settings") }, { id: "loginOptions", label: t("login_options") }, { id: "webdav", label: "WebDAV Access" }, + { id: "onlyoffice", label: "ONLYOFFICE" }, { id: "upload", label: t("shared_max_upload_size_bytes_title") }, { id: "oidc", label: t("oidc_configuration") + " & TOTP" }, { id: "shareLinks", label: t("manage_shared_links") }, @@ -514,7 +515,7 @@ export function openAdminPanel() { document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel); document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel); - ["userManagement", "headerSettings", "loginOptions", "webdav", "upload", "oidc", "shareLinks", "sponsor"] + ["userManagement", "headerSettings", "loginOptions", "webdav", "onlyoffice", "upload", "oidc", "shareLinks", "sponsor"] .forEach(id => { document.getElementById(id + "Header") .addEventListener("click", () => toggleSection(id)); @@ -574,6 +575,268 @@ export function openAdminPanel() { `; + // ONLYOFFICE Content + const hasOOSecret = !!(config.onlyoffice && config.onlyoffice.hasJwtSecret); + window.__HAS_OO_SECRET = hasOOSecret; + document.getElementById("onlyofficeContent").innerHTML = ` +
+ + +
+ +
+ + + Must be reachable by your browser (for API.js) and by FileRise (for callbacks). Avoid “localhost”. +
+ + ${renderMaskedInput({ id: "ooJwtSecret", label: "JWT Secret", hasValue: hasOOSecret, isSecret: true })} +`; + + wireReplaceButtons(document.getElementById("onlyofficeContent")); + + + + + + // --- Test ONLYOFFICE block --- + const testBox = document.createElement("div"); + testBox.className = "card"; + testBox.style.marginTop = "12px"; + testBox.innerHTML = ` +
+
+ Test ONLYOFFICE connection + + +
+
    + These tests check FileRise config, callback reachability, CSP/script loading, and iframe embedding. +
    + `; + document.getElementById("onlyofficeContent").appendChild(testBox); + + // Util: tiny UI helpers for results + function ooRow(label, status, detail = "") { + const li = document.createElement("li"); + li.style.margin = "6px 0"; + const icon = status === "ok" ? "✅" : status === "warn" ? "⚠️" : "❌"; + li.innerHTML = `${icon} ${label}${detail ? ` — ${detail}` : ""}`; + return li; + } + function ooClear(el) { while (el.firstChild) el.removeChild(el.firstChild); } + + // Probes that don’t explode your state + async function ooProbeScript(docsOrigin) { + return new Promise(resolve => { + const src = docsOrigin.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js?probe=' + Date.now(); + const s = document.createElement('script'); + s.id = 'ooProbeScript'; + s.async = true; + s.src = src; + s.onload = () => { resolve({ ok: true }); setTimeout(() => s.remove(), 0); }; + s.onerror = () => { resolve({ ok: false }); setTimeout(() => s.remove(), 0); }; + document.head.appendChild(s); + }); + } + async function ooProbeFrame(docsOrigin, timeoutMs = 4000) { + return new Promise(resolve => { + const f = document.createElement('iframe'); + f.id = 'ooProbeFrame'; + f.src = docsOrigin; + f.style.display = 'none'; + let t = setTimeout(() => { cleanup(); resolve({ ok: false, timeout: true }); }, timeoutMs); + function cleanup() { try { f.remove(); } catch { } clearTimeout(t); } + f.onload = () => { cleanup(); resolve({ ok: true }); }; + f.onerror = () => { cleanup(); resolve({ ok: false }); }; + document.body.appendChild(f); + }); + } + + // Main test runner + async function runOnlyOfficeTests() { + const spinner = document.getElementById('ooTestSpinner'); + const out = document.getElementById('ooTestResults'); + const docsOrigin = (document.getElementById('ooDocsOrigin')?.value || '').trim(); + + spinner.style.display = 'inline'; + ooClear(out); + + // 1) FileRise status + let statusOk = false, statusJson = null; + try { + const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' }); + statusJson = await r.json().catch(() => ({})); + if (r.ok) { + if (statusJson.enabled) { + out.appendChild(ooRow('FileRise status', 'ok', 'Enabled and ready')); + statusOk = true; + } else { + // Disabled usually means missing secret or origin; we’ll dig deeper below. + out.appendChild(ooRow('FileRise status', 'warn', 'Disabled — check JWT Secret and Document Server Origin')); + } + } else { + out.appendChild(ooRow('FileRise status', 'fail', `HTTP ${r.status}`)); + } + } catch (e) { + out.appendChild(ooRow('FileRise status', 'fail', (e && e.message) || 'Network error')); + } + + // 2) Secret presence (fresh read) + try { + const cfg = await fetch('/api/admin/getConfig.php', { credentials: 'include', cache: 'no-store' }).then(r => r.json()); + const hasSecret = !!(cfg.onlyoffice && cfg.onlyoffice.hasJwtSecret); + out.appendChild(ooRow('JWT secret saved', hasSecret ? 'ok' : 'fail', hasSecret ? 'Present' : 'Missing')); + } catch { + out.appendChild(ooRow('JWT secret saved', 'warn', 'Could not verify')); + } + + // 3) Callback reachable (basic ping) + try { + const r = await fetch('/api/onlyoffice/callback.php?ping=1', { credentials: 'include', cache: 'no-store' }); + if (r.ok) out.appendChild(ooRow('Callback endpoint', 'ok', 'Reachable')); + else out.appendChild(ooRow('Callback endpoint', 'fail', `HTTP ${r.status}`)); + } catch { + out.appendChild(ooRow('Callback endpoint', 'fail', 'Network error')); + } + + // Early sanity on origin + if (!/^https?:\/\//i.test(docsOrigin)) { + out.appendChild(ooRow('Document Server Origin', 'fail', 'Enter a valid http(s) origin (e.g., https://docs.example.com)')); + spinner.style.display = 'none'; + return; + } + + // 4a) Can browser load api.js (also surfaces CSP script-src issues) + const sRes = await ooProbeScript(docsOrigin); + out.appendChild(ooRow('Load api.js', sRes.ok ? 'ok' : 'fail', sRes.ok ? 'Loaded' : 'Blocked (check CSP script-src and origin)')); + + // 4b) Can browser embed DS in an iframe (CSP frame-src) + const fRes = await ooProbeFrame(docsOrigin); + out.appendChild(ooRow('Embed DS iframe', fRes.ok ? 'ok' : 'fail', fRes.ok ? 'Allowed' : 'Blocked (check CSP frame-src)')); + + // Optional tip if we see common red flags + if (!statusOk || !sRes.ok || !fRes.ok) { + const tip = document.createElement('li'); + tip.style.marginTop = '8px'; + tip.innerHTML = "💡 Tip: Use the CSP helper above to include your Document Server in script-src, connect-src, and frame-src."; + out.appendChild(tip); + } + + spinner.style.display = 'none'; + } + + // Wire the button + document.getElementById('ooTestBtn')?.addEventListener('click', runOnlyOfficeTests); + + + + // Append CSP help box + // --- CSP help box (replace your whole block with this) --- + const ooSec = document.getElementById("onlyofficeContent"); + const cspHelp = document.createElement("div"); + cspHelp.className = "alert alert-info"; + cspHelp.style.marginTop = "12px"; + cspHelp.innerHTML = ` +
    + Content-Security-Policy help + + +
    +
    + Add/replace this line in public/.htaccess (Apache). It allows loading ONLYOFFICE's api.js, + embedding the editor iframe, and letting the script make XHR to your Document Server. +
    +
    
    +  
    + If you terminate SSL or set CSP at a reverse proxy (e.g. Nginx), update it there instead. + Also note: if your site is https://, your ONLYOFFICE server must be https:// too, + otherwise the browser will block it as mixed content. +
    +
    + Nginx equivalent +
    
    +  
    +`; + ooSec.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}"`; + } + function buildCspNginx(originRaw) { + const o = (originRaw || "https://your-onlyoffice-server.example.com").replace(/\/+$/, ''); + const api = `${o}/web-apps/apps/api/documents/api.js`; + return `add_header 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}" always;`; + } + + const ooDocsInput = document.getElementById("ooDocsOrigin"); + const cspPre = document.getElementById("ooCspSnippet"); + const cspPreNgx = document.getElementById("ooCspSnippetNginx"); + + function refreshCsp() { + const val = (ooDocsInput?.value || "").trim(); + cspPre.textContent = buildCspApache(val); + cspPreNgx.textContent = buildCspNginx(val); + } + ooDocsInput?.addEventListener("input", refreshCsp); + refreshCsp(); + + // ---- Copy helpers (with robust fallback) ---- + async function copyToClipboard(text) { + // Best path: async clipboard API in a secure context (https/localhost) + if (navigator.clipboard && window.isSecureContext) { + try { await navigator.clipboard.writeText(text); return true; } + catch (_) { /* fall through */ } + } + // Fallback for http or blocked clipboard: hidden textarea + execCommand + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); // deprecated but still widely supported + document.body.removeChild(ta); + return ok; + } catch (_) { + return false; + } + } + function selectElementContents(el) { + const range = document.createRange(); + range.selectNodeContents(el); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + } + + document.getElementById("copyOoCsp")?.addEventListener("click", async () => { + const txt = (cspPre.textContent || "").trim(); + const ok = await copyToClipboard(txt); + if (ok) { + showToast("CSP line copied."); + } else { + // Auto-select so the user can Ctrl/Cmd+C as a last resort + try { selectElementContents(cspPre); } catch { } + const reason = window.isSecureContext ? "" : " (page is not HTTPS or localhost)"; + showToast("Copy failed" + reason + ". Press Ctrl/Cmd+C to copy."); + } + }); + + document.getElementById("selectOoCsp")?.addEventListener("click", () => { + try { selectElementContents(cspPre); showToast("Selected — press Ctrl/Cmd+C"); } + catch { /* ignore */ } + }); + + document.getElementById("ooEnabled").checked = !!(config.onlyoffice && config.onlyoffice.enabled); + document.getElementById("ooDocsOrigin").value = (config.onlyoffice && config.onlyoffice.docsOrigin) ? config.onlyoffice.docsOrigin : ""; + const hasId = !!(config.oidc && config.oidc.hasClientId); const hasSecret = !!(config.oidc && config.oidc.hasClientSecret); @@ -696,10 +959,24 @@ export function openAdminPanel() { document.getElementById("authHeaderName").value = config.loginOptions.authHeaderName || "X-Remote-User"; document.getElementById("enableWebDAV").checked = config.enableWebDAV === true; document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || ""; + // remember lock for handleSave + window.__OO_LOCKED = !!(config.onlyoffice && config.onlyoffice.lockedByPhp); + if (window.__OO_LOCKED) { + const sec = document.getElementById("onlyofficeContent"); + sec.querySelectorAll("input,button").forEach(el => el.disabled = true); + const note = document.createElement("div"); + note.className = "form-text"; + note.style.marginTop = "6px"; + note.textContent = "Managed by config.php — edit ONLYOFFICE_* constants there."; + sec.appendChild(note); + } captureInitialAdminConfig(); } else { mdl.style.display = "flex"; + const hasId = !!(config.oidc && config.oidc.hasClientId); + const hasSecret = !!(config.oidc && config.oidc.hasClientSecret); + document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true; document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true; document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true; @@ -713,6 +990,10 @@ export function openAdminPanel() { if (!hasId) idEl.value = window.currentOIDCConfig?.clientId || ""; if (!hasSecret) secEl.value = window.currentOIDCConfig?.clientSecret || ""; wireReplaceButtons(document.getElementById("oidcContent")); + document.getElementById("ooEnabled").checked = !!(config.onlyoffice && config.onlyoffice.enabled); + document.getElementById("ooDocsOrigin").value = (config.onlyoffice && config.onlyoffice.docsOrigin) ? config.onlyoffice.docsOrigin : ""; + const ooCont = document.getElementById("onlyofficeContent"); + if (ooCont) wireReplaceButtons(ooCont); document.getElementById("oidcClientSecret").value = window.currentOIDCConfig?.clientSecret || ""; document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig?.redirectUri || ""; document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig?.globalOtpauthUrl || ''; @@ -752,6 +1033,30 @@ function handleSave() { payload.oidc.clientSecret = scEl.value.trim(); } + const ooSecretEl = document.getElementById("ooJwtSecret"); + + payload.onlyoffice = { + enabled: document.getElementById("ooEnabled").checked, + docsOrigin: document.getElementById("ooDocsOrigin").value.trim() + }; + + if (ooSecretEl?.dataset.replace === '1' && ooSecretEl.value.trim() !== '') { + payload.onlyoffice.jwtSecret = ooSecretEl.value.trim(); + } + + // ---- ONLYOFFICE payload ---- + if (!window.__OO_LOCKED) { + const ooSecretVal = (document.getElementById("ooJwtSecret")?.value || "").trim(); + payload.onlyoffice = { + enabled: document.getElementById("ooEnabled").checked, + docsOrigin: document.getElementById("ooDocsOrigin").value.trim() + }; + // If user typed a secret (non-empty), send it (server keeps it if non-empty) + if (ooSecretVal !== "") { + payload.onlyoffice.jwtSecret = ooSecretVal; + } + } + fetch('/api/admin/updateConfig.php', { method: 'POST', credentials: 'include', diff --git a/public/js/fileEditor.js b/public/js/fileEditor.js index 652e97e..c310a31 100644 --- a/public/js/fileEditor.js +++ b/public/js/fileEditor.js @@ -65,6 +65,137 @@ function normalizeModeName(modeOption) { return name; } +// ---- ONLYOFFICE integration ----------------------------------------------- + +function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; } + +// Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php +let __ooCaps = { enabled: false, exts: new Set(), fetched: false }; + +async function fetchOnlyOfficeCapsOnce() { + if (__ooCaps.fetched) return __ooCaps; + try { + const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' }); + if (r.ok) { + const j = await r.json(); + __ooCaps.enabled = !!j.enabled; + __ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []); + } + } catch { /* ignore; keep defaults */ } + __ooCaps.fetched = true; + return __ooCaps; +} + +async function shouldUseOnlyOffice(fileName) { + const { enabled, exts } = await fetchOnlyOfficeCapsOnce(); + return enabled && exts.has(getExt(fileName)); +} + +function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); } + +async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) { + let src = + srcFromConfig || + (originFromConfig ? originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js' + : (window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js')); + if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return; + await loadScriptOnce(src); +} + +async function openOnlyOffice(fileName, folder) { + let editor; // make visible to the whole function + + try { + const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`; + const resp = await fetch(url, { credentials: 'include' }); + + const text = await resp.text(); + let cfg; + try { cfg = JSON.parse(text); } catch { + throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`); + } + if (!resp.ok) throw new Error(cfg.error || `ONLYOFFICE config HTTP ${resp.status}`); + + // Must be absolute + const docUrl = cfg?.document?.url; + const cbUrl = cfg?.editorConfig?.callbackUrl; + if (!/^https?:\/\//i.test(docUrl || '') || !/^https?:\/\//i.test(cbUrl || '')) { + throw new Error(`Config URLs must be absolute. document.url='${docUrl}', callbackUrl='${cbUrl}'`); + } + + // Load DocsAPI if needed + await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin); + + // Modal + const modal = document.createElement('div'); + modal.id = 'ooEditorModal'; + modal.classList.add('modal', 'editor-modal'); + modal.setAttribute('tabindex', '-1'); + modal.innerHTML = ` +
    +

    + ${t("editing")}: ${escapeHTML(fileName)} +

    + +
    +
    +
    +
    + `; + document.body.appendChild(modal); + modal.style.display = 'block'; + modal.focus(); + + // We’ll fill this after wiring the toggle, so destroy() can unhook it + let removeThemeListener = () => {}; + + const destroy = () => { + try { editor?.destroyEditor?.(); } catch {} + try { removeThemeListener(); } catch {} + try { modal.remove(); } catch {} + }; + + modal.addEventListener('keydown', e => { if (e.key === 'Escape') destroy(); }); + document.getElementById('closeEditorX')?.addEventListener('click', destroy); + + // Let DS request closing + cfg.events = Object.assign({}, cfg.events, { onRequestClose: destroy }); + + // Initial theme + const isDark = + document.documentElement.classList.contains('dark-mode') || + /^(1|true)$/i.test(localStorage.getItem('darkMode') || ''); + + cfg.editorConfig = cfg.editorConfig || {}; + cfg.editorConfig.customization = Object.assign( + {}, + cfg.editorConfig.customization, + { uiTheme: isDark ? 'theme-dark' : 'theme-light' } // <- correct key/value + ); + + // Launch editor + editor = new window.DocsAPI.DocEditor('oo-editor', cfg); + + // Live theme switching (ONLYOFFICE v7.2+ supports setTheme) + const darkToggle = document.getElementById('darkModeToggle'); + const onDarkToggle = () => { + const nowDark = document.documentElement.classList.contains('dark-mode'); + if (editor && typeof editor.setTheme === 'function') { + editor.setTheme(nowDark ? 'dark' : 'light'); + } + }; + if (darkToggle) { + darkToggle.addEventListener('click', onDarkToggle); + removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle); + } + } catch (e) { + console.error('[ONLYOFFICE] failed to open:', e); + showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.'); + } +} +// ---- /ONLYOFFICE integration ---------------------------------------------- + + const _loadedScripts = new Set(); const _loadedCss = new Set(); let _corePromise = null; @@ -196,7 +327,7 @@ function observeModalResize(modal) { } export { observeModalResize }; -export function editFile(fileName, folder) { +export async function editFile(fileName, folder) { // destroy any previous editor let existingEditor = document.getElementById("editorContainer"); if (existingEditor) existingEditor.remove(); @@ -204,6 +335,11 @@ export function editFile(fileName, folder) { const folderUsed = folder || window.currentFolder || "root"; const fileUrl = buildPreviewUrl(folderUsed, fileName); + if (await shouldUseOnlyOffice(fileName)) { + await openOnlyOffice(fileName, folderUsed); + return; + } + // Probe size safely via API. Prefer HEAD; if missing Content-Length, fall back to a 1-byte Range GET. async function probeSize(url) { try { diff --git a/public/js/fileListView.js b/public/js/fileListView.js index 84a4d76..6550c2e 100644 --- a/public/js/fileListView.js +++ b/public/js/fileListView.js @@ -34,6 +34,25 @@ import { export let fileData = []; export let sortOrder = { column: "uploaded", ascending: true }; + + + + // onnlyoffice +let OO_ENABLED = false; +let OO_EXTS = new Set(); + +export async function initOnlyOfficeCaps() { + try { + const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' }); + if (!r.ok) throw 0; + const j = await r.json(); + OO_ENABLED = !!j.enabled; + OO_EXTS = new Set(Array.isArray(j.exts) ? j.exts : []); + } catch { + OO_ENABLED = false; + OO_EXTS = new Set(); + } +} // Hide "Edit" for files >10 MiB const MAX_EDIT_BYTES = 10 * 1024 * 1024; @@ -338,6 +357,7 @@ function searchFiles(searchTerm) { window.updateRowHighlight = updateRowHighlight; export async function loadFileList(folderParam) { + await initOnlyOfficeCaps(); const reqId = ++__fileListReqSeq; // latest call wins const folder = folderParam || "root"; const fileListContainer = document.getElementById("fileList"); @@ -1328,46 +1348,34 @@ function searchFiles(searchTerm) { if (!fileName || typeof fileName !== "string") return false; const dot = fileName.lastIndexOf("."); if (dot < 0) return false; - const ext = fileName.slice(dot + 1).toLowerCase(); - const allowedExtensions = [ - "txt", "text", "md", "markdown", "rst", - "html", "htm", "xhtml", "shtml", - "css", "scss", "sass", "less", - "js", "mjs", "cjs", "jsx", - "ts", "tsx", - "json", "jsonc", "ndjson", - "yml", "yaml", "toml", "xml", "plist", - "ini", "conf", "config", "cfg", "cnf", "properties", "props", "rc", - "env", "dotenv", - "csv", "tsv", "tab", + // Your CodeMirror text-based types + const textEditExts = new Set([ + "txt","text","md","markdown","rst", + "html","htm","xhtml","shtml", + "css","scss","sass","less", + "js","mjs","cjs","jsx", + "ts","tsx", + "json","jsonc","ndjson", + "yml","yaml","toml","xml","plist", + "ini","conf","config","cfg","cnf","properties","props","rc", + "env","dotenv", + "csv","tsv","tab", "log", - "sh", "bash", "zsh", "ksh", "fish", - "bat", "cmd", - "ps1", "psm1", "psd1", - "py", "pyw", - "rb", - "pl", "pm", - "go", - "rs", - "java", - "kt", "kts", - "scala", "sc", - "groovy", "gradle", - "c", "h", "cpp", "cxx", "cc", "hpp", "hh", "hxx", - "m", "mm", - "swift", - "cs", "fs", "fsx", - "dart", - "lua", - "r", "rmd", - "sql", - "vue", "svelte", - "twig", "mustache", "hbs", "handlebars", "ejs", "pug", "jade" - ]; + "sh","bash","zsh","ksh","fish", + "bat","cmd", + "ps1","psm1","psd1", + "py","pyw","rb","pl","pm","go","rs","java","kt","kts", + "scala","sc","groovy","gradle", + "c","h","cpp","cxx","cc","hpp","hh","hxx", + "m","mm","swift","cs","fs","fsx","dart","lua","r","rmd", + "sql","vue","svelte","twig","mustache","hbs","handlebars","ejs","pug","jade" + ]); - return allowedExtensions.includes(ext); + if (textEditExts.has(ext)) return true; // CodeMirror + if (OO_ENABLED && OO_EXTS.has(ext)) return true; // ONLYOFFICE types if enabled + return false; } // Expose global functions for pagination and preview. diff --git a/src/controllers/AdminController.php b/src/controllers/AdminController.php index e2d2641..ec90aae 100644 --- a/src/controllers/AdminController.php +++ b/src/controllers/AdminController.php @@ -5,11 +5,11 @@ require_once __DIR__ . '/../../config/config.php'; require_once PROJECT_ROOT . '/src/models/AdminModel.php'; class AdminController -{ +{ public function getConfig(): void { header('Content-Type: application/json; charset=utf-8'); - + $config = AdminModel::getConfig(); if (isset($config['error'])) { http_response_code(500); @@ -17,8 +17,24 @@ class AdminController echo json_encode(['error' => $config['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return; } - - // Whitelisted public subset only + + // ---- Effective ONLYOFFICE values (constants override adminConfig) ---- + $ooCfg = is_array($config['onlyoffice'] ?? null) ? $config['onlyoffice'] : []; + $effEnabled = defined('ONLYOFFICE_ENABLED') + ? (bool) ONLYOFFICE_ENABLED + : (bool) ($ooCfg['enabled'] ?? false); + + $effDocs = defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== '' + ? (string) ONLYOFFICE_DOCS_ORIGIN + : (string) ($ooCfg['docsOrigin'] ?? ''); + + $hasSecret = defined('ONLYOFFICE_JWT_SECRET') + ? (ONLYOFFICE_JWT_SECRET !== '') + : (!empty($ooCfg['jwtSecret'])); + + $publicOriginCfg = (string) ($ooCfg['publicOrigin'] ?? ''); + + // Whitelisted public subset only (+ ONLYOFFICE enabled flag) $public = [ 'header_title' => (string)($config['header_title'] ?? 'FileRise'), 'loginOptions' => [ @@ -34,12 +50,16 @@ class AdminController 'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''), // never include clientId/clientSecret ], + 'onlyoffice' => [ + // Public only needs to know if it’s on; no secrets/origins here. + 'enabled' => $effEnabled, + ], ]; - + $isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']); - + if ($isAdmin) { - // admin-only extras: presence flags + proxy options + // admin-only extras: presence flags + proxy options + ONLYOFFICE effective view $adminExtra = [ 'loginOptions' => array_merge($public['loginOptions'], [ 'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false), @@ -49,12 +69,23 @@ class AdminController 'hasClientId' => !empty($config['oidc']['clientId']), 'hasClientSecret' => !empty($config['oidc']['clientSecret']), ]), + 'onlyoffice' => [ + 'enabled' => $effEnabled, + 'docsOrigin' => $effDocs, // effective (constants win) + 'publicOrigin' => $publicOriginCfg, // optional override from adminConfig + 'hasJwtSecret' => (bool)$hasSecret, // boolean only; never leak secret + 'lockedByPhp' => ( + defined('ONLYOFFICE_ENABLED') + || defined('ONLYOFFICE_DOCS_ORIGIN') + || defined('ONLYOFFICE_JWT_SECRET') + ), + ], ]; header('Cache-Control: no-store'); // don’t cache admin config echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return; } - + // Non-admins / unauthenticated: only the public subset header('Cache-Control: no-store'); echo json_encode($public, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); @@ -221,6 +252,40 @@ class AdminController } // —– persist merged config —– + // ---- ONLYOFFICE: merge from payload (unless locked by PHP defines) ---- + $ooLockedByPhp = ( + defined('ONLYOFFICE_ENABLED') || + defined('ONLYOFFICE_DOCS_ORIGIN') || + defined('ONLYOFFICE_JWT_SECRET') || + defined('ONLYOFFICE_PUBLIC_ORIGIN') + ); + + if (!$ooLockedByPhp && isset($data['onlyoffice']) && is_array($data['onlyoffice'])) { + $ooExisting = (isset($existing['onlyoffice']) && is_array($existing['onlyoffice'])) + ? $existing['onlyoffice'] : []; + + $oo = $ooExisting; + + if (array_key_exists('enabled', $data['onlyoffice'])) { + $oo['enabled'] = filter_var($data['onlyoffice']['enabled'], FILTER_VALIDATE_BOOLEAN); + } + if (isset($data['onlyoffice']['docsOrigin'])) { + $oo['docsOrigin'] = (string)$data['onlyoffice']['docsOrigin']; + } + if (isset($data['onlyoffice']['publicOrigin'])) { + $oo['publicOrigin'] = (string)$data['onlyoffice']['publicOrigin']; + } + // Allow setting/changing the secret when NOT locked by PHP + if (isset($data['onlyoffice']['jwtSecret'])) { + $js = trim((string)$data['onlyoffice']['jwtSecret']); + if ($js !== '') { + $oo['jwtSecret'] = $js; // stored encrypted by AdminModel + } + // If blank, we leave existing secret unchanged (no implicit wipe). + } + + $merged['onlyoffice'] = $oo; + } $result = AdminModel::updateConfig($merged); if (isset($result['error'])) { http_response_code(500); diff --git a/src/controllers/OnlyOfficeController.php b/src/controllers/OnlyOfficeController.php new file mode 100644 index 0000000..1c4d6cc --- /dev/null +++ b/src/controllers/OnlyOfficeController.php @@ -0,0 +1,383 @@ +ooDebug()) { + @file_put_contents(self::OO_LOG_PATH, '[' . date('c') . '] ' . $line . "\n", FILE_APPEND); + } +} + + /** Resolve effective docs origin (http/https root of OO Docs server) */ + private function effectiveDocsOrigin(): string + { + $cfg = AdminModel::getConfig(); + $oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : []; + if (defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== '') { + return (string)ONLYOFFICE_DOCS_ORIGIN; + } + if (!empty($oo['docsOrigin'])) return (string)$oo['docsOrigin']; + $env = getenv('ONLYOFFICE_DOCS_ORIGIN'); + return $env ? (string)$env : ''; + } + + /** Resolve effective enabled flag (constants override adminConfig) */ + private function effectiveEnabled(): bool + { + $cfg = AdminModel::getConfig(); + $oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : []; + if (defined('ONLYOFFICE_ENABLED')) return (bool)ONLYOFFICE_ENABLED; + return !empty($oo['enabled']); + } + + /** Optional explicit public origin; else infer from BASE_URL / request */ + private function effectivePublicOrigin(): string + { + $cfg = AdminModel::getConfig(); + $oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : []; + + if (defined('ONLYOFFICE_PUBLIC_ORIGIN') && ONLYOFFICE_PUBLIC_ORIGIN !== '') { + return (string)ONLYOFFICE_PUBLIC_ORIGIN; + } + if (!empty($oo['publicOrigin'])) return (string)$oo['publicOrigin']; + + // Try BASE_URL if it isn't a placeholder + if (defined('BASE_URL') && strpos((string)BASE_URL, 'yourwebsite') === false) { + $u = parse_url((string)BASE_URL); + if (!empty($u['scheme']) && !empty($u['host'])) { + return $u['scheme'].'://'.$u['host'].(isset($u['port'])?':'.$u['port']:''); + } + } + // Fallback to request (proxy aware) + $proto = $_SERVER['HTTP_X_FORWARDED_PROTO'] + ?? ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'); + $host = $_SERVER['HTTP_X_FORWARDED_HOST'] ?? ($_SERVER['HTTP_HOST'] ?? 'localhost'); + return $proto.'://'.$host; + } + + /** base64url encode/decode helpers */ + private function b64uDec(string $s) + { + $s = strtr($s, '-_', '+/'); + $pad = strlen($s) % 4; + if ($pad) $s .= str_repeat('=', 4 - $pad); + return base64_decode($s, true); + } + private function b64uEnc(string $s): string + { + return rtrim(strtr(base64_encode($s), '+/','-_'), '='); + } + + /** GET /api/onlyoffice/status.php */ + public function status(): void + { + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store'); + + $enabled = $this->effectiveEnabled(); + $docsOrig = $this->effectiveDocsOrigin(); + $secret = $this->effectiveSecret(); + + // Must have docs origin and secret to actually function + $enabled = $enabled && ($docsOrig !== '') && ($secret !== ''); + + $exts = self::OO_SUPPORTED_EXTS; + // If you want the extras: + $exts = array_values(array_unique(array_merge($exts, self::OO_VIEW_ONLY_EXTRAS))); + + echo json_encode(['enabled' => (bool)$enabled, 'exts' => $exts], JSON_UNESCAPED_SLASHES); + } + + /** GET /api/onlyoffice/config.php?folder=...&file=... */ + public function config(): void + { + header('Content-Type: application/json; charset=utf-8'); + header('Cache-Control: no-store'); + + @session_start(); + $user = $_SESSION['username'] ?? 'anonymous'; + $perms = []; + $isAdmin = \ACL::isAdmin($perms); + + // Effective toggles + $enabled = $this->effectiveEnabled(); + $docsOrigin = rtrim($this->effectiveDocsOrigin(), '/'); + $secret = $this->effectiveSecret(); + if (!$enabled) { http_response_code(404); echo '{"error":"ONLYOFFICE disabled"}'; return; } + if ($secret === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_JWT_SECRET not configured"}'; return; } + if ($docsOrigin === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_DOCS_ORIGIN not configured"}'; return; } + if (!defined('UPLOAD_DIR')) { http_response_code(500); echo '{"error":"UPLOAD_DIR not defined"}'; return; } + + // Inputs + $folder = \ACL::normalizeFolder((string)($_GET['folder'] ?? 'root')); + $file = basename((string)($_GET['file'] ?? '')); + if ($file === '') { http_response_code(400); echo '{"error":"Bad request"}'; return; } + + // ACL + if (!\ACL::canRead($user, $perms, $folder)) { http_response_code(403); echo '{"error":"Forbidden"}'; return; } + $canEdit = \ACL::canEdit($user, $perms, $folder); + + // Path + $base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR; + $rel = ($folder === 'root') ? '' : ($folder . '/'); + $abs = realpath($base . $rel . $file); + if (!$abs || !is_file($abs)) { http_response_code(404); echo '{"error":"Not found"}'; return; } + if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); echo '{"error":"Invalid path"}'; return; } + + // Public origin + $publicOrigin = $this->effectivePublicOrigin(); + + // Signed download + $exp = time() + 10*60; + $data = json_encode(['f'=>$folder,'n'=>$file,'u'=>$user,'adm'=>$isAdmin,'exp'=>$exp], JSON_UNESCAPED_SLASHES); + $sig = hash_hmac('sha256', $data, $secret, true); + $tok = $this->b64uEnc($data) . '.' . $this->b64uEnc($sig); + $fileUrl = $publicOrigin . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok); + + // Callback + $cbExp = time() + 10*60; + $cbSig = hash_hmac('sha256', $folder.'|'.$file.'|'.$cbExp, $secret); + $callbackUrl = $publicOrigin . '/api/onlyoffice/callback.php' + . '?folder=' . rawurlencode($folder) + . '&file=' . rawurlencode($file) + . '&exp=' . $cbExp + . '&sig=' . $cbSig; + + // Doc type & key + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx'); + $docType = in_array($ext, ['xls','xlsx','ods','csv'], true) ? 'cell' + : (in_array($ext, ['ppt','pptx','odp'], true) ? 'slide' : 'word'); + $key = substr(sha1($abs . '|' . (string)filemtime($abs)), 0, 20); + + $docsApiJs = $docsOrigin . '/web-apps/apps/api/documents/api.js'; + + $cfgOut = [ + 'document' => [ + 'fileType' => $ext, + 'key' => $key, + 'title' => $file, + 'url' => $fileUrl, + 'permissions' => [ + 'download' => true, + 'print' => true, + 'edit' => $canEdit && !in_array($ext, self::OO_NEVER_EDIT, true), + ], + ], + 'documentType' => $docType, + 'editorConfig' => [ + 'callbackUrl' => $callbackUrl, + 'user' => ['id'=>$user, 'name'=>$user], + 'lang' => 'en', + ], + 'type' => 'desktop', + ]; + + // JWT sign cfg + $h = $this->b64uEnc(json_encode(['alg'=>'HS256','typ'=>'JWT'])); + $p = $this->b64uEnc(json_encode($cfgOut, JSON_UNESCAPED_SLASHES)); + $s = $this->b64uEnc(hash_hmac('sha256', "$h.$p", $secret, true)); + $cfgOut['token'] = "$h.$p.$s"; + $cfgOut['docs_api_js'] = $docsApiJs; + + echo json_encode($cfgOut, JSON_UNESCAPED_SLASHES); + } + + /** POST /api/onlyoffice/callback.php?folder=...&file=...&exp=...&sig=... */ + public function callback(): void +{ + header('Content-Type: application/json; charset=utf-8'); + + if (isset($_GET['ping'])) { echo '{"error":0}'; return; } + + $secret = $this->effectiveSecret(); + if ($secret === '') { http_response_code(500); $this->ooLog('error', 'missing secret'); echo '{"error":6}'; return; } + + $folderRaw = (string)($_GET['folder'] ?? 'root'); + $fileRaw = (string)($_GET['file'] ?? ''); + $exp = (int)($_GET['exp'] ?? 0); + $sig = (string)($_GET['sig'] ?? ''); + $calc = hash_hmac('sha256', "$folderRaw|$fileRaw|$exp", $secret); + + // Debug-only preflight (no secrets; show short sigs) + if ($this->ooDebug()) { + $this->ooLog('debug', sprintf( + "PRE f='%s' n='%s' exp=%d sig[8]=%s calc[8]=%s", + $folderRaw, $fileRaw, $exp, substr($sig, 0, 8), substr($calc, 0, 8) + )); + } + + $folder = \ACL::normalizeFolder($folderRaw); + $file = basename($fileRaw); + if (!$exp || time() > $exp) { $this->ooLog('error', "expired exp for $folder/$file"); echo '{"error":6}'; return; } + if (!hash_equals($calc, $sig)) { $this->ooLog('error', "sig mismatch for $folder/$file"); echo '{"error":6}'; return; } + + $raw = file_get_contents('php://input') ?: ''; + if ($this->ooDebug()) { + $this->ooLog('debug', 'BODY len=' . strlen($raw)); + } + + $body = json_decode($raw, true) ?: []; + $status = (int)($body['status'] ?? 0); + $actor = (string)($body['actions'][0]['userid'] ?? ''); + + $actorIsAdmin = (defined('DEFAULT_ADMIN_USER') && $actor !== '' && strcasecmp($actor, (string)DEFAULT_ADMIN_USER) === 0) + || (strcasecmp($actor, 'admin') === 0); + $perms = $actorIsAdmin ? ['admin'=>true] : []; + + $base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR; + $rel = ($folder === 'root') ? '' : ($folder . '/'); + $dir = realpath($base . $rel) ?: ($base . $rel); + if (strpos($dir, realpath($base)) !== 0) { $this->ooLog('error', 'path escape'); echo '{"error":6}'; return; } + + // Save-on statuses: 2/6/7 + if (in_array($status, [2,6,7], true)) { + if (!$actor || !\ACL::canEdit($actor, $perms, $folder)) { + $this->ooLog('error', "ACL deny edit: actor='$actor' folder='$folder'"); + echo '{"error":6}'; return; + } + $saveUrl = (string)($body['url'] ?? ''); + if ($saveUrl === '') { $this->ooLog('error', "no url for status=$status"); echo '{"error":6}'; return; } + + // fetch saved file + $data = null; $curlErr=''; $httpCode=0; + if (function_exists('curl_init')) { + $ch = curl_init($saveUrl); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 45, + CURLOPT_HTTPHEADER => ['Accept: */*','User-Agent: FileRise-ONLYOFFICE-Callback'], + ]); + $data = curl_exec($ch); + if ($data === false) $curlErr = curl_error($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($data === false || $httpCode >= 400) { + $this->ooLog('error', "curl get failed ($httpCode) url=$saveUrl err=" . ($curlErr ?: 'n/a')); + $data = null; + } + } + if ($data === null) { + $ctx = stream_context_create(['http'=>['method'=>'GET','timeout'=>45,'header'=>"Accept: */*\r\n"]]); + $data = @file_get_contents($saveUrl, false, $ctx); + if ($data === false) { $this->ooLog('error', "stream get failed url=$saveUrl"); echo '{"error":6}'; return; } + } + + if (!is_dir($dir)) { @mkdir($dir, 0775, true); } + $dest = rtrim($dir, "/\\") . DIRECTORY_SEPARATOR . $file; + if (@file_put_contents($dest, $data) === false) { $this->ooLog('error', "write failed: $dest"); echo '{"error":6}'; return; } + + @touch($dest); + + // Success: debug only + if ($this->ooDebug()) { + $this->ooLog('debug', "saved OK by '$actor' → $dest (" . strlen($data) . " bytes, status=$status)"); + } + echo '{"error":0}'; return; + } + + // Non-saving statuses: debug only + if ($this->ooDebug()) { + $this->ooLog('debug', "status=$status ack for $folder/$file by '$actor'"); + } + echo '{"error":0}'; +} + + /** GET /api/onlyoffice/signed-download.php?tok=... */ + public function signedDownload(): void + { + header('X-Content-Type-Options: nosniff'); + header('Cache-Control: no-store'); + + $secret = $this->effectiveSecret(); + if ($secret === '') { http_response_code(403); return; } + + $tok = $_GET['tok'] ?? ''; + if (!$tok || strpos($tok, '.') === false) { http_response_code(400); return; } + [$b64data, $b64sig] = explode('.', $tok, 2); + $data = $this->b64uDec($b64data); + $sig = $this->b64uDec($b64sig); + if ($data === false || $sig === false) { http_response_code(400); return; } + + $calc = hash_hmac('sha256', $data, $secret, true); + if (!hash_equals($calc, $sig)) { http_response_code(403); return; } + + $payload = json_decode($data, true); + if (!$payload || !isset($payload['f'],$payload['n'],$payload['exp'])) { http_response_code(400); return; } + if (time() > (int)$payload['exp']) { http_response_code(403); return; } + + $folder = trim(str_replace('\\','/',$payload['f']),"/ \t\r\n"); + if ($folder === '' || $folder === 'root') $folder = 'root'; + $file = basename((string)$payload['n']); + + $base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR; + $rel = ($folder === 'root') ? '' : ($folder . '/'); + $abs = realpath($base . $rel . $file); + if (!$abs || !is_file($abs)) { http_response_code(404); return; } + if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); return; } + + $mime = mime_content_type($abs) ?: 'application/octet-stream'; + header('Content-Type: '.$mime); + header('Content-Length: '.filesize($abs)); + header('Content-Disposition: inline; filename="' . rawurlencode($file) . '"'); + readfile($abs); + } +} \ No newline at end of file diff --git a/src/models/AdminModel.php b/src/models/AdminModel.php index b83c636..fcef1a2 100644 --- a/src/models/AdminModel.php +++ b/src/models/AdminModel.php @@ -62,27 +62,59 @@ class AdminModel return (int)$val; } - public static function buildPublicSubset(array $config): array + /** Allow only http(s) URLs; return '' for invalid input. */ + private static function sanitizeHttpUrl($url): string { - return [ - 'header_title' => $config['header_title'] ?? 'FileRise', - 'loginOptions' => [ - 'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false), - 'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false), - 'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false), - // do NOT include authBypass/authHeaderName here — admin-only - ], - 'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '', - 'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false), - 'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0), - 'oidc' => [ - 'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''), - 'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''), - // never include clientId / clientSecret - ], - ]; + $url = trim((string)$url); + if ($url === '') return ''; + $valid = filter_var($url, FILTER_VALIDATE_URL); + if (!$valid) return ''; + $scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?: ''); + return ($scheme === 'http' || $scheme === 'https') ? $url : ''; } + public static function buildPublicSubset(array $config): array +{ + $public = [ + 'header_title' => $config['header_title'] ?? 'FileRise', + 'loginOptions' => [ + 'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false), + 'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false), + 'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false), + ], + 'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '', + 'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false), + 'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0), + 'oidc' => [ + 'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''), + 'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''), + ], + ]; + + // NEW: include ONLYOFFICE minimal public flag + $ooEnabled = null; + if (isset($config['onlyoffice']['enabled'])) { + $ooEnabled = (bool)$config['onlyoffice']['enabled']; + } elseif (defined('ONLYOFFICE_ENABLED')) { + $ooEnabled = (bool)ONLYOFFICE_ENABLED; + } + if ($ooEnabled !== null) { + $public['onlyoffice'] = ['enabled' => $ooEnabled]; + } + $locked = defined('ONLYOFFICE_ENABLED') || defined('ONLYOFFICE_JWT_SECRET') + || defined('ONLYOFFICE_DOCS_ORIGIN') || defined('ONLYOFFICE_PUBLIC_ORIGIN'); + +if ($locked) { + $ooEnabled = defined('ONLYOFFICE_ENABLED') ? (bool)ONLYOFFICE_ENABLED : false; +} else { + $ooEnabled = isset($config['onlyoffice']['enabled']) ? (bool)$config['onlyoffice']['enabled'] : false; +} + +$public['onlyoffice'] = ['enabled' => $ooEnabled]; + + return $public; +} + /** Write USERS_DIR/siteConfig.json atomically (unencrypted). */ public static function writeSiteConfig(array $publicSubset): array { @@ -173,6 +205,28 @@ class AdminModel $configUpdate['loginOptions']['authHeaderName'] = trim($configUpdate['loginOptions']['authHeaderName']); } + // ---- ONLYOFFICE (persist, sanitize; keep secret unless explicitly replaced) ---- + if (isset($configUpdate['onlyoffice']) && is_array($configUpdate['onlyoffice'])) { + $oo = $configUpdate['onlyoffice']; + + $norm = [ + 'enabled' => (bool)($oo['enabled'] ?? false), + 'docsOrigin' => self::sanitizeHttpUrl($oo['docsOrigin'] ?? ''), + 'publicOrigin' => self::sanitizeHttpUrl($oo['publicOrigin'] ?? ''), + ]; + + // Only accept a new secret if provided (non-empty). We do NOT clear on empty. + if (array_key_exists('jwtSecret', $oo)) { + $js = trim((string)$oo['jwtSecret']); + if ($js !== '') { + if (strlen($js) > 1024) $js = substr($js, 0, 1024); + $norm['jwtSecret'] = $js; // will be encrypted with encryptData() + } + } + + $configUpdate['onlyoffice'] = $norm; + } + // Convert configuration to JSON. $plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT); if ($plainTextConfig === false) { @@ -301,6 +355,19 @@ class AdminModel $config['sharedMaxUploadSize'] = (int)min((int)$config['sharedMaxUploadSize'], $maxBytes); } + // ---- Ensure ONLYOFFICE structure exists, sanitize values ---- + if (!isset($config['onlyoffice']) || !is_array($config['onlyoffice'])) { + $config['onlyoffice'] = [ + 'enabled' => false, + 'docsOrigin' => '', + 'publicOrigin' => '', + ]; + } else { + $config['onlyoffice']['enabled'] = (bool)($config['onlyoffice']['enabled'] ?? false); + $config['onlyoffice']['docsOrigin'] = self::sanitizeHttpUrl($config['onlyoffice']['docsOrigin'] ?? ''); + $config['onlyoffice']['publicOrigin'] = self::sanitizeHttpUrl($config['onlyoffice']['publicOrigin'] ?? ''); + } + return $config; } @@ -320,7 +387,12 @@ class AdminModel ], 'globalOtpauthUrl' => "", 'enableWebDAV' => false, - 'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE)) + 'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE)), + 'onlyoffice' => [ + 'enabled' => false, + 'docsOrigin' => '', + 'publicOrigin' => '', + ], ]; } -} +} \ No newline at end of file