Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1b20a9f1d | ||
|
|
0ec8103fbf | ||
|
|
3b1ebdd77f | ||
|
|
3726e2423d |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -1,5 +1,40 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 11/18/2025 (v1.9.11)
|
||||||
|
|
||||||
|
release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68)
|
||||||
|
|
||||||
|
- media: add proper HTTP Range support to /api/file/download.php so HTML5
|
||||||
|
video/audio can seek correctly across all browsers (Brave/Chrome/Android/Windows).
|
||||||
|
- media: avoid buffering the entire file in memory; stream from disk with
|
||||||
|
200/206 responses and Accept-Ranges for smoother playback and faster start times.
|
||||||
|
- media: keep video progress tracking, watched badges, and status chip behavior
|
||||||
|
unchanged but now compatible with the new streaming endpoint.
|
||||||
|
|
||||||
|
- ui: update the folder strip to be responsive:
|
||||||
|
- desktop: keep the existing "chip" layout with icon above name.
|
||||||
|
- mobile: switch to inline rows `[icon] [name]` with reduced whitespace.
|
||||||
|
- ui: add simple lazy-loading for the folder strip so only the first batch of
|
||||||
|
folders is rendered initially, with a "Load more…" button to append chunks for
|
||||||
|
very large folder sets (stays friendly with 100k+ folders).
|
||||||
|
|
||||||
|
- misc: small CSS tidy-up around the folder strip classes to remove duplicates
|
||||||
|
and keep mobile/desktop behavior clearly separated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/18/2025 (v1.9.10)
|
||||||
|
|
||||||
|
release(v1.9.10): add Pro bundle installer and admin panel polish
|
||||||
|
|
||||||
|
- Add FileRise Pro section in admin panel with license management and bundle upload
|
||||||
|
- Persist Pro bundle under users/pro and sync public/api/pro endpoints on container startup
|
||||||
|
- Improve admin config API: Pro metadata, license file handling, hardened auth/CSRF helpers
|
||||||
|
- Update Pro badge/version UI with “update available” hint and link to filerise.net
|
||||||
|
- Change Pro bundle installer to always overwrite existing bundle files for clean upgrades
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 11/16/2025 (v1.9.9)
|
## Changes 11/16/2025 (v1.9.9)
|
||||||
|
|
||||||
release(v1.9.9): fix(branding): sanitize custom logo URL preview
|
release(v1.9.9): fix(branding): sanitize custom logo URL preview
|
||||||
|
|||||||
@@ -240,30 +240,57 @@ if (strpos(BASE_URL, 'yourwebsite') !== false) {
|
|||||||
// Final: env var wins, else fallback
|
// Final: env var wins, else fallback
|
||||||
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
||||||
|
|
||||||
// --------------------------------
|
// ------------------------------------------------------------
|
||||||
// FileRise Pro (optional add-on)
|
// FileRise Pro bootstrap wiring
|
||||||
// --------------------------------
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
// Where the Pro license JSON lives
|
// Inline license (optional; usually set via Admin UI and PRO_LICENSE_FILE)
|
||||||
|
if (!defined('FR_PRO_LICENSE')) {
|
||||||
|
$envLicense = getenv('FR_PRO_LICENSE');
|
||||||
|
define('FR_PRO_LICENSE', $envLicense !== false ? trim((string)$envLicense) : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON license file used by AdminController::setLicense()
|
||||||
if (!defined('PRO_LICENSE_FILE')) {
|
if (!defined('PRO_LICENSE_FILE')) {
|
||||||
define('PRO_LICENSE_FILE', PROJECT_ROOT . '/users/proLicense.json');
|
define('PRO_LICENSE_FILE', PROJECT_ROOT . '/users/proLicense.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline/env license strings (optional)
|
// Optional plain-text license file (used as fallback in bootstrap)
|
||||||
if (!defined('FR_PRO_LICENSE')) {
|
|
||||||
define('FR_PRO_LICENSE', getenv('FR_PRO_LICENSE') ?: '');
|
|
||||||
}
|
|
||||||
if (!defined('FR_PRO_LICENSE_FILE')) {
|
if (!defined('FR_PRO_LICENSE_FILE')) {
|
||||||
define('FR_PRO_LICENSE_FILE', getenv('FR_PRO_LICENSE_FILE') ?: '');
|
$lf = getenv('FR_PRO_LICENSE_FILE');
|
||||||
|
if ($lf === false || $lf === '') {
|
||||||
|
$lf = PROJECT_ROOT . '/users/proLicense.txt';
|
||||||
|
}
|
||||||
|
define('FR_PRO_LICENSE_FILE', $lf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional Pro bootstrap (shipped only with Pro bundle)
|
// Where Pro code lives by default → inside users volume
|
||||||
$proBootstrap = PROJECT_ROOT . '/src/pro/bootstrap_pro.php';
|
$proDir = getenv('FR_PRO_BUNDLE_DIR');
|
||||||
if (is_file($proBootstrap)) {
|
if ($proDir === false || $proDir === '') {
|
||||||
|
$proDir = PROJECT_ROOT . '/users/pro';
|
||||||
|
}
|
||||||
|
$proDir = rtrim($proDir, "/\\");
|
||||||
|
if (!defined('FR_PRO_BUNDLE_DIR')) {
|
||||||
|
define('FR_PRO_BUNDLE_DIR', $proDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load Pro bootstrap if enabled + present
|
||||||
|
$proBootstrap = FR_PRO_BUNDLE_DIR . '/bootstrap_pro.php';
|
||||||
|
if (@is_file($proBootstrap)) {
|
||||||
require_once $proBootstrap;
|
require_once $proBootstrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safe default so the rest of the app always has the constant
|
// If bootstrap didn’t define these, give safe defaults
|
||||||
if (!defined('FR_PRO_ACTIVE')) {
|
if (!defined('FR_PRO_ACTIVE')) {
|
||||||
define('FR_PRO_ACTIVE', false);
|
define('FR_PRO_ACTIVE', false);
|
||||||
}
|
}
|
||||||
|
if (!defined('FR_PRO_INFO')) {
|
||||||
|
define('FR_PRO_INFO', [
|
||||||
|
'valid' => false,
|
||||||
|
'error' => null,
|
||||||
|
'payload' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (!defined('FR_PRO_BUNDLE_VERSION')) {
|
||||||
|
define('FR_PRO_BUNDLE_VERSION', null);
|
||||||
|
}
|
||||||
8
public/api/admin/installProBundle.php
Normal file
8
public/api/admin/installProBundle.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
|
||||||
|
$controller = new AdminController();
|
||||||
|
$controller->installProBundle();
|
||||||
@@ -1887,4 +1887,94 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
|
|||||||
color: #111;}
|
color: #111;}
|
||||||
.dark-mode .upload-resume-banner-inner .material-icons,
|
.dark-mode .upload-resume-banner-inner .material-icons,
|
||||||
.dark-mode .folder-badge .material-icons{background-color: transparent;
|
.dark-mode .folder-badge .material-icons{background-color: transparent;
|
||||||
color: #f5f5f5;}
|
color: #f5f5f5;}
|
||||||
|
/* Base strip container */
|
||||||
|
.folder-strip-container {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base item layout */
|
||||||
|
.folder-strip-container .folder-item {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container .folder-svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container .folder-name {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Desktop: chips, icon above name --- */
|
||||||
|
.folder-strip-container.folder-strip-desktop {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container.folder-strip-desktop .folder-item {
|
||||||
|
flex-direction: column; /* icon on top, name under */
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container.folder-strip-desktop .folder-name {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile: stacked rows, icon left of name --- */
|
||||||
|
.folder-strip-container.folder-strip-mobile {
|
||||||
|
display: block;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(0,0,0,.08);
|
||||||
|
background: rgba(0,0,0,.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container.folder-strip-mobile .folder-item {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row; /* icon left, name right */
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container.folder-strip-mobile .folder-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
text-align: left;
|
||||||
|
transform: translate(8px, 4px);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container.folder-strip-mobile .folder-item:hover {
|
||||||
|
background: rgba(0,0,0,.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-strip-container.folder-strip-mobile .folder-item.selected {
|
||||||
|
background: rgba(59,130,246,.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Load-more button */
|
||||||
|
.folder-strip-load-more {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(0,0,0,.15);
|
||||||
|
background: rgba(0,0,0,.02);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ function normalizeLogoPath(raw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const version = window.APP_VERSION || "dev";
|
const version = window.APP_VERSION || "dev";
|
||||||
|
// Hard-coded *FOR NOW* latest FileRise Pro bundle version for UI hints only.
|
||||||
|
// Update this when I cut a new Pro ZIP.
|
||||||
|
const PRO_LATEST_BUNDLE_VERSION = 'v1.0.0';
|
||||||
|
|
||||||
function getAdminTitle(isPro, proVersion) {
|
function getAdminTitle(isPro, proVersion) {
|
||||||
const corePill = `
|
const corePill = `
|
||||||
@@ -22,6 +25,23 @@ function getAdminTitle(isPro, proVersion) {
|
|||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Normalize versions so "v1.0.1" and "1.0.1" compare cleanly
|
||||||
|
const norm = (v) => String(v || '').trim().replace(/^v/i, '');
|
||||||
|
|
||||||
|
const latestRaw = (typeof PRO_LATEST_BUNDLE_VERSION !== 'undefined'
|
||||||
|
? PRO_LATEST_BUNDLE_VERSION
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentRaw = (proVersion && proVersion !== 'not installed')
|
||||||
|
? String(proVersion)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const hasCurrent = !!norm(currentRaw);
|
||||||
|
const hasLatest = !!norm(latestRaw);
|
||||||
|
const hasUpdate = isPro && hasCurrent && hasLatest &&
|
||||||
|
norm(currentRaw) !== norm(latestRaw);
|
||||||
|
|
||||||
if (!isPro) {
|
if (!isPro) {
|
||||||
// Free/core only
|
// Free/core only
|
||||||
return `
|
return `
|
||||||
@@ -30,18 +50,32 @@ function getAdminTitle(isPro, proVersion) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pv = proVersion ? `Pro v${proVersion}` : 'Pro';
|
const pvLabel = hasCurrent ? `Pro v${norm(currentRaw)}` : 'Pro';
|
||||||
|
|
||||||
const proPill = `
|
const proPill = `
|
||||||
<span class="badge badge-pill badge-warning admin-pro-badge">
|
<span class="badge badge-pill badge-warning admin-pro-badge">
|
||||||
${pv}
|
${pvLabel}
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const updateHint = hasUpdate
|
||||||
|
? `
|
||||||
|
<a
|
||||||
|
href="https://filerise.net/pro/update.php"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="badge badge-pill badge-warning admin-pro-badge"
|
||||||
|
style="cursor:pointer; text-decoration:none; margin-left:4px;">
|
||||||
|
Pro update available
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
${t("admin_panel")}
|
${t("admin_panel")}
|
||||||
${corePill}
|
${corePill}
|
||||||
${proPill}
|
${proPill}
|
||||||
|
${updateHint}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,6 +484,81 @@ function toggleSection(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function initProBundleInstaller() {
|
||||||
|
try {
|
||||||
|
const fileInput = document.getElementById('proBundleFile');
|
||||||
|
const btn = document.getElementById('btnInstallProBundle');
|
||||||
|
const statusEl = document.getElementById('proBundleStatus');
|
||||||
|
|
||||||
|
if (!fileInput || !btn || !statusEl) return;
|
||||||
|
|
||||||
|
// Allow names like: FileRisePro_v1.0.0.zip or FileRisePro-1.0.0.zip
|
||||||
|
const PRO_ZIP_NAME_RE = /^FileRisePro[_-]v?[0-9]+\.[0-9]+\.[0-9]+\.zip$/i;
|
||||||
|
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const file = fileInput.files && fileInput.files[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
statusEl.textContent = 'Choose a FileRise Pro .zip bundle first.';
|
||||||
|
statusEl.className = 'small text-danger';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = file.name || '';
|
||||||
|
if (!PRO_ZIP_NAME_RE.test(name)) {
|
||||||
|
statusEl.textContent = 'Bundle must be named like "FileRisePro_v1.0.0.zip".';
|
||||||
|
statusEl.className = 'small text-danger';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('bundle', file);
|
||||||
|
|
||||||
|
statusEl.textContent = 'Uploading and installing Pro bundle...';
|
||||||
|
statusEl.className = 'small text-muted';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/admin/installProBundle.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-Token': window.csrfToken || ''
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = await resp.json();
|
||||||
|
} catch (_) {
|
||||||
|
// ignore JSON parse errors; handled below
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok || !data || !data.success) {
|
||||||
|
const msg = data && data.error
|
||||||
|
? data.error
|
||||||
|
: `HTTP ${resp.status}`;
|
||||||
|
statusEl.textContent = 'Install failed: ' + msg;
|
||||||
|
statusEl.className = 'small text-danger';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionText = data.proVersion ? ` (version ${data.proVersion})` : '';
|
||||||
|
statusEl.textContent = 'Pro bundle installed' + versionText + '. Reload the page to apply changes.';
|
||||||
|
statusEl.className = 'small text-success';
|
||||||
|
|
||||||
|
if (typeof loadAdminConfigFunc === 'function') {
|
||||||
|
loadAdminConfigFunc();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = 'Install failed: ' + (e && e.message ? e.message : String(e));
|
||||||
|
statusEl.className = 'small text-danger';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to init Pro bundle installer', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadShareLinksSection() {
|
function loadShareLinksSection() {
|
||||||
const container = document.getElementById("shareLinksContent");
|
const container = document.getElementById("shareLinksContent");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -1266,18 +1375,32 @@ async function ooProbeFrame(docsOrigin, timeoutMs = 4000) {
|
|||||||
// --- FileRise Pro / License section ---
|
// --- FileRise Pro / License section ---
|
||||||
const proContent = document.getElementById("proContent");
|
const proContent = document.getElementById("proContent");
|
||||||
if (proContent) {
|
if (proContent) {
|
||||||
|
// Normalize versions so "v1.0.1" and "1.0.1" compare cleanly
|
||||||
|
const norm = (v) => (String(v || '').trim().replace(/^v/i, ''));
|
||||||
|
|
||||||
|
const currentVersionRaw = (proVersion && proVersion !== 'not installed') ? String(proVersion) : '';
|
||||||
|
const latestVersionRaw = PRO_LATEST_BUNDLE_VERSION || '';
|
||||||
|
const hasCurrent = !!norm(currentVersionRaw);
|
||||||
|
const hasLatest = !!norm(latestVersionRaw);
|
||||||
|
const hasUpdate = hasCurrent && hasLatest && norm(currentVersionRaw) !== norm(latestVersionRaw);
|
||||||
|
|
||||||
const proMetaHtml =
|
const proMetaHtml =
|
||||||
isPro && (proType || proEmail || proVersion)
|
isPro && (proType || proEmail || proVersion)
|
||||||
? `
|
? `
|
||||||
<div class="pro-license-meta">
|
<div class="pro-license-meta" style="margin-top:8px;font-size:12px;color:#777;">
|
||||||
<div>
|
<div>
|
||||||
✅ ${proType ? `License type: ${proType}` : 'License active'}
|
✅ ${proType ? `License type: ${proType}` : 'License active'}
|
||||||
${proType && proEmail ? ' • ' : ''}
|
${proType && proEmail ? ' • ' : ''}
|
||||||
${proEmail ? `Licensed to: ${proEmail}` : ''}
|
${proEmail ? `Licensed to: ${proEmail}` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${hasCurrent ? `
|
||||||
<div>
|
<div>
|
||||||
Pro bundle version: v${proVersion}
|
Installed Pro bundle: v${norm(currentVersionRaw)}
|
||||||
</div>
|
</div>` : ''}
|
||||||
|
${hasLatest ? `
|
||||||
|
<div>
|
||||||
|
Latest Pro bundle (UI hint): ${latestVersionRaw}
|
||||||
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: '';
|
: '';
|
||||||
@@ -1308,8 +1431,13 @@ if (proContent) {
|
|||||||
href="https://filerise.net/pro/update.php"
|
href="https://filerise.net/pro/update.php"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="btn btn-sm btn-pro-admin">
|
class="btn btn-sm btn-pro-admin d-inline-flex align-items-center"
|
||||||
Download latest Pro bundle
|
>
|
||||||
|
<span>Download latest Pro bundle</span>
|
||||||
|
${hasUpdate ? `
|
||||||
|
<span class="badge badge-light" style="margin-left:6px;">
|
||||||
|
Update available
|
||||||
|
</span>` : ''}
|
||||||
</a>
|
</a>
|
||||||
<small class="text-muted d-block" style="margin-top:4px;">
|
<small class="text-muted d-block" style="margin-top:4px;">
|
||||||
Opens filerise.net in a new tab where you can enter your Pro license
|
Opens filerise.net in a new tab where you can enter your Pro license
|
||||||
@@ -1322,7 +1450,8 @@ if (proContent) {
|
|||||||
href="https://filerise.net/pro/checkout.php"
|
href="https://filerise.net/pro/checkout.php"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="btn btn-sm btn-pro-admin">
|
class="btn btn-sm btn-pro-admin"
|
||||||
|
>
|
||||||
Buy FileRise Pro
|
Buy FileRise Pro
|
||||||
</a>
|
</a>
|
||||||
<small class="text-muted d-block" style="margin-top:4px;">
|
<small class="text-muted d-block" style="margin-top:4px;">
|
||||||
@@ -1332,7 +1461,16 @@ if (proContent) {
|
|||||||
`}
|
`}
|
||||||
|
|
||||||
<div class="form-group" style="margin-top:10px;">
|
<div class="form-group" style="margin-top:10px;">
|
||||||
<label for="proLicenseInput" style="font-size:12px;">License key</label>
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<label for="proLicenseInput" style="font-size:12px; margin-bottom:0;">License key</label>
|
||||||
|
${isPro && proLicense ? `
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-link btn-sm p-0"
|
||||||
|
id="proCopyLicenseBtn">
|
||||||
|
Copy current license
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
id="proLicenseInput"
|
id="proLicenseInput"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
@@ -1344,30 +1482,14 @@ if (proContent) {
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${isPro && proLicense ? `
|
|
||||||
<div style="margin-top:6px;">
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm" id="proCopyLicenseBtn">
|
|
||||||
Copy current license
|
|
||||||
</button>
|
|
||||||
<small class="text-muted d-block" style="margin-top:4px;">
|
|
||||||
Copies the saved license so you can reuse it for upgrades or downloads on filerise.net.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
<div class="form-group" style="margin-top:6px;">
|
<div class="form-group" style="margin-top:6px;">
|
||||||
<label style="font-size:12px;">Or upload license file</label>
|
<label style="font-size:12px;">Or upload license file</label>
|
||||||
<div class="input-group">
|
<input
|
||||||
<input
|
type="file"
|
||||||
type="file"
|
id="proLicenseFile"
|
||||||
id="proLicenseFile"
|
class="form-control-file"
|
||||||
class="form-control"
|
accept=".lic,.json,.txt,.filerise-lic"
|
||||||
accept=".lic,.json,.txt,.filerise-lic"
|
/>
|
||||||
/>
|
|
||||||
<button type="button" class="btn btn-sm btn-secondary" id="proLoadLicenseFileBtn">
|
|
||||||
Load from file
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Supported: FileRise.lic, plain text with FRP1... or JSON containing a <code>license</code> field.
|
Supported: FileRise.lic, plain text with FRP1... or JSON containing a <code>license</code> field.
|
||||||
</small>
|
</small>
|
||||||
@@ -1376,26 +1498,44 @@ if (proContent) {
|
|||||||
<button type="button" class="btn btn-primary btn-sm" id="proSaveLicenseBtn" style="margin-top:8px;">
|
<button type="button" class="btn btn-primary btn-sm" id="proSaveLicenseBtn" style="margin-top:8px;">
|
||||||
Save license
|
Save license
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="mt-3 border-top pt-3" style="margin-top:14px;">
|
||||||
|
<h6 class="mb-1">Install / update Pro bundle</h6>
|
||||||
|
<p class="text-muted small mb-2">
|
||||||
|
Upload the <code>.zip</code> bundle you downloaded from <a href="https://filerise.net" target="_blank" rel="noopener noreferrer">filerise.net</a>.
|
||||||
|
This runs locally on your server and never contacts an external update service.
|
||||||
|
</p>
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2" style="margin-top:4px;">
|
||||||
|
<input type="file"
|
||||||
|
id="proBundleFile"
|
||||||
|
accept=".zip"
|
||||||
|
class="form-control-file mb-2 mb-sm-0" />
|
||||||
|
<button type="button"
|
||||||
|
id="btnInstallProBundle"
|
||||||
|
class="btn btn-sm btn-pro-admin">
|
||||||
|
Install Pro bundle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="proBundleStatus" class="small mt-2"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Wire up local Pro bundle installer (upload .zip into core)
|
||||||
|
initProBundleInstaller();
|
||||||
|
|
||||||
// Pre-fill textarea with saved license if present
|
// Pre-fill textarea with saved license if present
|
||||||
const licenseTextarea = document.getElementById('proLicenseInput');
|
const licenseTextarea = document.getElementById('proLicenseInput');
|
||||||
if (licenseTextarea && proLicense) {
|
if (licenseTextarea && proLicense) {
|
||||||
licenseTextarea.value = proLicense;
|
licenseTextarea.value = proLicense;
|
||||||
}
|
}
|
||||||
|
|
||||||
// File upload → fill textarea (unchanged)
|
// Auto-load license when a file is selected
|
||||||
const fileInput = document.getElementById('proLicenseFile');
|
const fileInput = document.getElementById('proLicenseFile');
|
||||||
const fileBtn = document.getElementById('proLoadLicenseFileBtn');
|
if (fileInput && licenseTextarea) {
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
if (fileInput && fileBtn && licenseTextarea) {
|
|
||||||
fileBtn.addEventListener('click', () => {
|
|
||||||
const file = fileInput.files && fileInput.files[0];
|
const file = fileInput.files && fileInput.files[0];
|
||||||
if (!file) {
|
if (!file) return;
|
||||||
showToast('Please choose a license file first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
@@ -1428,7 +1568,7 @@ if (proContent) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy current license button
|
// Copy current license button (now inline next to the label)
|
||||||
const proCopyBtn = document.getElementById('proCopyLicenseBtn');
|
const proCopyBtn = document.getElementById('proCopyLicenseBtn');
|
||||||
if (proCopyBtn && proLicense) {
|
if (proCopyBtn && proLicense) {
|
||||||
proCopyBtn.addEventListener('click', async () => {
|
proCopyBtn.addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export let fileData = [];
|
|||||||
export let sortOrder = { column: "uploaded", ascending: true };
|
export let sortOrder = { column: "uploaded", ascending: true };
|
||||||
|
|
||||||
|
|
||||||
|
const FOLDER_STRIP_PAGE_SIZE = 50;
|
||||||
// onnlyoffice
|
// onnlyoffice
|
||||||
let OO_ENABLED = false;
|
let OO_ENABLED = false;
|
||||||
let OO_EXTS = new Set();
|
let OO_EXTS = new Set();
|
||||||
@@ -58,6 +58,143 @@ export async function initOnlyOfficeCaps() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wireFolderStripItems(strip) {
|
||||||
|
if (!strip) return;
|
||||||
|
|
||||||
|
// Click / DnD / context menu
|
||||||
|
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||||
|
// 1) click to navigate
|
||||||
|
el.addEventListener("click", () => {
|
||||||
|
const dest = el.dataset.folder;
|
||||||
|
if (!dest) return;
|
||||||
|
|
||||||
|
window.currentFolder = dest;
|
||||||
|
localStorage.setItem("lastOpenedFolder", dest);
|
||||||
|
updateBreadcrumbTitle(dest);
|
||||||
|
|
||||||
|
document.querySelectorAll(".folder-option.selected")
|
||||||
|
.forEach(o => o.classList.remove("selected"));
|
||||||
|
document
|
||||||
|
.querySelector(`.folder-option[data-folder="${dest}"]`)
|
||||||
|
?.classList.add("selected");
|
||||||
|
|
||||||
|
loadFileList(dest);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) drag & drop
|
||||||
|
el.addEventListener("dragover", folderDragOverHandler);
|
||||||
|
el.addEventListener("dragleave", folderDragLeaveHandler);
|
||||||
|
el.addEventListener("drop", folderDropHandler);
|
||||||
|
|
||||||
|
// 3) right-click context menu
|
||||||
|
el.addEventListener("contextmenu", e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const dest = el.dataset.folder;
|
||||||
|
if (!dest) return;
|
||||||
|
|
||||||
|
window.currentFolder = dest;
|
||||||
|
localStorage.setItem("lastOpenedFolder", dest);
|
||||||
|
|
||||||
|
strip.querySelectorAll(".folder-item.selected")
|
||||||
|
.forEach(i => i.classList.remove("selected"));
|
||||||
|
el.classList.add("selected");
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
label: t("create_folder"),
|
||||||
|
action: () => document.getElementById("createFolderModal").style.display = "block"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("move_folder"),
|
||||||
|
action: () => openMoveFolderUI()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("rename_folder"),
|
||||||
|
action: () => openRenameFolderModal()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("color_folder"),
|
||||||
|
action: () => openColorFolderModal(dest)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("folder_share"),
|
||||||
|
action: () => openFolderShareModal(dest)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("delete_folder"),
|
||||||
|
action: () => openDeleteFolderModal()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close menu when clicking elsewhere
|
||||||
|
document.addEventListener("click", hideFolderManagerContextMenu);
|
||||||
|
|
||||||
|
// Folder icons
|
||||||
|
strip.querySelectorAll(".folder-item").forEach(el => {
|
||||||
|
const full = el.getAttribute('data-folder');
|
||||||
|
if (full) attachStripIconAsync(el, full, 48);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFolderStripPaged(strip, subfolders) {
|
||||||
|
if (!strip) return;
|
||||||
|
|
||||||
|
if (!window.showFoldersInList || !subfolders.length) {
|
||||||
|
strip.style.display = "none";
|
||||||
|
strip.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = subfolders.length;
|
||||||
|
const pageSize = FOLDER_STRIP_PAGE_SIZE;
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
function drawPage(page) {
|
||||||
|
const endIdx = Math.min(page * pageSize, total);
|
||||||
|
const visible = subfolders.slice(0, endIdx);
|
||||||
|
|
||||||
|
let html = visible.map(sf => `
|
||||||
|
<div class="folder-item"
|
||||||
|
data-folder="${sf.full}"
|
||||||
|
draggable="true">
|
||||||
|
<span class="folder-svg"></span>
|
||||||
|
<div class="folder-name">
|
||||||
|
${escapeHTML(sf.name)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
if (endIdx < total) {
|
||||||
|
html += `
|
||||||
|
<button type="button"
|
||||||
|
class="folder-strip-load-more">
|
||||||
|
${t('load_more_folders') || t('load_more') || 'Load more folders'}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
strip.innerHTML = html;
|
||||||
|
|
||||||
|
applyFolderStripLayout(strip);
|
||||||
|
wireFolderStripItems(strip);
|
||||||
|
|
||||||
|
const loadMoreBtn = strip.querySelector(".folder-strip-load-more");
|
||||||
|
if (loadMoreBtn) {
|
||||||
|
loadMoreBtn.addEventListener("click", e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
drawPage(page + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
// helper to repaint one strip item quickly
|
// helper to repaint one strip item quickly
|
||||||
function repaintStripIcon(folder) {
|
function repaintStripIcon(folder) {
|
||||||
@@ -78,6 +215,31 @@ function repaintStripIcon(folder) {
|
|||||||
iconSpan.innerHTML = folderSVG(kind);
|
iconSpan.innerHTML = folderSVG(kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyFolderStripLayout(strip) {
|
||||||
|
if (!strip) return;
|
||||||
|
const hasItems = strip.querySelector('.folder-item') !== null;
|
||||||
|
if (!hasItems) {
|
||||||
|
strip.style.display = 'none';
|
||||||
|
strip.classList.remove('folder-strip-mobile', 'folder-strip-desktop');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMobile = window.innerWidth <= 640; // tweak breakpoint if you want
|
||||||
|
|
||||||
|
strip.classList.add('folder-strip-container');
|
||||||
|
strip.classList.toggle('folder-strip-mobile', isMobile);
|
||||||
|
strip.classList.toggle('folder-strip-desktop', !isMobile);
|
||||||
|
|
||||||
|
strip.style.display = isMobile ? 'block' : 'flex';
|
||||||
|
strip.style.overflowX = isMobile ? 'visible' : 'auto';
|
||||||
|
strip.style.overflowY = isMobile ? 'auto' : 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
const strip = document.getElementById('folderStripContainer');
|
||||||
|
if (strip) applyFolderStripLayout(strip);
|
||||||
|
});
|
||||||
|
|
||||||
// Listen once: update strip + tree when folder color changes
|
// Listen once: update strip + tree when folder color changes
|
||||||
window.addEventListener('folderColorChanged', (e) => {
|
window.addEventListener('folderColorChanged', (e) => {
|
||||||
const { folder } = e.detail || {};
|
const { folder } = e.detail || {};
|
||||||
@@ -812,93 +974,8 @@ export async function loadFileList(folderParam) {
|
|||||||
actionsContainer.parentNode.insertBefore(strip, actionsContainer);
|
actionsContainer.parentNode.insertBefore(strip, actionsContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.showFoldersInList && subfolders.length) {
|
// NEW: paged + responsive strip
|
||||||
strip.innerHTML = subfolders.map(sf => {
|
renderFolderStripPaged(strip, subfolders);
|
||||||
return `
|
|
||||||
<div class="folder-item"
|
|
||||||
data-folder="${sf.full}"
|
|
||||||
draggable="true"
|
|
||||||
style="display:flex;align-items:center;gap:10px;min-width:0;">
|
|
||||||
<span class="folder-svg" style="flex:0 0 auto;line-height:0;"></span>
|
|
||||||
<div class="folder-name"
|
|
||||||
style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
|
|
||||||
${escapeHTML(sf.name)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join("");
|
|
||||||
strip.style.display = "flex";
|
|
||||||
|
|
||||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
|
||||||
// 1) click to navigate
|
|
||||||
el.addEventListener("click", () => {
|
|
||||||
const dest = el.dataset.folder;
|
|
||||||
window.currentFolder = dest;
|
|
||||||
localStorage.setItem("lastOpenedFolder", dest);
|
|
||||||
updateBreadcrumbTitle(dest);
|
|
||||||
document.querySelectorAll(".folder-option.selected").forEach(o => o.classList.remove("selected"));
|
|
||||||
document.querySelector(`.folder-option[data-folder="${dest}"]`)?.classList.add("selected");
|
|
||||||
loadFileList(dest);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2) drag & drop
|
|
||||||
el.addEventListener("dragover", folderDragOverHandler);
|
|
||||||
el.addEventListener("dragleave", folderDragLeaveHandler);
|
|
||||||
el.addEventListener("drop", folderDropHandler);
|
|
||||||
|
|
||||||
// 3) right-click context menu
|
|
||||||
el.addEventListener("contextmenu", e => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const dest = el.dataset.folder;
|
|
||||||
window.currentFolder = dest;
|
|
||||||
localStorage.setItem("lastOpenedFolder", dest);
|
|
||||||
|
|
||||||
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
|
|
||||||
el.classList.add("selected");
|
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{
|
|
||||||
label: t("create_folder"),
|
|
||||||
action: () => document.getElementById("createFolderModal").style.display = "block"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("move_folder"),
|
|
||||||
action: () => openMoveFolderUI()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("rename_folder"),
|
|
||||||
action: () => openRenameFolderModal()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("color_folder"),
|
|
||||||
action: () => openColorFolderModal(dest)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("folder_share"),
|
|
||||||
action: () => openFolderShareModal(dest)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("delete_folder"),
|
|
||||||
action: () => openDeleteFolderModal()
|
|
||||||
}
|
|
||||||
];
|
|
||||||
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("click", hideFolderManagerContextMenu);
|
|
||||||
|
|
||||||
// After wiring events for each .folder-item:
|
|
||||||
strip.querySelectorAll(".folder-item").forEach(el => {
|
|
||||||
const full = el.getAttribute('data-folder');
|
|
||||||
attachStripIconAsync(el, full, 48);
|
|
||||||
});
|
|
||||||
|
|
||||||
} else {
|
|
||||||
strip.style.display = "none";
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore folder errors; rows already rendered
|
// ignore folder errors; rows already rendered
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -469,102 +469,118 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------- VIDEOS -------------------- */
|
/* -------------------- VIDEOS -------------------- */
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
let video = document.createElement("video"); // let so we can rebind
|
let video = document.createElement("video");
|
||||||
video.controls = true;
|
video.controls = true;
|
||||||
video.style.maxWidth = "88vw";
|
video.preload = 'auto'; // hint browser to start fetching quickly
|
||||||
video.style.maxHeight = "88vh";
|
video.style.maxWidth = "88vw";
|
||||||
video.style.objectFit = "contain";
|
video.style.maxHeight = "88vh";
|
||||||
container.appendChild(video);
|
video.style.objectFit = "contain";
|
||||||
|
container.appendChild(video);
|
||||||
// Top-right action icons (Material icons, theme-aware)
|
|
||||||
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
// Top-right action icons (Material icons, theme-aware)
|
||||||
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
||||||
actionWrap.appendChild(markBtnIcon);
|
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
||||||
actionWrap.appendChild(clearBtnIcon);
|
actionWrap.appendChild(markBtnIcon);
|
||||||
|
actionWrap.appendChild(clearBtnIcon);
|
||||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
|
||||||
overlay.mediaType = 'video';
|
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||||
overlay.mediaList = videos;
|
overlay.mediaType = 'video';
|
||||||
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
overlay.mediaList = videos;
|
||||||
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||||
|
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||||
const setVideoSrc = (nm) => { video.src = buildPreviewUrl(folder, nm); setTitle(overlay, nm); };
|
|
||||||
|
// Track which file is currently active
|
||||||
const SAVE_INTERVAL_MS = 5000;
|
let currentName = name;
|
||||||
let lastSaveAt = 0;
|
|
||||||
let pending = false;
|
const setVideoSrc = (nm) => {
|
||||||
|
currentName = nm;
|
||||||
async function getProgress(nm) {
|
video.src = buildPreviewUrl(folder, nm);
|
||||||
try {
|
setTitle(overlay, nm);
|
||||||
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
};
|
||||||
const data = await res.json();
|
|
||||||
return data && data.state ? data.state : null;
|
const SAVE_INTERVAL_MS = 5000;
|
||||||
} catch { return null; }
|
let lastSaveAt = 0;
|
||||||
}
|
let pending = false;
|
||||||
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
|
||||||
try {
|
async function getProgress(nm) {
|
||||||
pending = true;
|
try {
|
||||||
const res = await fetch("/api/media/updateProgress.php", {
|
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
||||||
method: "POST",
|
const data = await res.json();
|
||||||
credentials: "include",
|
return data && data.state ? data.state : null;
|
||||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
} catch { return null; }
|
||||||
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
pending = false;
|
|
||||||
return data;
|
|
||||||
} catch (e) { pending = false; console.error(e); return null; }
|
|
||||||
}
|
|
||||||
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
|
||||||
|
|
||||||
function renderStatus(state) {
|
|
||||||
if (!statusChip) return;
|
|
||||||
// Completed
|
|
||||||
if (state && state.completed) {
|
|
||||||
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
|
||||||
statusChip.style.display = 'inline-block';
|
|
||||||
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
|
||||||
statusChip.style.background = 'rgba(34,197,94,.15)';
|
|
||||||
statusChip.style.color = '#22c55e';
|
|
||||||
markBtnIcon.style.display = 'none';
|
|
||||||
clearBtnIcon.style.display = '';
|
|
||||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// In progress
|
|
||||||
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
||||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
try {
|
||||||
statusChip.textContent = `${pct}%`;
|
pending = true;
|
||||||
statusChip.style.display = 'inline-block';
|
const res = await fetch("/api/media/updateProgress.php", {
|
||||||
const dark = document.documentElement.classList.contains('dark-mode');
|
method: "POST",
|
||||||
const ORANGE_HEX = '#ea580c'; // darker orange (works in light/dark)
|
credentials: "include",
|
||||||
statusChip.style.color = ORANGE_HEX;
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)'; // #ea580c @ different alphas
|
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
||||||
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
pending = false;
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
pending = false;
|
||||||
|
console.error(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
||||||
|
|
||||||
|
function renderStatus(state) {
|
||||||
|
if (!statusChip) return;
|
||||||
|
|
||||||
|
// Completed
|
||||||
|
if (state && state.completed) {
|
||||||
|
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
||||||
|
statusChip.style.display = 'inline-block';
|
||||||
|
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
||||||
|
statusChip.style.background = 'rgba(34,197,94,.15)';
|
||||||
|
statusChip.style.color = '#22c55e';
|
||||||
|
markBtnIcon.style.display = 'none';
|
||||||
|
clearBtnIcon.style.display = '';
|
||||||
|
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In progress
|
||||||
|
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
||||||
|
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||||
|
statusChip.textContent = `${pct}%`;
|
||||||
|
statusChip.style.display = 'inline-block';
|
||||||
|
|
||||||
|
const dark = document.documentElement.classList.contains('dark-mode');
|
||||||
|
const ORANGE_HEX = '#ea580c';
|
||||||
|
statusChip.style.color = ORANGE_HEX;
|
||||||
|
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
|
||||||
|
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
|
||||||
|
|
||||||
|
markBtnIcon.style.display = '';
|
||||||
|
clearBtnIcon.style.display = '';
|
||||||
|
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No progress
|
||||||
|
statusChip.style.display = 'none';
|
||||||
markBtnIcon.style.display = '';
|
markBtnIcon.style.display = '';
|
||||||
clearBtnIcon.style.display = '';
|
clearBtnIcon.style.display = 'none';
|
||||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// No progress
|
|
||||||
statusChip.style.display = 'none';
|
// ---- Event handlers (use currentName instead of rebinding per file) ----
|
||||||
markBtnIcon.style.display = '';
|
|
||||||
clearBtnIcon.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindVideoEvents(nm) {
|
|
||||||
const nv = video.cloneNode(true);
|
|
||||||
video.replaceWith(nv);
|
|
||||||
video = nv;
|
|
||||||
|
|
||||||
video.addEventListener("loadedmetadata", async () => {
|
video.addEventListener("loadedmetadata", async () => {
|
||||||
|
const nm = currentName;
|
||||||
try {
|
try {
|
||||||
const state = await getProgress(nm);
|
const state = await getProgress(nm);
|
||||||
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
|
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
|
||||||
video.currentTime = state.seconds;
|
video.currentTime = state.seconds;
|
||||||
const seconds = Math.floor(video.currentTime || 0);
|
const seconds = Math.floor(video.currentTime || 0);
|
||||||
const duration = Math.floor(video.duration || 0);
|
const duration = Math.floor(video.duration || 0);
|
||||||
setFileProgressBadge(nm, seconds, duration);
|
setFileProgressBadge(nm, seconds, duration);
|
||||||
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
||||||
@@ -577,20 +593,24 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
renderStatus(null);
|
renderStatus(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
video.addEventListener("timeupdate", async () => {
|
video.addEventListener("timeupdate", async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
|
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
|
||||||
lastSaveAt = now;
|
lastSaveAt = now;
|
||||||
const seconds = Math.floor(video.currentTime || 0);
|
|
||||||
|
const nm = currentName;
|
||||||
|
const seconds = Math.floor(video.currentTime || 0);
|
||||||
const duration = Math.floor(video.duration || 0);
|
const duration = Math.floor(video.duration || 0);
|
||||||
|
|
||||||
sendProgress({ nm, seconds, duration });
|
sendProgress({ nm, seconds, duration });
|
||||||
setFileProgressBadge(nm, seconds, duration);
|
setFileProgressBadge(nm, seconds, duration);
|
||||||
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
|
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
|
||||||
renderStatus({ seconds, duration, completed: false });
|
renderStatus({ seconds, duration, completed: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
video.addEventListener("ended", async () => {
|
video.addEventListener("ended", async () => {
|
||||||
|
const nm = currentName;
|
||||||
const duration = Math.floor(video.duration || 0);
|
const duration = Math.floor(video.duration || 0);
|
||||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||||
@@ -598,50 +618,54 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
setFileWatchedBadge(nm, true);
|
setFileWatchedBadge(nm, true);
|
||||||
renderStatus({ seconds: duration, duration, completed: true });
|
renderStatus({ seconds: duration, duration, completed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
markBtnIcon.onclick = async () => {
|
markBtnIcon.onclick = async () => {
|
||||||
|
const nm = currentName;
|
||||||
const duration = Math.floor(video.duration || 0);
|
const duration = Math.floor(video.duration || 0);
|
||||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||||
showToast(t("marked_viewed") || "Marked as viewed");
|
showToast(t("marked_viewed") || "Marked as viewed");
|
||||||
setFileWatchedBadge(nm, true);
|
setFileWatchedBadge(nm, true);
|
||||||
renderStatus({ seconds: duration, duration, completed: true });
|
renderStatus({ seconds: duration, duration, completed: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
clearBtnIcon.onclick = async () => {
|
clearBtnIcon.onclick = async () => {
|
||||||
|
const nm = currentName;
|
||||||
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
|
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
|
||||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||||
showToast(t("progress_cleared") || "Progress cleared");
|
showToast(t("progress_cleared") || "Progress cleared");
|
||||||
setFileWatchedBadge(nm, false);
|
setFileWatchedBadge(nm, false);
|
||||||
renderStatus(null);
|
renderStatus(null);
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
const navigate = (dir) => {
|
||||||
const navigate = (dir) => {
|
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
const nm = overlay.mediaList[overlay.mediaIndex].name;
|
||||||
const nm = overlay.mediaList[overlay.mediaIndex].name;
|
setVideoSrc(nm);
|
||||||
setVideoSrc(nm);
|
renderStatus(null);
|
||||||
bindVideoEvents(nm);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (videos.length > 1) {
|
|
||||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
|
||||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
|
||||||
const onKey = (e) => {
|
|
||||||
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
|
|
||||||
if (e.key === "ArrowLeft") navigate(-1);
|
|
||||||
if (e.key === "ArrowRight") navigate(+1);
|
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
overlay._onKey = onKey;
|
if (videos.length > 1) {
|
||||||
|
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||||
|
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (!document.body.contains(overlay)) {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowLeft") navigate(-1);
|
||||||
|
if (e.key === "ArrowRight") navigate(+1);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
overlay._onKey = onKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVideoSrc(name);
|
||||||
|
renderStatus(null);
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setVideoSrc(name);
|
|
||||||
renderStatus(null);
|
|
||||||
bindVideoEvents(name);
|
|
||||||
overlay.style.display = "flex";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------- AUDIO / OTHER -------------------- */
|
/* -------------------- AUDIO / OTHER -------------------- */
|
||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
const audio = document.createElement("audio");
|
const audio = document.createElement("audio");
|
||||||
|
|||||||
@@ -330,7 +330,8 @@ const translations = {
|
|||||||
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
|
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
|
||||||
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
|
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
|
||||||
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
|
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
|
||||||
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder."
|
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder.",
|
||||||
|
"load_more_folders": "Load More Folders"
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// generated by CI
|
// generated by CI
|
||||||
window.APP_VERSION = 'v1.9.9';
|
window.APP_VERSION = 'v1.9.11';
|
||||||
|
|||||||
@@ -272,6 +272,265 @@ public function setLicense(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function installProBundle(): void
|
||||||
|
{
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Guard rails: method + auth + CSRF
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::requireAuth();
|
||||||
|
self::requireAdmin();
|
||||||
|
self::requireCsrf();
|
||||||
|
|
||||||
|
// Ensure ZipArchive is available
|
||||||
|
if (!class_exists('\\ZipArchive')) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'ZipArchive extension is required on the server.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic upload validation
|
||||||
|
if (empty($_FILES['bundle']) || !is_array($_FILES['bundle'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing uploaded bundle (field "bundle").']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$f = $_FILES['bundle'];
|
||||||
|
|
||||||
|
if (!empty($f['error']) && $f['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
$msg = 'Upload error.';
|
||||||
|
switch ($f['error']) {
|
||||||
|
case UPLOAD_ERR_INI_SIZE:
|
||||||
|
case UPLOAD_ERR_FORM_SIZE:
|
||||||
|
$msg = 'Uploaded file exceeds size limit.';
|
||||||
|
break;
|
||||||
|
case UPLOAD_ERR_PARTIAL:
|
||||||
|
$msg = 'Uploaded file was only partially received.';
|
||||||
|
break;
|
||||||
|
case UPLOAD_ERR_NO_FILE:
|
||||||
|
$msg = 'No file was uploaded.';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$msg = 'Upload failed with error code ' . (int)$f['error'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => $msg]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmpName = $f['tmp_name'] ?? '';
|
||||||
|
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid uploaded file.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against unexpectedly large bundles (e.g., >100MB)
|
||||||
|
$size = isset($f['size']) ? (int)$f['size'] : 0;
|
||||||
|
if ($size <= 0 || $size > 100 * 1024 * 1024) {
|
||||||
|
http_response_code(413);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Bundle size is invalid or too large (max 100MB).']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: require .zip extension by name (best-effort)
|
||||||
|
$origName = (string)($f['name'] ?? '');
|
||||||
|
if ($origName !== '' && !preg_match('/\.zip$/i', $origName)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Bundle must be a .zip file.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare temp working dir
|
||||||
|
$tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
|
||||||
|
$workDir = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8));
|
||||||
|
if (!@mkdir($workDir, 0700, true)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to prepare temp dir.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zipPath = $workDir . DIRECTORY_SEPARATOR . 'bundle.zip';
|
||||||
|
if (!@move_uploaded_file($tmpName, $zipPath)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to move uploaded bundle.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip = new \ZipArchive();
|
||||||
|
if ($zip->open($zipPath) !== true) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to open ZIP bundle.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$installed = [
|
||||||
|
'src' => [],
|
||||||
|
'public' => [],
|
||||||
|
'docs' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$projectRoot = rtrim(PROJECT_ROOT, DIRECTORY_SEPARATOR);
|
||||||
|
|
||||||
|
// Where Pro bundle code lives (defaults to PROJECT_ROOT . '/users/pro')
|
||||||
|
$bundleRoot = defined('FR_PRO_BUNDLE_DIR')
|
||||||
|
? rtrim(FR_PRO_BUNDLE_DIR, DIRECTORY_SEPARATOR)
|
||||||
|
: ($projectRoot . DIRECTORY_SEPARATOR . 'users' . DIRECTORY_SEPARATOR . 'pro');
|
||||||
|
|
||||||
|
// Put README-Pro.txt / LICENSE-Pro.txt inside the bundle dir as well
|
||||||
|
$proDocsDir = $bundleRoot;
|
||||||
|
if (!is_dir($proDocsDir)) {
|
||||||
|
@mkdir($proDocsDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedTopLevel = ['LICENSE-Pro.txt', 'README-Pro.txt'];
|
||||||
|
|
||||||
|
// Iterate entries and selectively extract/copy expected files only
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$name = $zip->getNameIndex($i);
|
||||||
|
if ($name === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalise and guard
|
||||||
|
$name = ltrim($name, "/\\");
|
||||||
|
if ($name === '' || substr($name, -1) === '/') {
|
||||||
|
continue; // skip directories
|
||||||
|
}
|
||||||
|
if (strpos($name, '../') !== false || strpos($name, '..\\') !== false) {
|
||||||
|
continue; // path traversal guard
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore macOS Finder junk: __MACOSX and "._" resource forks
|
||||||
|
$base = basename($name);
|
||||||
|
if (
|
||||||
|
str_starts_with($name, '__MACOSX/') ||
|
||||||
|
str_contains($name, '/__MACOSX/') ||
|
||||||
|
str_starts_with($base, '._')
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetPath = null;
|
||||||
|
$category = null;
|
||||||
|
|
||||||
|
if (in_array($name, $allowedTopLevel, true)) {
|
||||||
|
// Docs → bundle dir (under /users/pro)
|
||||||
|
$targetPath = $proDocsDir . DIRECTORY_SEPARATOR . $name;
|
||||||
|
$category = 'docs';
|
||||||
|
|
||||||
|
} elseif (strpos($name, 'src/pro/') === 0) {
|
||||||
|
// e.g. src/pro/bootstrap_pro.php -> FR_PRO_BUNDLE_DIR/bootstrap_pro.php
|
||||||
|
$relative = substr($name, strlen('src/pro/'));
|
||||||
|
if ($relative === '' || substr($relative, -1) === '/') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$targetPath = $bundleRoot . DIRECTORY_SEPARATOR . $relative;
|
||||||
|
$category = 'src';
|
||||||
|
|
||||||
|
} elseif (strpos($name, 'public/api/pro/') === 0) {
|
||||||
|
// e.g. public/api/pro/uploadBrandLogo.php
|
||||||
|
$relative = substr($name, strlen('public/api/pro/'));
|
||||||
|
if ($relative === '' || substr($relative, -1) === '/') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist under bundle dir so it survives image rebuilds:
|
||||||
|
// users/pro/public/api/pro/...
|
||||||
|
$targetPath = $bundleRoot
|
||||||
|
. DIRECTORY_SEPARATOR . 'public'
|
||||||
|
. DIRECTORY_SEPARATOR . 'api'
|
||||||
|
. DIRECTORY_SEPARATOR . 'pro'
|
||||||
|
. DIRECTORY_SEPARATOR . $relative;
|
||||||
|
$category = 'public';
|
||||||
|
} else {
|
||||||
|
// Skip anything outside these prefixes
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$targetPath || !$category) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track whether we're overwriting an existing file (for reporting only)
|
||||||
|
$wasExisting = is_file($targetPath);
|
||||||
|
|
||||||
|
// Read from ZIP entry
|
||||||
|
$stream = $zip->getStream($name);
|
||||||
|
if (!$stream) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = dirname($targetPath);
|
||||||
|
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
|
||||||
|
fclose($stream);
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to create destination directory for ' . $name]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = stream_get_contents($stream);
|
||||||
|
fclose($stream);
|
||||||
|
if ($data === false) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to read data for ' . $name]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always overwrite target file on install/upgrade
|
||||||
|
if (@file_put_contents($targetPath, $data) === false) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to write ' . $name]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@chmod($targetPath, 0644);
|
||||||
|
|
||||||
|
// Track what we installed (and whether it was overwritten)
|
||||||
|
if (!isset($installed[$category])) {
|
||||||
|
$installed[$category] = [];
|
||||||
|
}
|
||||||
|
$installed[$category][] = $targetPath . ($wasExisting ? ' (overwritten)' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
// Best-effort cleanup; ignore failures
|
||||||
|
@unlink($zipPath);
|
||||||
|
@rmdir($workDir);
|
||||||
|
|
||||||
|
// Reflect current Pro status in response if bootstrap was loaded
|
||||||
|
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
|
||||||
|
$proPayload = defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)
|
||||||
|
? (FR_PRO_INFO['payload'] ?? null)
|
||||||
|
: null;
|
||||||
|
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Pro bundle installed.',
|
||||||
|
'installed' => $installed,
|
||||||
|
'proActive' => (bool)$proActive,
|
||||||
|
'proVersion' => $proVersion,
|
||||||
|
'proPayload' => $proPayload,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Exception during bundle install: ' . $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function updateConfig(): void
|
public function updateConfig(): void
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|||||||
@@ -643,25 +643,137 @@ public function deleteFiles()
|
|||||||
} finally { $this->_jsonEnd(); }
|
} finally { $this->_jsonEnd(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a file with proper HTTP Range support so HTML5 video/audio can seek.
|
||||||
|
*
|
||||||
|
* @param string $fullPath Absolute filesystem path
|
||||||
|
* @param string $downloadName Name shown in Content-Disposition
|
||||||
|
* @param string $mimeType MIME type (from FileModel::getDownloadInfo)
|
||||||
|
* @param bool $inline true => inline, false => attachment
|
||||||
|
*/
|
||||||
|
private function streamFileWithRange(string $fullPath, string $downloadName, string $mimeType, bool $inline): void
|
||||||
|
{
|
||||||
|
if (!is_file($fullPath) || !is_readable($fullPath)) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['error' => 'File not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = (int)@filesize($fullPath);
|
||||||
|
$start = 0;
|
||||||
|
$end = $size > 0 ? $size - 1 : 0;
|
||||||
|
|
||||||
|
if ($size < 0) {
|
||||||
|
$size = 0;
|
||||||
|
$end = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close session + disable output buffering for streaming
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
@session_write_close();
|
||||||
|
}
|
||||||
|
if (function_exists('apache_setenv')) {
|
||||||
|
@apache_setenv('no-gzip', '1');
|
||||||
|
}
|
||||||
|
@ini_set('zlib.output_compression', '0');
|
||||||
|
@ini_set('output_buffering', 'off');
|
||||||
|
while (ob_get_level() > 0) {
|
||||||
|
@ob_end_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
$disposition = $inline ? 'inline' : 'attachment';
|
||||||
|
$mime = $mimeType ?: 'application/octet-stream';
|
||||||
|
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Accept-Ranges: bytes');
|
||||||
|
header("Content-Type: {$mime}");
|
||||||
|
header("Content-Disposition: {$disposition}; filename=\"" . basename($downloadName) . "\"");
|
||||||
|
|
||||||
|
// Handle HTTP Range header (single range)
|
||||||
|
$length = $size;
|
||||||
|
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=\s*(\d*)-(\d*)/i', $_SERVER['HTTP_RANGE'], $m)) {
|
||||||
|
if ($m[1] !== '') {
|
||||||
|
$start = (int)$m[1];
|
||||||
|
}
|
||||||
|
if ($m[2] !== '') {
|
||||||
|
$end = (int)$m[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// clamp to file size
|
||||||
|
if ($start < 0) $start = 0;
|
||||||
|
if ($end < $start) $end = $start;
|
||||||
|
if ($end >= $size) $end = $size - 1;
|
||||||
|
|
||||||
|
$length = $end - $start + 1;
|
||||||
|
|
||||||
|
http_response_code(206);
|
||||||
|
header("Content-Range: bytes {$start}-{$end}/{$size}");
|
||||||
|
header("Content-Length: {$length}");
|
||||||
|
} else {
|
||||||
|
// no range => full file
|
||||||
|
http_response_code(200);
|
||||||
|
if ($size > 0) {
|
||||||
|
header("Content-Length: {$size}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fp = @fopen($fullPath, 'rb');
|
||||||
|
if ($fp === false) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['error' => 'Unable to open file.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($start > 0) {
|
||||||
|
@fseek($fp, $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytesToSend = $length;
|
||||||
|
$chunkSize = 8192;
|
||||||
|
|
||||||
|
while ($bytesToSend > 0 && !feof($fp)) {
|
||||||
|
$readSize = ($bytesToSend > $chunkSize) ? $chunkSize : $bytesToSend;
|
||||||
|
$buffer = fread($fp, $readSize);
|
||||||
|
if ($buffer === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
echo $buffer;
|
||||||
|
flush();
|
||||||
|
$bytesToSend -= strlen($buffer);
|
||||||
|
|
||||||
|
if (connection_aborted()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($fp);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
public function downloadFile()
|
public function downloadFile()
|
||||||
{
|
{
|
||||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
echo json_encode(["error" => "Unauthorized"]);
|
echo json_encode(["error" => "Unauthorized"]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$file = isset($_GET['file']) ? basename($_GET['file']) : '';
|
$file = isset($_GET['file']) ? basename((string)$_GET['file']) : '';
|
||||||
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
|
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||||
|
$inlineParam = isset($_GET['inline']) && (string)$_GET['inline'] === '1';
|
||||||
|
|
||||||
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
echo json_encode(["error" => "Invalid file name."]);
|
echo json_encode(["error" => "Invalid file name."]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
echo json_encode(["error" => "Invalid folder name."]);
|
echo json_encode(["error" => "Invalid folder name."]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -681,6 +793,7 @@ public function deleteFiles()
|
|||||||
|
|
||||||
if (!$fullView && !$ownGrant) {
|
if (!$fullView && !$ownGrant) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
echo json_encode(["error" => "Forbidden: no view access to this folder."]);
|
echo json_encode(["error" => "Forbidden: no view access to this folder."]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -690,6 +803,7 @@ public function deleteFiles()
|
|||||||
$meta = $this->loadFolderMetadata($folder);
|
$meta = $this->loadFolderMetadata($folder);
|
||||||
if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) {
|
if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
|
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -697,25 +811,25 @@ public function deleteFiles()
|
|||||||
|
|
||||||
$downloadInfo = FileModel::getDownloadInfo($folder, $file);
|
$downloadInfo = FileModel::getDownloadInfo($folder, $file);
|
||||||
if (isset($downloadInfo['error'])) {
|
if (isset($downloadInfo['error'])) {
|
||||||
http_response_code((in_array($downloadInfo['error'], ["File not found.", "Access forbidden."])) ? 404 : 400);
|
http_response_code(in_array($downloadInfo['error'], ["File not found.", "Access forbidden."]) ? 404 : 400);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
echo json_encode(["error" => $downloadInfo['error']]);
|
echo json_encode(["error" => $downloadInfo['error']]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$realFilePath = $downloadInfo['filePath'];
|
$realFilePath = $downloadInfo['filePath'];
|
||||||
$mimeType = $downloadInfo['mimeType'];
|
$mimeType = $downloadInfo['mimeType'];
|
||||||
header("Content-Type: " . $mimeType);
|
|
||||||
|
|
||||||
|
// Decide inline vs attachment:
|
||||||
|
// - if ?inline=1 => always inline (used by filePreview.js)
|
||||||
|
// - else keep your old behavior: images inline, everything else attachment
|
||||||
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
|
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
|
||||||
$inlineImageTypes = ['jpg','jpeg','png','gif','bmp','webp','svg','ico'];
|
$inlineImageTypes = ['jpg','jpeg','png','gif','bmp','webp','svg','ico'];
|
||||||
if (in_array($ext, $inlineImageTypes, true)) {
|
|
||||||
header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"');
|
$inline = $inlineParam || in_array($ext, $inlineImageTypes, true);
|
||||||
} else {
|
|
||||||
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
|
// Stream with proper Range support for video/audio seeking
|
||||||
}
|
$this->streamFileWithRange($realFilePath, basename($realFilePath), $mimeType, $inline);
|
||||||
header('Content-Length: ' . filesize($realFilePath));
|
|
||||||
readfile($realFilePath);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function zipStatus()
|
public function zipStatus()
|
||||||
|
|||||||
17
start.sh
17
start.sh
@@ -72,6 +72,23 @@ for d in uploads users metadata; do
|
|||||||
chmod 775 "${tgt}"
|
chmod 775 "${tgt}"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# 2.4) Sync FileRise Pro public endpoints from persistent bundle
|
||||||
|
BUNDLE_PRO_PUBLIC="/var/www/users/pro/public/api/pro"
|
||||||
|
LIVE_PRO_PUBLIC="/var/www/public/api/pro"
|
||||||
|
|
||||||
|
if [ -d "${BUNDLE_PRO_PUBLIC}" ]; then
|
||||||
|
echo "[startup] Syncing FileRise Pro public endpoints..."
|
||||||
|
mkdir -p "${LIVE_PRO_PUBLIC}"
|
||||||
|
|
||||||
|
# Copy files from bundle to live api/pro (overwrite for upgrades)
|
||||||
|
cp -R "${BUNDLE_PRO_PUBLIC}/." "${LIVE_PRO_PUBLIC}/" || echo "[startup] Pro sync copy failed (continuing)"
|
||||||
|
|
||||||
|
# Normalize ownership/permissions
|
||||||
|
chown -R www-data:www-data "${LIVE_PRO_PUBLIC}" || echo "[startup] chown api/pro failed (continuing)"
|
||||||
|
find "${LIVE_PRO_PUBLIC}" -type d -exec chmod 755 {} \; 2>/dev/null || true
|
||||||
|
find "${LIVE_PRO_PUBLIC}" -type f -exec chmod 644 {} \; 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
# 3) Ensure PHP conf dir & set upload limits
|
# 3) Ensure PHP conf dir & set upload limits
|
||||||
mkdir -p /etc/php/8.3/apache2/conf.d
|
mkdir -p /etc/php/8.3/apache2/conf.d
|
||||||
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user