${tf('source_local_path', 'Local path')}:
@@ -1678,6 +1617,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou
const sourceTypeEl = container.querySelector('#sourceType');
const sourceEnabledEl = container.querySelector('#sourceEnabled');
const sourceReadOnlyEl = container.querySelector('#sourceReadOnly');
+ const sourceDisableTrashEl = container.querySelector('#sourceDisableTrash');
const localPathEl = container.querySelector('#sourceLocalPath');
const s3BucketEl = container.querySelector('#sourceS3Bucket');
const s3RegionEl = container.querySelector('#sourceS3Region');
@@ -1731,7 +1671,27 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou
const dropboxRootPathEl = container.querySelector('#sourceDropboxRootPath');
const dropboxTeamMemberIdEl = container.querySelector('#sourceDropboxTeamMemberId');
const dropboxRootNamespaceIdEl = container.querySelector('#sourceDropboxRootNamespaceId');
+ const sourceTypeHintEl = container.querySelector('#sourceTypeAvailabilityHint');
const typeBlocks = Array.from(container.querySelectorAll('.sources-type-block'));
+ if (sourceTypeEl) {
+ Array.from(sourceTypeEl.options).forEach(opt => {
+ const type = String(opt.value || '').trim().toLowerCase();
+ if (!allowedTypeSet.has(type)) {
+ opt.remove();
+ }
+ });
+ if (!sourceTypeEl.options.length) {
+ const opt = document.createElement('option');
+ opt.value = 'local';
+ opt.textContent = 'local';
+ sourceTypeEl.appendChild(opt);
+ allowedTypeSet.add('local');
+ }
+ }
+
+ if (sourceTypeHintEl && !limitedAdaptersHint) {
+ sourceTypeHintEl.hidden = true;
+ }
let editingId = '';
let state = {
@@ -1816,11 +1776,29 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou
};
const setType = (type) => {
- const t = (type || 'local').toLowerCase();
+ let t = (type || 'local').toLowerCase();
+ if (!allowedTypeSet.has(t)) {
+ t = sourceTypeEl?.options?.[0]?.value
+ ? String(sourceTypeEl.options[0].value).toLowerCase()
+ : 'local';
+ }
sourceTypeEl.value = t;
typeBlocks.forEach(block => {
block.hidden = block.getAttribute('data-type') !== t;
});
+ if (sourceDisableTrashEl) {
+ const forcePermanentDelete = (t === 'gdrive');
+ sourceDisableTrashEl.disabled = forcePermanentDelete;
+ if (forcePermanentDelete) {
+ sourceDisableTrashEl.checked = true;
+ sourceDisableTrashEl.title = tf(
+ 'source_gdrive_trash_note',
+ 'Trash is not supported on Google Drive sources; deletes are permanent.'
+ );
+ } else {
+ sourceDisableTrashEl.removeAttribute('title');
+ }
+ }
};
const resetSecrets = () => {
@@ -1857,6 +1835,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou
if (sourceNameEl) sourceNameEl.value = '';
if (sourceEnabledEl) sourceEnabledEl.checked = true;
if (sourceReadOnlyEl) sourceReadOnlyEl.checked = false;
+ if (sourceDisableTrashEl) sourceDisableTrashEl.checked = false;
if (localPathEl) localPathEl.value = '';
if (s3BucketEl) s3BucketEl.value = '';
if (s3RegionEl) s3RegionEl.value = '';
@@ -1935,6 +1914,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou
if (sourceNameEl) sourceNameEl.value = src.name || '';
if (sourceEnabledEl) sourceEnabledEl.checked = src.enabled !== false;
if (sourceReadOnlyEl) sourceReadOnlyEl.checked = !!src.readOnly;
+ if (sourceDisableTrashEl) sourceDisableTrashEl.checked = !!src.disableTrash;
const type = (src.type || 'local').toLowerCase();
setType(type);
const cfg = src.config || {};
@@ -2012,7 +1992,8 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou
const enabledText = (src.enabled === false) ? tf('disabled', 'Disabled') : tf('enabled', 'Enabled');
const flags = [
enabledText,
- (src.readOnly ? t('read_only') : '')
+ (src.readOnly ? t('read_only') : ''),
+ (src.disableTrash ? tf('source_trash_disabled_badge', 'Trash off') : '')
].filter(Boolean);
const badges = flags.map(flag => `
${esc(flag)} `).join('');
const badgeWrap = badges ? `
${badges} ` : '';
@@ -2058,7 +2039,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou
};
const loadSources = async () => {
- if (!isPro || !proSourcesApiOk || window.__FR_IS_PRO === false) {
+ if (sourcesCfg && sourcesCfg.available === false) {
return;
}
setStatus(tf('loading', 'Loading...'));
@@ -2235,6 +2216,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou
const type = (sourceTypeEl?.value || '').trim().toLowerCase();
const enabled = !!sourceEnabledEl?.checked;
const readOnly = !!sourceReadOnlyEl?.checked;
+ const disableTrash = !!sourceDisableTrashEl?.checked;
if (!id || !name || !type) {
showToast(t('admin_source_required_fields'), 'error');
@@ -2419,7 +2401,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou
}
const payload = {
- source: { id, name, type, enabled, readOnly, config }
+ source: { id, name, type, enabled, readOnly, disableTrash, config }
};
const existingIdx = (state.sources || []).findIndex(s => String(s.id || '') === id);
@@ -2427,7 +2409,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou
const optimisticEnabled = isNewSource
? false
: ((state.sources[existingIdx]?.enabled) !== false);
- const optimisticSource = { id, name, type, enabled: optimisticEnabled, readOnly };
+ const optimisticSource = { id, name, type, enabled: optimisticEnabled, readOnly, disableTrash };
if (isNewSource) {
state.sources = [...(state.sources || []), optimisticSource];
@@ -2470,7 +2452,7 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou
} else if (enabled) {
const testOk = await runSourceTest({ id });
if (testOk === false) {
- const disablePayload = { source: { id, name, type, enabled: false, readOnly, config } };
+ const disablePayload = { source: { id, name, type, enabled: false, readOnly, disableTrash, config } };
try {
const disableRes = await fetch(withBase('/api/pro/sources/save.php'), {
method: 'POST',
@@ -2520,6 +2502,824 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou
loadSources();
}
+function initGatewaysSection({ isPro }) {
+ const container = document.getElementById('gatewaysContent');
+ if (!container || container.__initialized) return;
+ container.__initialized = true;
+
+ if (!isPro) {
+ container.innerHTML = `
+
+
+
+
+ ${escapeHTML(tf('gateway_locked_body', 'Expose a scoped source root over SFTP, S3, or MCP with generated start commands and safety checks.'))}
+
+
+
+ `;
+ return;
+ }
+
+ container.innerHTML = `
+
+
+ ${escapeHTML(tf('gateway_add', 'Add Gateway'))}
+ ${escapeHTML(tf('refresh', 'Refresh'))}
+
+
+
+
${escapeHTML(tf('gateway_runtime_title', 'Gateway runtime checklist'))}
+
${escapeHTML(tf('gateway_runtime_line_1', '1) Install rclone on the gateway host/container.'))}
+
${escapeHTML(tf('gateway_runtime_line_2', '2) For Docker, recommended: run rclone as a sidecar from the docker-compose snippet.'))}
+
${escapeHTML(tf('gateway_runtime_line_3', '3) If you run rclone inside the FileRise container, publish the gateway port (example: -p 2022:2022).'))}
+
${escapeHTML(tf('gateway_runtime_line_4', '4) Use listen address 0.0.0.0 for LAN access, then connect to host IP:port.'))}
+
${escapeHTML(tf('gateway_runtime_line_5', '5) Test validates config only; it does not start or stop gateway services.'))}
+
+ ${escapeHTML(tf('gateway_runtime_docs', 'Open Gateway setup guide'))}
+
+
+
+
+
+
+
+
+
+
+
+
+
${escapeHTML(tf('gateway_snippets', 'Gateway snippets'))}
+
+
+ ${escapeHTML(tf('gateway_start_command', 'Start command'))}
+ ${escapeHTML(tf('gateway_docker_compose', 'docker-compose snippet'))}
+ ${escapeHTML(tf('gateway_systemd_unit', 'systemd unit snippet'))}
+
+ ${escapeHTML(tf('copy', 'Copy'))}
+
+
+
+
+
+
+
+
${escapeHTML(tf('gateway_test_output', 'Test output'))}
+
+
+
+ ${escapeHTML(tf('gateway_include_secrets', 'Include secrets on test'))}
+
+
${escapeHTML(tf('copy', 'Copy'))}
+
+
+
+
+
+ `;
+
+ const state = {
+ gateways: [],
+ editingId: '',
+ previewGatewayId: '',
+ sourceOptions: [],
+ lastTestResult: null,
+ };
+
+ const getCsrf = () =>
+ (document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken || '');
+
+ const statusEl = container.querySelector('#gwStatus');
+ const listEl = container.querySelector('#gwList');
+ const formTitleEl = container.querySelector('#gwFormTitle');
+ const commandEl = container.querySelector('#gwSnippetPreview');
+ const snippetTypeEl = container.querySelector('#gwSnippetType');
+ const copySnippetBtn = container.querySelector('#gwCopySnippetBtn');
+ const testOutEl = container.querySelector('#gwTestOutput');
+ const includeSecretsEl = container.querySelector('#gwIncludeSecrets');
+ const copyTestBtn = container.querySelector('#gwCopyTestBtn');
+ const nameEl = container.querySelector('#gwName');
+ const typeEl = container.querySelector('#gwType');
+ const sourceIdEl = container.querySelector('#gwSourceId');
+ const rootPathEl = container.querySelector('#gwRootPath');
+ const modeEl = container.querySelector('#gwMode');
+ const listenEl = container.querySelector('#gwListenAddr');
+ const portEl = container.querySelector('#gwPort');
+ const enabledEl = container.querySelector('#gwEnabled');
+ const sftpUserEl = container.querySelector('#gwSftpUser');
+ const sftpPassEl = container.querySelector('#gwSftpPass');
+ const sftpKeysEl = container.querySelector('#gwSftpAuthorizedKeys');
+ const s3AccessEl = container.querySelector('#gwS3AccessKey');
+ const s3SecretEl = container.querySelector('#gwS3SecretKey');
+ const mcpTokenEl = container.querySelector('#gwMcpToken');
+ const saveBtn = container.querySelector('#gwSaveBtn');
+ const resetBtn = container.querySelector('#gwResetBtn');
+ const refreshBtn = container.querySelector('#gwRefreshBtn');
+ const addBtn = container.querySelector('#gwAddBtn');
+
+ const setStatus = (msg, tone = 'muted') => {
+ if (!statusEl) return;
+ statusEl.classList.remove('text-muted', 'text-danger', 'text-success', 'text-warning');
+ if (tone === 'danger') statusEl.classList.add('text-danger');
+ else if (tone === 'success') statusEl.classList.add('text-success');
+ else if (tone === 'warning') statusEl.classList.add('text-warning');
+ else statusEl.classList.add('text-muted');
+ statusEl.textContent = msg || '';
+ };
+
+ const setCommand = (text) => {
+ if (!commandEl) return;
+ commandEl.textContent = String(text || '');
+ };
+
+ const setTestOutput = (text) => {
+ if (!testOutEl) return;
+ testOutEl.textContent = String(text || '');
+ };
+
+ const selectedSnippetKind = () => {
+ return String(snippetTypeEl?.value || 'command');
+ };
+
+ const snippetFromGateway = (gw, kind = selectedSnippetKind(), includeSecrets = false) => {
+ if (!gw || typeof gw !== 'object') return '';
+ const k = String(kind || 'command').toLowerCase();
+ const defaultSnippets = gw?.snippets && typeof gw.snippets === 'object' ? gw.snippets : {};
+ const secretsSnippets = gw?.snippetsWithSecrets && typeof gw.snippetsWithSecrets === 'object'
+ ? gw.snippetsWithSecrets
+ : {};
+ const pick = includeSecrets ? secretsSnippets : defaultSnippets;
+
+ if (k === 'docker') {
+ return String(
+ pick.dockerCompose
+ || (includeSecrets ? gw.dockerComposeWithSecrets : '')
+ || defaultSnippets.dockerCompose
+ || gw.dockerCompose
+ || ''
+ );
+ }
+ if (k === 'systemd') {
+ return String(
+ pick.systemd
+ || (includeSecrets ? gw.systemdWithSecrets : '')
+ || defaultSnippets.systemd
+ || gw.systemd
+ || ''
+ );
+ }
+ return String(
+ pick.startCommand
+ || (includeSecrets ? gw.startCommandWithSecrets : '')
+ || defaultSnippets.startCommand
+ || gw.startCommand
+ || ''
+ );
+ };
+
+ const copyText = async (value) => {
+ const text = String(value || '');
+ if (!text) return false;
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.setAttribute('readonly', 'readonly');
+ ta.style.position = 'absolute';
+ ta.style.left = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ try {
+ return document.execCommand('copy');
+ } finally {
+ document.body.removeChild(ta);
+ }
+ };
+
+ const activeGateway = () => {
+ const id = state.editingId || state.previewGatewayId;
+ if (!id) return null;
+ return (state.gateways || []).find((x) => String(x.id || '') === id) || null;
+ };
+
+ const refreshSnippetPreview = () => {
+ const includeSecrets = !!includeSecretsEl?.checked;
+ const payload = state.lastTestResult || activeGateway();
+ setCommand(snippetFromGateway(payload, selectedSnippetKind(), includeSecrets));
+ };
+
+ const ensureSourceOption = (id, label = '') => {
+ const sourceId = String(id || '').trim();
+ if (!sourceId) return;
+ const existing = (state.sourceOptions || []).find((s) => String(s.id || '') === sourceId);
+ if (existing) return;
+ const sourceLabel = String(label || sourceId);
+ state.sourceOptions = [...(state.sourceOptions || []), { id: sourceId, label: sourceLabel }];
+ };
+
+ const renderSourceOptions = (preferredId = '') => {
+ if (!sourceIdEl) return;
+ let options = Array.isArray(state.sourceOptions) ? state.sourceOptions.slice() : [];
+ if (!options.length) {
+ options = [{ id: 'local', label: 'local - Local' }];
+ }
+ if (!options.some((s) => String(s.id || '') === 'local')) {
+ options.unshift({ id: 'local', label: 'local - Local' });
+ }
+
+ sourceIdEl.innerHTML = '';
+ options.forEach((src) => {
+ const id = String(src?.id || '').trim();
+ if (!id) return;
+ const option = document.createElement('option');
+ option.value = id;
+ option.textContent = String(src?.label || id);
+ sourceIdEl.appendChild(option);
+ });
+
+ const chosen = String(preferredId || sourceIdEl.value || 'local').trim() || 'local';
+ const hasChosen = options.some((s) => String(s.id || '') === chosen);
+ sourceIdEl.value = hasChosen ? chosen : 'local';
+ };
+
+ const loadSourceOptions = async () => {
+ try {
+ const res = await fetch(withBase('/api/pro/sources/list.php'), {
+ method: 'GET',
+ credentials: 'include',
+ headers: { 'Accept': 'application/json' },
+ });
+ const data = await safeJson(res);
+ if (!data || data.ok !== true) {
+ throw new Error(data?.error || 'Failed to load sources.');
+ }
+
+ const sources = Array.isArray(data.sources) ? data.sources : [];
+ const options = [];
+ sources.forEach((src) => {
+ const id = String(src?.id || '').trim();
+ if (!id) return;
+ const name = String(src?.name || id).trim() || id;
+ const type = String(src?.type || '').trim();
+ const enabled = src?.enabled !== false;
+ const status = enabled ? '' : ` (${tf('disabled', 'Disabled')})`;
+ const typePart = type ? ` [${type}]` : '';
+ options.push({ id, label: `${id} - ${name}${typePart}${status}` });
+ });
+ if (!options.some((s) => s.id === 'local')) {
+ options.unshift({ id: 'local', label: 'local - Local' });
+ }
+ state.sourceOptions = options;
+ renderSourceOptions(sourceIdEl?.value || 'local');
+ } catch (err) {
+ state.sourceOptions = [{ id: 'local', label: 'local - Local' }];
+ renderSourceOptions(sourceIdEl?.value || 'local');
+ }
+ };
+
+ const setType = (type) => {
+ const t = String(type || 'sftp').toLowerCase();
+ container.querySelectorAll('[data-gw-type]').forEach((el) => {
+ el.hidden = el.getAttribute('data-gw-type') !== t;
+ });
+ };
+
+ const resetForm = () => {
+ state.editingId = '';
+ state.previewGatewayId = '';
+ state.lastTestResult = null;
+ if (formTitleEl) formTitleEl.textContent = tf('gateway_add', 'Add Gateway');
+ if (nameEl) nameEl.value = '';
+ if (typeEl) typeEl.value = 'sftp';
+ renderSourceOptions('local');
+ if (rootPathEl) rootPathEl.value = 'root';
+ if (modeEl) modeEl.value = 'ro';
+ if (listenEl) listenEl.value = '127.0.0.1';
+ if (portEl) portEl.value = '';
+ if (enabledEl) enabledEl.checked = true;
+ if (sftpUserEl) sftpUserEl.value = '';
+ if (sftpPassEl) sftpPassEl.value = '';
+ if (sftpKeysEl) sftpKeysEl.value = '';
+ if (s3AccessEl) s3AccessEl.value = '';
+ if (s3SecretEl) s3SecretEl.value = '';
+ if (mcpTokenEl) mcpTokenEl.value = '';
+ setType('sftp');
+ refreshSnippetPreview();
+ };
+
+ const fillForm = (gw) => {
+ if (!gw || typeof gw !== 'object') return;
+ state.editingId = String(gw.id || '');
+ state.previewGatewayId = state.editingId;
+ state.lastTestResult = null;
+ if (formTitleEl) formTitleEl.textContent = tf('gateway_edit', 'Edit Gateway');
+ if (nameEl) nameEl.value = String(gw.name || '');
+ if (typeEl) typeEl.value = String(gw.gatewayType || 'sftp').toLowerCase();
+ ensureSourceOption(gw.sourceId, `${String(gw.sourceId || 'local')} - ${String(gw.sourceId || 'local')}`);
+ renderSourceOptions(String(gw.sourceId || 'local'));
+ if (rootPathEl) rootPathEl.value = String(gw.rootPath || 'root');
+ if (modeEl) modeEl.value = String(gw.mode || 'ro').toLowerCase() === 'rw' ? 'rw' : 'ro';
+ if (listenEl) listenEl.value = String(gw.listenAddr || '127.0.0.1');
+ if (portEl) portEl.value = gw.port ? String(gw.port) : '';
+ if (enabledEl) enabledEl.checked = !!gw.enabled;
+
+ if (sftpUserEl) sftpUserEl.value = String(gw?.sftp?.user || '');
+ if (sftpPassEl) sftpPassEl.value = '';
+ if (sftpKeysEl) sftpKeysEl.value = '';
+ if (s3AccessEl) s3AccessEl.value = '';
+ if (s3SecretEl) s3SecretEl.value = '';
+ if (mcpTokenEl) mcpTokenEl.value = '';
+
+ setType(gw.gatewayType || 'sftp');
+ refreshSnippetPreview();
+ setTestOutput('');
+ };
+
+ const renderList = () => {
+ if (!listEl) return;
+ const rows = Array.isArray(state.gateways) ? state.gateways : [];
+ if (!rows.length) {
+ listEl.innerHTML = `
${escapeHTML(tf('gateway_list_empty', 'No gateway shares configured yet.'))}
`;
+ return;
+ }
+
+ const statusText = (row) => row.enabled ? tf('enabled', 'Enabled') : tf('disabled', 'Disabled');
+ const modeText = (row) => (String(row.mode || 'ro').toLowerCase() === 'rw' ? 'rw' : 'ro');
+ const bindText = (row) => `${String(row.listenAddr || '127.0.0.1')}:${String(row.port || '')}`;
+ const actions = (row) => `
+
+ ${escapeHTML(tf('edit', 'Edit'))}
+ ${escapeHTML(tf('source_test', 'Test'))}
+ ${escapeHTML(tf('gateway_command', 'Command'))}
+ ${escapeHTML(tf('delete', 'Delete'))}
+
+ `;
+
+ listEl.innerHTML = `
+
+
+
+
+ ${escapeHTML(tf('gateway_name', 'Name'))}
+ ${escapeHTML(tf('gateway_type', 'Gateway Type'))}
+ ${escapeHTML(tf('gateway_source_id', 'Source ID'))}
+ ${escapeHTML(tf('gateway_root_path', 'Root Path'))}
+ ${escapeHTML(tf('gateway_mode', 'Mode'))}
+ ${escapeHTML(tf('gateway_bind', 'Bind'))}
+ ${escapeHTML(tf('status', 'Status'))}
+ ${escapeHTML(tf('actions', 'Actions'))}
+
+
+
+ ${rows.map((row) => `
+
+ ${escapeHTML(String(row.name || row.id || ''))}
+ ${escapeHTML(String(row.gatewayType || ''))}
+ ${escapeHTML(String(row.sourceId || ''))}
+ ${escapeHTML(String(row.rootPath || 'root'))}
+ ${escapeHTML(modeText(row))}
+ ${escapeHTML(bindText(row))}
+ ${escapeHTML(statusText(row))}
+ ${actions(row)}
+
+ `).join('')}
+
+
+
+ `;
+ };
+
+ const buildPayload = () => {
+ const name = String(nameEl?.value || '').trim();
+ const gatewayType = String(typeEl?.value || 'sftp').trim().toLowerCase();
+ const sourceId = String(sourceIdEl?.value || 'local').trim() || 'local';
+ const rootPath = String(rootPathEl?.value || 'root').trim() || 'root';
+ const mode = String(modeEl?.value || 'ro').trim().toLowerCase() === 'rw' ? 'rw' : 'ro';
+ const listenAddr = String(listenEl?.value || '127.0.0.1').trim() || '127.0.0.1';
+ const enabled = !!enabledEl?.checked;
+
+ if (!name) {
+ throw new Error(tf('gateway_name_required', 'Gateway name is required.'));
+ }
+ if (!gatewayType || !['sftp', 's3', 'mcp'].includes(gatewayType)) {
+ throw new Error(tf('gateway_type_required', 'Gateway type is required.'));
+ }
+
+ const payload = {
+ name,
+ gatewayType,
+ sourceId,
+ rootPath,
+ mode,
+ listenAddr,
+ enabled,
+ };
+
+ const portRaw = String(portEl?.value || '').trim();
+ if (portRaw !== '') {
+ const p = parseInt(portRaw, 10);
+ if (!Number.isFinite(p) || p < 1024 || p > 65535) {
+ throw new Error(tf('gateway_port_invalid', 'Port must be between 1024 and 65535.'));
+ }
+ payload.port = p;
+ }
+
+ if (state.editingId) {
+ payload.id = state.editingId;
+ }
+
+ if (gatewayType === 'sftp') {
+ const user = String(sftpUserEl?.value || '').trim();
+ const pass = String(sftpPassEl?.value || '').trim();
+ const authorizedKeys = String(sftpKeysEl?.value || '').trim();
+ if (!user) {
+ throw new Error(tf('gateway_sftp_user_required', 'SFTP user is required.'));
+ }
+ if (!state.editingId && !pass && !authorizedKeys) {
+ throw new Error(tf('gateway_sftp_auth_required', 'Provide SFTP password or authorized keys.'));
+ }
+ payload.sftp = { user };
+ if (pass) payload.sftp.pass = pass;
+ if (authorizedKeys) payload.sftp.authorizedKeys = authorizedKeys;
+ } else if (gatewayType === 's3') {
+ const accessKey = String(s3AccessEl?.value || '').trim();
+ const secretKey = String(s3SecretEl?.value || '').trim();
+ if (!state.editingId && (!accessKey || !secretKey)) {
+ throw new Error(tf('gateway_s3_keys_required', 'S3 access key and secret key are required.'));
+ }
+ if (accessKey || secretKey) {
+ if (!accessKey || !secretKey) {
+ throw new Error(tf('gateway_s3_keys_pair_required', 'Provide both S3 access key and secret key.'));
+ }
+ payload.s3 = { keys: [{ accessKey, secretKey }] };
+ }
+ } else if (gatewayType === 'mcp') {
+ const token = String(mcpTokenEl?.value || '').trim();
+ if (token) {
+ payload.mcp = { token };
+ }
+ }
+
+ return payload;
+ };
+
+ const loadGateways = async () => {
+ try {
+ setStatus(tf('loading', 'Loading…'));
+ const res = await fetch(withBase('/api/pro/gateways/list.php'), {
+ method: 'GET',
+ credentials: 'include',
+ headers: { 'Accept': 'application/json' },
+ });
+ const data = await safeJson(res);
+ if (!res.ok || !data || data.ok !== true) {
+ throw new Error(data?.error || tf('gateway_list_failed', 'Failed to load gateway shares.'));
+ }
+ state.gateways = Array.isArray(data.gateways) ? data.gateways : [];
+ renderList();
+ if (state.editingId || state.previewGatewayId) {
+ const current = activeGateway();
+ if (!current) {
+ state.previewGatewayId = '';
+ state.lastTestResult = null;
+ }
+ refreshSnippetPreview();
+ }
+ const countMsg = tf('gateway_count', '{count} gateway share(s).').replace('{count}', String(state.gateways.length));
+ setStatus(countMsg, 'muted');
+ } catch (err) {
+ console.warn('Gateway list failed', err);
+ state.gateways = [];
+ renderList();
+ setStatus(err?.message || tf('gateway_list_failed', 'Failed to load gateway shares.'), 'danger');
+ }
+ };
+
+ const runGatewayTest = async (id) => {
+ if (!id) return;
+ try {
+ setTestOutput(tf('source_test_running', 'Testing...'));
+ const includeSecrets = !!includeSecretsEl?.checked;
+ const res = await fetch(withBase('/api/pro/gateways/test.php'), {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': getCsrf(),
+ 'Accept': 'application/json',
+ },
+ body: JSON.stringify({ id, includeSecrets }),
+ });
+ const data = await safeJson(res);
+ if (!res.ok || !data) {
+ throw new Error(data?.error || tf('gateway_test_failed', 'Gateway test failed.'));
+ }
+
+ const lines = [];
+ if (data.ok === true) {
+ lines.push(tf('gateway_test_ok', 'Gateway test passed.'));
+ } else {
+ lines.push(tf('gateway_test_failed', 'Gateway test failed.'));
+ }
+ const errors = Array.isArray(data.errors) ? data.errors : [];
+ const warnings = Array.isArray(data.warnings) ? data.warnings : [];
+ if (errors.length) {
+ lines.push('');
+ lines.push('Errors:');
+ errors.forEach((e) => lines.push(`- ${String(e)}`));
+ }
+ if (warnings.length) {
+ lines.push('');
+ lines.push('Warnings:');
+ warnings.forEach((w) => lines.push(`- ${String(w)}`));
+ }
+ if (includeSecrets) {
+ lines.push('');
+ lines.push(tf('gateway_secrets_warning', 'Warning: snippets may include secrets. Handle output carefully.'));
+ }
+ setTestOutput(lines.join('\n'));
+ state.previewGatewayId = String(id);
+ state.lastTestResult = data;
+ refreshSnippetPreview();
+ if (data.ok === true) {
+ showToast(tf('gateway_test_ok', 'Gateway test passed.'));
+ } else {
+ showToast((errors[0] || tf('gateway_test_failed', 'Gateway test failed.')), 'error');
+ }
+ } catch (err) {
+ const msg = err?.message || tf('gateway_test_failed', 'Gateway test failed.');
+ setTestOutput(msg);
+ showToast(msg, 'error');
+ }
+ };
+
+ const saveGateway = async () => {
+ try {
+ const gateway = buildPayload();
+ setStatus(tf('gateway_saving', 'Saving gateway...'));
+ const res = await fetch(withBase('/api/pro/gateways/save.php'), {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': getCsrf(),
+ 'Accept': 'application/json',
+ },
+ body: JSON.stringify({ gateway }),
+ });
+ const data = await safeJson(res);
+ if (!res.ok || !data || data.ok !== true) {
+ throw new Error(data?.error || tf('gateway_save_failed', 'Failed to save gateway share.'));
+ }
+ state.lastTestResult = null;
+ state.previewGatewayId = String(data?.gateway?.id || state.previewGatewayId || '');
+ setCommand(snippetFromGateway(data, selectedSnippetKind(), false));
+ setTestOutput('');
+ showToast(tf('gateway_saved', 'Gateway share saved.'));
+ setStatus(tf('gateway_saved', 'Gateway share saved.'), 'success');
+ await loadGateways();
+ resetForm();
+ } catch (err) {
+ const msg = err?.message || tf('gateway_save_failed', 'Failed to save gateway share.');
+ setStatus(msg, 'danger');
+ showToast(msg, 'error');
+ }
+ };
+
+ const deleteGateway = async (gw) => {
+ if (!gw || !gw.id) return;
+ const label = String(gw.name || gw.id);
+ const ok = window.confirm(
+ tf('gateway_delete_confirm', 'Delete gateway "{name}"?').replace('{name}', label)
+ );
+ if (!ok) return;
+
+ try {
+ const res = await fetch(withBase('/api/pro/gateways/delete.php'), {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': getCsrf(),
+ 'Accept': 'application/json',
+ },
+ body: JSON.stringify({ id: gw.id }),
+ });
+ const data = await safeJson(res);
+ if (!res.ok || !data || data.ok !== true) {
+ throw new Error(data?.error || tf('gateway_delete_failed', 'Failed to delete gateway share.'));
+ }
+ if (state.editingId && state.editingId === String(gw.id)) {
+ resetForm();
+ } else if (state.previewGatewayId && state.previewGatewayId === String(gw.id)) {
+ state.previewGatewayId = '';
+ state.lastTestResult = null;
+ }
+ refreshSnippetPreview();
+ setTestOutput('');
+ showToast(tf('gateway_deleted', 'Gateway share deleted.'));
+ await loadGateways();
+ } catch (err) {
+ showToast(err?.message || tf('gateway_delete_failed', 'Failed to delete gateway share.'), 'error');
+ }
+ };
+
+ if (typeEl) {
+ typeEl.addEventListener('change', () => setType(typeEl.value));
+ }
+ if (snippetTypeEl) {
+ snippetTypeEl.addEventListener('change', refreshSnippetPreview);
+ }
+ if (includeSecretsEl) {
+ includeSecretsEl.addEventListener('change', refreshSnippetPreview);
+ }
+ if (copySnippetBtn) {
+ copySnippetBtn.addEventListener('click', async () => {
+ try {
+ const ok = await copyText(commandEl?.textContent || '');
+ if (!ok) {
+ throw new Error(tf('copy_failed', 'Copy failed.'));
+ }
+ showToast(tf('copied_to_clipboard', 'Copied to clipboard.'));
+ } catch (err) {
+ showToast(err?.message || tf('copy_failed', 'Copy failed.'), 'error');
+ }
+ });
+ }
+ if (copyTestBtn) {
+ copyTestBtn.addEventListener('click', async () => {
+ try {
+ const ok = await copyText(testOutEl?.textContent || '');
+ if (!ok) {
+ throw new Error(tf('copy_failed', 'Copy failed.'));
+ }
+ showToast(tf('copied_to_clipboard', 'Copied to clipboard.'));
+ } catch (err) {
+ showToast(err?.message || tf('copy_failed', 'Copy failed.'), 'error');
+ }
+ });
+ }
+ if (refreshBtn) {
+ refreshBtn.addEventListener('click', () => {
+ Promise.allSettled([
+ loadSourceOptions(),
+ loadGateways(),
+ ]);
+ });
+ }
+ if (addBtn) {
+ addBtn.addEventListener('click', () => {
+ resetForm();
+ });
+ }
+ if (resetBtn) {
+ resetBtn.addEventListener('click', () => {
+ resetForm();
+ });
+ }
+ if (saveBtn) {
+ saveBtn.addEventListener('click', () => {
+ saveGateway();
+ });
+ }
+ if (listEl) {
+ listEl.addEventListener('click', (e) => {
+ const btn = e.target.closest('button[data-action]');
+ if (!btn) return;
+ const id = String(btn.getAttribute('data-id') || '');
+ const action = String(btn.getAttribute('data-action') || '');
+ const gw = (state.gateways || []).find((x) => String(x.id || '') === id);
+ if (!gw) return;
+
+ if (action === 'edit') {
+ fillForm(gw);
+ return;
+ }
+ if (action === 'command') {
+ state.previewGatewayId = String(gw.id || '');
+ state.lastTestResult = null;
+ refreshSnippetPreview();
+ setTestOutput('');
+ return;
+ }
+ if (action === 'test') {
+ runGatewayTest(id);
+ return;
+ }
+ if (action === 'delete') {
+ deleteGateway(gw);
+ }
+ });
+ }
+
+ resetForm();
+ setCommand('');
+ setTestOutput('');
+ Promise.allSettled([
+ loadSourceOptions(),
+ loadGateways(),
+ ]);
+}
+
function onShareFolderToggle(row, checked) {
const manage = qs(row, 'input[data-cap="manage"]');
const viewAll = qs(row, 'input[data-cap="view"]');
@@ -4249,7 +5049,13 @@ export function openAdminPanel() {
? config.storageSources
: {};
const sourcesEnabled = !!sourcesCfg.enabled;
- const showSourcesSection = true;
+ const sourcesAvailable = (sourcesCfg.available !== false);
+ const sourcesProExtended = !!sourcesCfg.proExtended || proSourcesApiOk;
+ const sourcesAllowedTypes = Array.isArray(sourcesCfg.allowedTypes) && sourcesCfg.allowedTypes.length
+ ? sourcesCfg.allowedTypes.map(v => String(v || '').trim().toLowerCase()).filter(Boolean)
+ : (sourcesProExtended ? ALL_SOURCE_TYPES.slice() : CORE_SOURCE_TYPES.slice());
+ const showSourcesSection = !!sourcesAvailable;
+ const showGatewaysSection = true;
const brandingCfg = config.branding || {};
const brandingCustomLogoUrl = brandingCfg.customLogoUrl || "";
const brandingHeaderBgLight = brandingCfg.headerBgLight || "";
@@ -4344,11 +5150,15 @@ export function openAdminPanel() {
{ id: "storage", label: tf("storage_usage", "Storage / Disk Usage") }
];
if (showSourcesSection) {
- const sourcesLabel = !isPro
- ? `
${tf("sources", "Sources")}Pro `
- : tf("sources", "Sources");
+ const sourcesLabel = tf("sources", "Sources");
sections.push({ id: "sources", label: sourcesLabel });
}
+ if (showGatewaysSection) {
+ const gatewaysLabel = !isPro
+ ? `
${tf("gateway_shares", "Gateway Shares")}Pro `
+ : tf("gateway_shares", "Gateway Shares");
+ sections.push({ id: "gateways", label: gatewaysLabel });
+ }
sections.push(
{ id: "proFeatures", label: "Pro Features" },
{ id: "pro", label: "FileRise Pro" },
@@ -4485,7 +5295,14 @@ export function openAdminPanel() {
sourcesEnabled,
sourcesCfg,
isPro,
- proSourcesApiOk
+ proSourcesApiOk,
+ sourcesAllowedTypes,
+ sourcesProExtended
+ });
+ }
+ if (showGatewaysSection) {
+ initGatewaysSection({
+ isPro
});
}
@@ -6141,11 +6958,32 @@ ${t("shared_max_upload_size_bytes")}
return;
}
- showToast(t('admin_license_saved'));
+ const autoBind = (data && typeof data.autoBind === 'object' && data.autoBind)
+ ? data.autoBind
+ : {};
+ const autoBindAttempted = autoBind.attempted === true;
+ const autoBindBound = autoBind.bound === true;
+ const autoBindMessage = String(autoBind.message || '').trim();
+
+ if (autoBindAttempted) {
+ if (autoBindBound) {
+ const msg = autoBindMessage || t('admin_license_saved');
+ showToast(msg);
+ setStatus(msg, 'success');
+ } else {
+ const msg = autoBindMessage || 'License saved, but instance auto-bind was not completed.';
+ showToast(msg, 'error');
+ setStatus(msg, 'warning');
+ }
+ } else {
+ showToast(t('admin_license_saved'));
+ }
if (!isPro) {
const ok = await showCustomConfirmModal(
- 'Download and install the latest Pro bundle now?'
+ (autoBindAttempted && !autoBindBound)
+ ? 'License saved, but instance binding was not completed automatically. Download and install the latest Pro bundle now anyway?'
+ : 'Download and install the latest Pro bundle now?'
);
if (ok) {
setStatus('Downloading and installing latest Pro bundle...', 'muted');
@@ -6201,6 +7039,10 @@ ${t("shared_max_upload_size_bytes")}
}
}
+ if (autoBindAttempted && !autoBindBound && isPro) {
+ return;
+ }
+
window.location.reload();
} catch (e) {
console.error(e);
diff --git a/public/js/fileListView.js b/public/js/fileListView.js
index 2cc1690..bd9764e 100644
--- a/public/js/fileListView.js
+++ b/public/js/fileListView.js
@@ -2301,6 +2301,7 @@ try {
// Activate pane on click
document.addEventListener('click', (e) => {
+ if (!window.dualPaneEnabled) return;
const pane = e.target && e.target.closest
? e.target.closest('.file-list-pane')
: null;
@@ -6892,6 +6893,13 @@ const subfoldersSorted = await sortSubfoldersForCurrentOrder(allSubfolders);
currentPage = totalPages;
window.currentPage = currentPage;
}
+ // Keep pane-local pagination state in sync so click-capture pane activation
+ // does not restore a stale page before pagination handlers run.
+ const tablePane = getPaneKeyForElement(fileListContent);
+ savePaneState(tablePane, {
+ currentPage,
+ currentSearchTerm: window.currentSearchTerm || ''
+ });
const startRow = (currentPage - 1) * itemsPerPageSetting;
const endRow = Math.min(startRow + itemsPerPageSetting, totalRows);
@@ -7356,6 +7364,12 @@ export function renderGalleryView(folder, container) {
currentPage = totalPages || 1;
window.currentPage = currentPage;
}
+ // Keep pane-local pagination state in sync for gallery mode as well.
+ const galleryPane = getPaneKeyForElement(fileListContent);
+ savePaneState(galleryPane, {
+ currentPage,
+ currentSearchTerm: window.currentSearchTerm || ''
+ });
// --- Top controls: search + pagination + items-per-page ---
let galleryHTML = buildSearchAndPaginationControls({
diff --git a/public/js/folderManager.js b/public/js/folderManager.js
index 5c20540..19d74e3 100644
--- a/public/js/folderManager.js
+++ b/public/js/folderManager.js
@@ -183,6 +183,28 @@ function getSourceTypeById(sourceId) {
return '';
}
+function isTrashDisabledForSource(sourceId = '') {
+ const id = String(sourceId || getActiveSourceId() || '').trim();
+ if (!id) return false;
+
+ try {
+ if (typeof window.__frGetSourceMetaById === 'function') {
+ const meta = window.__frGetSourceMetaById(id);
+ if (meta && typeof meta === 'object' && Object.prototype.hasOwnProperty.call(meta, 'disableTrash')) {
+ return !!meta.disableTrash;
+ }
+ }
+ } catch (e) { /* ignore */ }
+
+ const sel = document.getElementById('sourceSelector');
+ if (sel) {
+ const opt = Array.from(sel.options).find(o => o.value === id);
+ if (opt) return opt.dataset?.sourceDisableTrash === '1';
+ }
+
+ return false;
+}
+
function isFtpSourceId(sourceId = '') {
const type = String(getSourceTypeById(sourceId || getActiveSourceId()) || '').toLowerCase();
return type === 'ftp';
@@ -1837,6 +1859,8 @@ function placeRecycleBinNode() {
const isAdmin = localStorage.getItem('isAdmin') === '1' || localStorage.getItem('isAdmin') === 'true';
if (!isAdmin) return;
+ if (isTrashDisabledForSource()) return;
+
renderRecycleBinNode(window.recycleBinHasItems || false);
}
diff --git a/public/js/i18n.js b/public/js/i18n.js
index 5eab535..8a90418 100644
--- a/public/js/i18n.js
+++ b/public/js/i18n.js
@@ -253,6 +253,9 @@ const translations = {
"source_name": "Source Name",
"source_type": "Type",
"source_enabled": "Enabled",
+ "source_disable_trash": "Delete permanently",
+ "source_disable_trash_help": "Delete permanently toggle: ON skips Trash and deletes immediately. OFF moves files to Trash (FileRise creates the trash folder on demand).",
+ "source_trash_disabled_badge": "Trash off",
"source_local_path": "Local path",
"source_s3_bucket": "S3 bucket",
"source_s3_region": "S3 region (optional)",
@@ -303,13 +306,69 @@ const translations = {
"source_test_error": "Test failed",
"source_secret_note": "Secrets are never shown after saving. Leave blank to keep existing values.",
"source_hint_button": "Show setup hints",
- "source_hint_local": "Use an absolute server path. Leave blank to use the default uploads root.",
+ "source_hint_local": "Use an absolute server path. Leave blank to use the default uploads root. Ensure the web user can read/write this path. If trash cannot be created, enable Delete permanently for this source.",
"source_hint_s3": "Bucket is required.\nRegion is optional (defaults to us-east-1).\nEndpoint and path-style are for S3-compatible providers.\nPrefix is optional.",
"source_hint_sftp": "Host and username are required.\nUse a password or private key.\nRoot is optional; blank uses the login directory.",
"source_hint_ftp": "Host and username are required.\nPassive mode is recommended.\nRoot is optional; blank uses the login directory.",
"source_hint_gdrive": "Create an OAuth client in Google Cloud.\nGet a refresh token with scope https://www.googleapis.com/auth/drive.\nRootId: drive.google.com/drive/folders/ID or blank for root.\nDriveId: set for shared drives.\nNative Docs/Sheets/Slides export as DOCX/XLSX/PPTX on download.",
"source_hint_webdav": "Base URL and username are required.\nRoot is optional; blank uses the server root.\nDisable TLS verification only for self-signed certs.",
"source_hint_smb": "Host, share, and username are required.\nDomain and root are optional.\nLeave SMB version on Auto unless your server requires a specific version.",
+
+ // Gateway Shares (Pro)
+ "gateway_shares": "Gateway Shares",
+ "gateway_locked_body": "Expose a scoped source root over SFTP, S3, or MCP with generated start commands and safety checks.",
+ "gateway_add": "Add Gateway",
+ "gateway_edit": "Edit Gateway",
+ "gateway_save": "Save Gateway",
+ "gateway_name": "Name",
+ "gateway_type": "Gateway Type",
+ "gateway_source_id": "Source ID",
+ "gateway_root_path": "Root Path",
+ "gateway_mode": "Mode",
+ "gateway_listen_addr": "Listen Address",
+ "gateway_port": "Port",
+ "gateway_bind": "Bind",
+ "gateway_snippets": "Gateway snippets",
+ "gateway_start_command": "Start command",
+ "gateway_docker_compose": "docker-compose snippet",
+ "gateway_systemd_unit": "systemd unit snippet",
+ "gateway_include_secrets": "Include secrets on test",
+ "gateway_secrets_warning": "Warning: snippets may include secrets. Handle output carefully.",
+ "gateway_test_output": "Test output",
+ "gateway_runtime_title": "Gateway runtime checklist",
+ "gateway_runtime_line_1": "1) Install rclone on the gateway host/container.",
+ "gateway_runtime_line_2": "2) For Docker, recommended: run rclone as a sidecar from the docker-compose snippet.",
+ "gateway_runtime_line_3": "3) If you run rclone inside the FileRise container, publish the gateway port (example: -p 2022:2022).",
+ "gateway_runtime_line_4": "4) Use listen address 0.0.0.0 for LAN access, then connect to host IP:port.",
+ "gateway_runtime_line_5": "5) Test validates config only; it does not start or stop gateway services.",
+ "gateway_runtime_docs": "Open Gateway setup guide",
+ "gateway_command": "Command",
+ "gateway_list_empty": "No gateway shares configured yet.",
+ "gateway_name_required": "Gateway name is required.",
+ "gateway_type_required": "Gateway type is required.",
+ "gateway_port_invalid": "Port must be between 1024 and 65535.",
+ "gateway_sftp_user": "SFTP user",
+ "gateway_sftp_pass": "SFTP password",
+ "gateway_sftp_authorized_keys": "Authorized keys",
+ "gateway_sftp_user_required": "SFTP user is required.",
+ "gateway_sftp_auth_required": "Provide SFTP password or authorized keys.",
+ "gateway_s3_access_key": "S3 access key",
+ "gateway_s3_secret_key": "S3 secret key",
+ "gateway_s3_keys_required": "S3 access key and secret key are required.",
+ "gateway_s3_keys_pair_required": "Provide both S3 access key and secret key.",
+ "gateway_mcp_token": "MCP token (optional)",
+ "gateway_mcp_token_hint": "Leave blank to keep existing token or auto-generate on create.",
+ "gateway_secret_note": "Secrets are never shown after saving. Leave secret fields blank to keep existing values.",
+ "gateway_saving": "Saving gateway...",
+ "gateway_saved": "Gateway share saved.",
+ "gateway_deleted": "Gateway share deleted.",
+ "gateway_delete_confirm": "Delete gateway \"{name}\"?",
+ "gateway_save_failed": "Failed to save gateway share.",
+ "gateway_delete_failed": "Failed to delete gateway share.",
+ "gateway_list_failed": "Failed to load gateway shares.",
+ "gateway_test_ok": "Gateway config validation passed (service not started).",
+ "gateway_test_failed": "Gateway test failed.",
+ "gateway_count": "{count} gateway share(s).",
"enabled": "Enabled",
"disabled": "Disabled",
diff --git a/public/js/pretheme.js b/public/js/pretheme.js
new file mode 100644
index 0000000..be19f85
--- /dev/null
+++ b/public/js/pretheme.js
@@ -0,0 +1,53 @@
+// Apply theme colors before main CSS/JS to reduce flash on first paint.
+(function () {
+ try {
+ var stored = localStorage.getItem('darkMode');
+ var isDark = (stored === null)
+ ? !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
+ : (stored === '1' || stored === 'true');
+ var theme = isDark ? 'dark' : 'light';
+ var bg = isDark ? '#121212' : '#ffffff';
+ var root = document.documentElement;
+
+ function applyTheme(el) {
+ if (!el) return;
+ el.classList.toggle('dark-mode', isDark);
+ el.setAttribute('data-theme', theme);
+ el.style.colorScheme = theme;
+ }
+
+ applyTheme(root);
+ root.style.backgroundColor = bg;
+ root.style.setProperty('--pre-bg', bg);
+
+ function applyBodyTheme() {
+ var body = document.body;
+ if (!body) return false;
+ applyTheme(body);
+ return true;
+ }
+
+ if (!applyBodyTheme()) {
+ var applied = false;
+ var onBodyReady = function () {
+ if (applied) return;
+ applied = applyBodyTheme();
+ };
+
+ if (typeof MutationObserver === 'function') {
+ var observer = new MutationObserver(function () {
+ onBodyReady();
+ if (applied) observer.disconnect();
+ });
+ observer.observe(root, { childList: true });
+ }
+
+ document.addEventListener('DOMContentLoaded', onBodyReady, { once: true });
+ }
+
+ var metaTheme = document.querySelector('meta[name="theme-color"]');
+ if (metaTheme) metaTheme.setAttribute('content', bg);
+ } catch (e) {
+ // Ignore early bootstrap errors.
+ }
+})();
diff --git a/public/js/sourceManager.js b/public/js/sourceManager.js
index d73037d..4983e30 100644
--- a/public/js/sourceManager.js
+++ b/public/js/sourceManager.js
@@ -110,9 +110,40 @@ function getSourceTypeById(id) {
}
function getSourceMetaById(id) {
+ const key = String(id || '').trim();
+ if (!key) return { name: '', type: '', readOnly: false, disableTrash: false };
+
+ try {
+ const meta = window.__FR_SOURCE_META_MAP;
+ if (meta && Object.prototype.hasOwnProperty.call(meta, key)) {
+ const row = meta[key] || {};
+ return {
+ name: String(row.name || ''),
+ type: String(row.type || ''),
+ readOnly: !!row.readOnly,
+ disableTrash: !!row.disableTrash
+ };
+ }
+ } catch (e) { /* ignore */ }
+
+ const select = document.getElementById('sourceSelector');
+ if (select) {
+ const opt = Array.from(select.options).find(o => o.value === key);
+ if (opt) {
+ return {
+ name: String(opt.dataset?.sourceName || ''),
+ type: String(opt.dataset?.sourceType || ''),
+ readOnly: opt.dataset?.sourceReadOnly === '1',
+ disableTrash: opt.dataset?.sourceDisableTrash === '1'
+ };
+ }
+ }
+
return {
- name: getSourceNameById(id),
- type: getSourceTypeById(id)
+ name: getSourceNameById(key),
+ type: getSourceTypeById(key),
+ readOnly: false,
+ disableTrash: false
};
}
@@ -260,8 +291,9 @@ export async function initSourceSelector(opts = {}) {
opt.dataset.sourceName = name;
opt.dataset.sourceType = type;
opt.dataset.sourceReadOnly = src.readOnly ? '1' : '0';
+ opt.dataset.sourceDisableTrash = src.disableTrash ? '1' : '0';
nameMap[id] = name;
- metaMap[id] = { name, type, readOnly: !!src.readOnly };
+ metaMap[id] = { name, type, readOnly: !!src.readOnly, disableTrash: !!src.disableTrash };
select.appendChild(opt);
});
diff --git a/src/FileRise/Domain/AdminModel.php b/src/FileRise/Domain/AdminModel.php
index f2eb0d1..07d7a80 100644
--- a/src/FileRise/Domain/AdminModel.php
+++ b/src/FileRise/Domain/AdminModel.php
@@ -3,12 +3,13 @@
namespace FileRise\Domain;
use FileRise\Http\Controllers\AdminController;
+use FileRise\Storage\SourcesConfig;
use ProAudit;
-use ProSources;
// src/models/AdminModel.php
require_once PROJECT_ROOT . '/config/config.php';
+require_once PROJECT_ROOT . '/src/lib/SourcesConfig.php';
class AdminModel
{
@@ -338,14 +339,7 @@ class AdminModel
'lockedByEnv' => $proSearchLockedByEnv,
];
- if ($isProActive && class_exists('ProSources') && fr_pro_api_level_at_least(FR_PRO_API_REQUIRE_SOURCES)) {
- $public['storageSources'] = ProSources::getPublicConfig();
- } else {
- $public['storageSources'] = [
- 'enabled' => false,
- 'sources' => [],
- ];
- }
+ $public['storageSources'] = SourcesConfig::getPublicConfig();
$proAuditCfg = (isset($config['proAudit']) && is_array($config['proAudit']))
? $config['proAudit']
diff --git a/src/FileRise/Domain/FileModel.php b/src/FileRise/Domain/FileModel.php
index a2497c1..57df8d0 100644
--- a/src/FileRise/Domain/FileModel.php
+++ b/src/FileRise/Domain/FileModel.php
@@ -823,16 +823,7 @@ class FileModel
$errors = [];
$storage = self::storage();
$isLocal = $storage->isLocal();
- $skipTrash = false;
- if (!$isLocal && class_exists('SourceContext')) {
- $src = SourceContext::getActiveSource();
- if (is_array($src)) {
- $type = strtolower((string)($src['type'] ?? ''));
- if ($type === 'gdrive') {
- $skipTrash = true;
- }
- }
- }
+ $skipTrash = class_exists('SourceContext') ? SourceContext::isTrashDisabled() : false;
list($uploadDir, $err) = self::resolveFolderPath($folder, false);
if ($err) {
@@ -847,7 +838,13 @@ class FileModel
if (!$skipTrash) {
$trashDir = rtrim(self::trashRoot(), '/\\') . DIRECTORY_SEPARATOR;
if ($storage->stat($trashDir) === null) {
- $storage->mkdir($trashDir, 0755, true);
+ if (!$storage->mkdir($trashDir, 0755, true)) {
+ $detail = self::adapterErrorDetail($storage);
+ $msg = $detail !== ''
+ ? ("Failed to create Trash folder: " . $detail)
+ : "Failed to create Trash folder. Check source permissions or enable delete permanently (skip trash) for this source.";
+ return ["error" => $msg];
+ }
}
$trashMetadataFile = $trashDir . "trash.json";
$trashJson = $storage->read($trashMetadataFile);
diff --git a/src/FileRise/Http/Controllers/AdminController.php b/src/FileRise/Http/Controllers/AdminController.php
index d77282f..88b63db 100644
--- a/src/FileRise/Http/Controllers/AdminController.php
+++ b/src/FileRise/Http/Controllers/AdminController.php
@@ -992,6 +992,190 @@ class AdminController
}
}
+ private static function decodeFrpPayload(string $license): ?array
+ {
+ $license = trim($license);
+ if ($license === '' || stripos($license, 'FRP1.') !== 0) {
+ return null;
+ }
+
+ $parts = explode('.', $license, 3);
+ if (count($parts) !== 3) {
+ return null;
+ }
+
+ $payloadPart = (string)$parts[1];
+ if ($payloadPart === '') {
+ return null;
+ }
+
+ $base64 = strtr($payloadPart, '-_', '+/');
+ $pad = strlen($base64) % 4;
+ if ($pad > 0) {
+ $base64 .= str_repeat('=', 4 - $pad);
+ }
+
+ $json = base64_decode($base64, true);
+ if (!is_string($json) || $json === '') {
+ return null;
+ }
+
+ $payload = json_decode($json, true);
+ return is_array($payload) ? $payload : null;
+ }
+
+ private static function normalizeFrpInstances(array $payload): array
+ {
+ $out = [];
+ if (isset($payload['instances']) && is_array($payload['instances'])) {
+ foreach ($payload['instances'] as $entry) {
+ $id = strtolower(trim((string)$entry));
+ if ($id !== '' && preg_match('/^[a-f0-9]{32}$/', $id)) {
+ $out[] = $id;
+ }
+ }
+ }
+ if (!$out) {
+ return [];
+ }
+ return array_values(array_unique($out));
+ }
+
+ private static function tryAutoBindInstance(string $license, string $instanceId): array
+ {
+ $result = [
+ 'attempted' => false,
+ 'bound' => false,
+ 'changed' => false,
+ 'message' => '',
+ 'license' => $license,
+ ];
+
+ $license = trim($license);
+ $instanceId = strtolower(trim($instanceId));
+ if ($license === '' || $instanceId === '' || !preg_match('/^[a-f0-9]{32}$/', $instanceId)) {
+ return $result;
+ }
+
+ $payload = self::decodeFrpPayload($license);
+ if (!is_array($payload)) {
+ return $result;
+ }
+
+ $plan = strtolower(trim((string)($payload['plan'] ?? '')));
+ if (!in_array($plan, ['personal_yearly', 'business_yearly'], true)) {
+ return $result;
+ }
+
+ $result['attempted'] = true;
+ $instances = self::normalizeFrpInstances($payload);
+ if (in_array($instanceId, $instances, true)) {
+ $result['bound'] = true;
+ $result['message'] = 'Instance ID already bound to this license.';
+ return $result;
+ }
+
+ $endpoint = defined('FR_PRO_BIND_INSTANCE_URL')
+ ? trim((string)FR_PRO_BIND_INSTANCE_URL)
+ : 'https://filerise.net/pro/bind_instance.php';
+ if ($endpoint === '') {
+ $result['message'] = 'Auto-bind endpoint is not configured.';
+ return $result;
+ }
+
+ $postData = http_build_query([
+ 'license' => $license,
+ 'instanceId' => $instanceId,
+ ]);
+
+ $status = 0;
+ $bodyText = '';
+
+ if (function_exists('curl_init')) {
+ $ch = curl_init($endpoint);
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 15);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, [
+ 'Content-Type: application/x-www-form-urlencoded',
+ 'Accept: application/json',
+ ]);
+ curl_setopt($ch, CURLOPT_USERAGENT, 'FileRise-Core/1.0 (+https://filerise.net)');
+ $resp = curl_exec($ch);
+ if (is_string($resp)) {
+ $bodyText = $resp;
+ }
+ $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+ } else {
+ $ctx = stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => implode("\r\n", [
+ 'Content-Type: application/x-www-form-urlencoded',
+ 'Accept: application/json',
+ 'User-Agent: FileRise-Core/1.0 (+https://filerise.net)',
+ ]) . "\r\n",
+ 'content' => $postData,
+ 'timeout' => 15,
+ 'ignore_errors' => true,
+ ],
+ ]);
+ $resp = @file_get_contents($endpoint, false, $ctx);
+ if (is_string($resp)) {
+ $bodyText = $resp;
+ }
+ if (isset($http_response_header) && is_array($http_response_header)) {
+ foreach ($http_response_header as $line) {
+ if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $line, $m)) {
+ $status = (int)$m[1];
+ break;
+ }
+ }
+ }
+ }
+
+ $body = null;
+ if ($bodyText !== '') {
+ $decoded = json_decode($bodyText, true);
+ if (is_array($decoded)) {
+ $body = $decoded;
+ }
+ }
+
+ if ($status !== 200 || !is_array($body) || empty($body['ok'])) {
+ $code = is_array($body) ? trim((string)($body['code'] ?? '')) : '';
+ if ($code === 'instance_limit_reached') {
+ $result['message'] = 'License instance limit reached. Use filerise.net/pro/instances.php to manage IDs.';
+ return $result;
+ }
+ if ($code === 'license_superseded') {
+ $result['message'] = 'This license key has been superseded. Use your latest issued key.';
+ return $result;
+ }
+ $err = is_array($body) ? trim((string)($body['error'] ?? '')) : '';
+ $result['message'] = $err !== '' ? $err : 'Instance auto-bind was not completed.';
+ return $result;
+ }
+
+ $nextLicense = trim((string)($body['license'] ?? ''));
+ if ($nextLicense !== '' && stripos($nextLicense, 'FRP1.') === 0) {
+ $result['license'] = $nextLicense;
+ $result['changed'] = ($nextLicense !== $license);
+ }
+ $result['bound'] = !empty($body['bound']) || $result['changed'];
+ $msg = trim((string)($body['message'] ?? ''));
+ if ($msg === '') {
+ $msg = $result['changed']
+ ? 'Instance ID was bound to this license automatically.'
+ : 'License saved.';
+ }
+ $result['message'] = $msg;
+ return $result;
+ }
+
public function setLicense(): void
{
// Always respond JSON
@@ -1012,6 +1196,11 @@ class AdminController
}
$license = isset($data['license']) ? trim((string)$data['license']) : '';
+ $instanceId = self::getInstanceId();
+ $autoBind = self::tryAutoBindInstance($license, $instanceId);
+ if (!empty($autoBind['changed']) && !empty($autoBind['license']) && is_string($autoBind['license'])) {
+ $license = trim((string)$autoBind['license']);
+ }
// Store license + updatedAt in JSON file
if (!defined('PRO_LICENSE_FILE')) {
@@ -1038,7 +1227,15 @@ class AdminController
return;
}
- echo json_encode(['success' => true]);
+ echo json_encode([
+ 'success' => true,
+ 'autoBind' => [
+ 'attempted' => !empty($autoBind['attempted']),
+ 'bound' => !empty($autoBind['bound']),
+ 'changed' => !empty($autoBind['changed']),
+ 'message' => (string)($autoBind['message'] ?? ''),
+ ],
+ ]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
@@ -2462,6 +2659,12 @@ class AdminController
@unlink($zipPath);
@rmdir($workDir);
$label = $httpCode ? "HTTP {$httpCode}" : 'Download failed';
+ if ($msg === '') {
+ if ($snippet !== '' && preg_match('/^Invalid license:\\s*(.+)$/i', $snippet, $m)) {
+ $detail = trim((string)$m[1]);
+ $msg = $detail !== '' ? $detail : 'Invalid license for bundle download.';
+ }
+ }
if ($msg === '') {
// If we got HTML (common with Cloudflare "Just a moment..."), return a human message.
$looksHtml = ($snippet !== '' && preg_match('/^\\s*<(?:!doctype|html|head|body)\\b/i', $snippet));
diff --git a/src/FileRise/Storage/SourceContext.php b/src/FileRise/Storage/SourceContext.php
index 21b869a..9a2bf14 100644
--- a/src/FileRise/Storage/SourceContext.php
+++ b/src/FileRise/Storage/SourceContext.php
@@ -4,11 +4,12 @@ declare(strict_types=1);
namespace FileRise\Storage;
-use ProSources;
+use FileRise\Storage\SourcesConfig;
// src/lib/SourceContext.php
require_once PROJECT_ROOT . '/config/config.php';
+require_once PROJECT_ROOT . '/src/lib/SourcesConfig.php';
final class SourceContext
{
@@ -39,21 +40,19 @@ final class SourceContext
$sessionId = (string)$_SESSION['active_source'];
}
- if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE && class_exists('ProSources')) {
- $cfg = ProSources::getConfig();
- $enabled = !empty($cfg['enabled']);
- if ($enabled) {
- $source = ProSources::getSource($sessionId);
- if (!$source || (empty($source['enabled']) && !$allowDisabled)) {
- $source = ProSources::getFirstEnabledSource();
- }
- if (!$source) {
- $source = ProSources::getDefaultSource();
- }
- self::$activeSource = $source;
- self::$activeId = (string)($source['id'] ?? self::DEFAULT_ID);
- return;
+ $cfg = SourcesConfig::getConfig();
+ $enabled = !empty($cfg['enabled']);
+ if ($enabled) {
+ $source = SourcesConfig::getSource($sessionId);
+ if (!$source || (empty($source['enabled']) && !$allowDisabled)) {
+ $source = SourcesConfig::getFirstEnabledSource();
}
+ if (!$source) {
+ $source = SourcesConfig::getDefaultSource();
+ }
+ self::$activeSource = $source;
+ self::$activeId = (string)($source['id'] ?? self::DEFAULT_ID);
+ return;
}
self::$activeId = self::DEFAULT_ID;
@@ -63,6 +62,7 @@ final class SourceContext
'type' => 'local',
'enabled' => true,
'readOnly' => false,
+ 'disableTrash' => false,
'config' => [
'path' => (string)UPLOAD_DIR,
],
@@ -72,11 +72,7 @@ final class SourceContext
public static function sourcesEnabled(): bool
{
- if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE && class_exists('ProSources')) {
- $cfg = ProSources::getConfig();
- return !empty($cfg['enabled']);
- }
- return false;
+ return SourcesConfig::sourcesEnabled();
}
public static function getActiveId(): string
@@ -105,22 +101,22 @@ final class SourceContext
public static function getSourceById(?string $id): ?array
{
- if (!self::sourcesEnabled() || !class_exists('ProSources')) {
+ if (!self::sourcesEnabled()) {
return null;
}
$id = trim((string)$id);
if ($id === '') {
return null;
}
- return ProSources::getSource($id);
+ return SourcesConfig::getSource($id);
}
public static function listAllSources(): array
{
- if (!self::sourcesEnabled() || !class_exists('ProSources')) {
+ if (!self::sourcesEnabled()) {
return [self::getActiveSource()];
}
- $cfg = ProSources::getConfig();
+ $cfg = SourcesConfig::getConfig();
$sources = isset($cfg['sources']) && is_array($cfg['sources']) ? $cfg['sources'] : [];
return $sources ?: [self::getActiveSource()];
}
@@ -131,6 +127,19 @@ final class SourceContext
return !empty($src['readOnly']);
}
+ public static function isTrashDisabled(): bool
+ {
+ $src = self::getActiveSource();
+ if (!is_array($src)) {
+ return false;
+ }
+ if (!empty($src['disableTrash'])) {
+ return true;
+ }
+ $type = strtolower((string)($src['type'] ?? ''));
+ return $type === 'gdrive';
+ }
+
public static function uploadRoot(): string
{
return self::uploadRootForSource(self::getActiveSource());
diff --git a/src/FileRise/Storage/SourcesConfig.php b/src/FileRise/Storage/SourcesConfig.php
new file mode 100644
index 0000000..8992f35
--- /dev/null
+++ b/src/FileRise/Storage/SourcesConfig.php
@@ -0,0 +1,498 @@
+ true,
+ 'proExtended' => $pro,
+ 'allowedTypes' => self::allowedTypes(),
+ 'coreTypes' => self::CORE_TYPES,
+ 'proTypes' => array_values(array_diff(self::ALL_TYPES, self::CORE_TYPES)),
+ ];
+ }
+
+ private static function withCapabilities(array $cfg): array
+ {
+ return array_merge($cfg, self::capabilityInfo());
+ }
+
+ private static function baseDir(): string
+ {
+ $base = defined('FR_PRO_BUNDLE_DIR') ? rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") : '';
+ if ($base === '') {
+ $base = rtrim((string)USERS_DIR, "/\\") . DIRECTORY_SEPARATOR . 'pro';
+ }
+ return $base;
+ }
+
+ private static function filePath(): string
+ {
+ $base = self::baseDir();
+ return $base !== '' ? ($base . DIRECTORY_SEPARATOR . self::FILE_NAME) : '';
+ }
+
+ private static function ensureDir(): void
+ {
+ $base = self::baseDir();
+ if ($base !== '' && !is_dir($base)) {
+ @mkdir($base, 0755, true);
+ }
+ }
+
+ private static function decryptSecret(string $value): string
+ {
+ $value = trim($value);
+ if ($value === '') return '';
+ $plain = decryptData($value, $GLOBALS['encryptionKey']);
+ return ($plain === false || $plain === null) ? '' : (string)$plain;
+ }
+
+ private static function encryptSecret(string $value): string
+ {
+ $value = (string)$value;
+ if ($value === '') return '';
+ return encryptData($value, $GLOBALS['encryptionKey']);
+ }
+
+ private static function defaultLocalSource(): array
+ {
+ return [
+ 'id' => self::DEFAULT_ID,
+ 'name' => 'Local',
+ 'type' => 'local',
+ 'enabled' => true,
+ 'readOnly' => false,
+ 'disableTrash' => false,
+ 'config' => [
+ 'path' => (string)UPLOAD_DIR,
+ ],
+ ];
+ }
+
+ private static function rawSourceMap(array $raw): array
+ {
+ $sourcesRaw = isset($raw['sources']) && is_array($raw['sources']) ? $raw['sources'] : [];
+ $out = [];
+
+ foreach ($sourcesRaw as $key => $src) {
+ if (!is_array($src)) {
+ continue;
+ }
+ if (!isset($src['id']) && is_string($key)) {
+ $src['id'] = $key;
+ }
+ $id = trim((string)($src['id'] ?? ''));
+ if ($id === '' || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id)) {
+ continue;
+ }
+ $out[$id] = $src;
+ }
+
+ return $out;
+ }
+
+ private static function coreConfigFromRaw(array $raw): array
+ {
+ $enabled = !empty($raw['enabled']);
+ $sources = [];
+
+ foreach (self::rawSourceMap($raw) as $src) {
+ $normalized = self::normalizeSourceStored($src);
+ if ($normalized) {
+ $sources[$normalized['id']] = $normalized;
+ }
+ }
+
+ if (!isset($sources[self::DEFAULT_ID])) {
+ $sources[self::DEFAULT_ID] = self::normalizeSourceStored(self::defaultLocalSource());
+ }
+
+ return [
+ 'enabled' => $enabled,
+ 'sources' => $sources,
+ ];
+ }
+
+ private static function normalizeSourceStored(array $src): ?array
+ {
+ $id = trim((string)($src['id'] ?? ''));
+ if ($id === '' || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id)) {
+ return null;
+ }
+
+ $type = strtolower((string)($src['type'] ?? 'local'));
+ if (!self::isTypeAllowed($type)) {
+ return null;
+ }
+
+ $name = trim((string)($src['name'] ?? ''));
+ if ($name === '') {
+ $name = ($id === self::DEFAULT_ID) ? 'Local' : $id;
+ }
+
+ $enabled = !isset($src['enabled']) || $src['enabled'] !== false;
+ $readOnly = !empty($src['readOnly']);
+ $disableTrash = !empty($src['disableTrash']);
+
+ $config = isset($src['config']) && is_array($src['config']) ? $src['config'] : [];
+ if ($type === 'local') {
+ $path = trim((string)($config['path'] ?? $config['root'] ?? ''));
+ if ($path === '') {
+ $path = (string)UPLOAD_DIR;
+ }
+ $configStore = [
+ 'path' => $path,
+ ];
+ } else {
+ $baseUrl = trim((string)($config['baseUrl'] ?? $config['url'] ?? ''));
+ $username = trim((string)($config['username'] ?? ''));
+ if ($baseUrl === '' || $username === '') {
+ return null;
+ }
+ $root = trim((string)($config['root'] ?? $config['path'] ?? ''));
+ $verifyTls = !isset($config['verifyTls']) || $config['verifyTls'] !== false;
+ $configStore = [
+ 'baseUrl' => $baseUrl,
+ 'username' => $username,
+ 'root' => $root,
+ 'verifyTls' => $verifyTls ? 1 : 0,
+ ];
+ if (isset($config['passwordEnc'])) {
+ $configStore['passwordEnc'] = (string)$config['passwordEnc'];
+ }
+ }
+
+ return [
+ 'id' => $id,
+ 'name' => $name,
+ 'type' => $type,
+ 'enabled' => $enabled,
+ 'readOnly' => $readOnly,
+ 'disableTrash' => $disableTrash,
+ 'config' => $configStore,
+ ];
+ }
+
+ private static function buildSourceView(array $src, bool $includeSecrets, bool $adminView): array
+ {
+ $out = [
+ 'id' => $src['id'],
+ 'name' => $src['name'],
+ 'type' => $src['type'],
+ 'enabled' => !empty($src['enabled']),
+ 'readOnly' => !empty($src['readOnly']),
+ 'disableTrash' => !empty($src['disableTrash']),
+ ];
+
+ $cfg = isset($src['config']) && is_array($src['config']) ? $src['config'] : [];
+ if ($src['type'] === 'local') {
+ $out['config'] = [
+ 'path' => (string)($cfg['path'] ?? ''),
+ ];
+ return $out;
+ }
+
+ if ($src['type'] === 'webdav') {
+ $config = [
+ 'baseUrl' => (string)($cfg['baseUrl'] ?? $cfg['url'] ?? ''),
+ 'username' => (string)($cfg['username'] ?? ''),
+ 'root' => (string)($cfg['root'] ?? $cfg['path'] ?? ''),
+ 'verifyTls' => !isset($cfg['verifyTls']) || $cfg['verifyTls'] !== false,
+ ];
+ $hasPassword = !empty($cfg['passwordEnc']);
+
+ if ($includeSecrets) {
+ $config['password'] = $hasPassword ? self::decryptSecret((string)$cfg['passwordEnc']) : '';
+ } elseif ($adminView) {
+ $config['hasPassword'] = $hasPassword;
+ }
+
+ $out['config'] = $config;
+ return $out;
+ }
+
+ $out['config'] = [];
+ return $out;
+ }
+
+ public static function sourcesEnabled(): bool
+ {
+ return !empty(self::getConfig()['enabled']);
+ }
+
+ public static function getConfig(): array
+ {
+ if (self::proSourcesAvailable()) {
+ return self::withCapabilities(ProSources::getConfig());
+ }
+
+ $cfg = self::coreConfigFromRaw(self::loadRaw());
+ $out = [
+ 'enabled' => !empty($cfg['enabled']),
+ 'sources' => [],
+ ];
+
+ foreach ($cfg['sources'] as $src) {
+ $out['sources'][] = self::buildSourceView($src, true, false);
+ }
+
+ return self::withCapabilities($out);
+ }
+
+ public static function getPublicConfig(): array
+ {
+ if (self::proSourcesAvailable()) {
+ return self::withCapabilities(ProSources::getPublicConfig());
+ }
+
+ $cfg = self::coreConfigFromRaw(self::loadRaw());
+ $out = [
+ 'enabled' => !empty($cfg['enabled']),
+ 'sources' => [],
+ ];
+
+ foreach ($cfg['sources'] as $src) {
+ if (empty($src['enabled'])) {
+ continue;
+ }
+ $view = self::buildSourceView($src, false, false);
+ unset($view['config']);
+ $out['sources'][] = $view;
+ }
+
+ return self::withCapabilities($out);
+ }
+
+ public static function getAdminList(): array
+ {
+ if (self::proSourcesAvailable()) {
+ return self::withCapabilities(ProSources::getAdminList());
+ }
+
+ $cfg = self::coreConfigFromRaw(self::loadRaw());
+ $out = [
+ 'enabled' => !empty($cfg['enabled']),
+ 'sources' => [],
+ ];
+
+ foreach ($cfg['sources'] as $src) {
+ $out['sources'][] = self::buildSourceView($src, false, true);
+ }
+
+ return self::withCapabilities($out);
+ }
+
+ public static function getSource(?string $id): ?array
+ {
+ if (self::proSourcesAvailable()) {
+ return ProSources::getSource($id);
+ }
+
+ $cfg = self::coreConfigFromRaw(self::loadRaw());
+ $id = trim((string)$id);
+ if ($id !== '' && isset($cfg['sources'][$id])) {
+ return self::buildSourceView($cfg['sources'][$id], true, false);
+ }
+
+ return null;
+ }
+
+ public static function getFirstEnabledSource(): ?array
+ {
+ if (self::proSourcesAvailable()) {
+ return ProSources::getFirstEnabledSource();
+ }
+
+ $cfg = self::coreConfigFromRaw(self::loadRaw());
+ foreach ($cfg['sources'] as $src) {
+ if (!empty($src['enabled'])) {
+ return self::buildSourceView($src, true, false);
+ }
+ }
+
+ return null;
+ }
+
+ public static function getDefaultSource(): array
+ {
+ if (self::proSourcesAvailable()) {
+ return ProSources::getDefaultSource();
+ }
+
+ return self::buildSourceView(self::normalizeSourceStored(self::defaultLocalSource()), true, false);
+ }
+
+ public static function saveEnabled(bool $enabled): bool
+ {
+ if (self::proSourcesAvailable()) {
+ return ProSources::saveEnabled($enabled);
+ }
+
+ $raw = self::loadRaw();
+ $raw['enabled'] = $enabled ? true : false;
+ return self::saveRaw($raw);
+ }
+
+ public static function upsertSource(array $source): array
+ {
+ if (self::proSourcesAvailable()) {
+ return ProSources::upsertSource($source);
+ }
+
+ $id = isset($source['id']) ? trim((string)$source['id']) : '';
+ if ($id === '' || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id)) {
+ return ['ok' => false, 'error' => 'Invalid source id'];
+ }
+
+ $rawType = strtolower(trim((string)($source['type'] ?? 'local')));
+ if (!self::isTypeAllowed($rawType)) {
+ return ['ok' => false, 'error' => 'Source type requires FileRise Pro'];
+ }
+
+ $raw = self::loadRaw();
+ $sourceMap = self::rawSourceMap($raw);
+ $existingRaw = isset($sourceMap[$id]) && is_array($sourceMap[$id]) ? $sourceMap[$id] : null;
+ $existing = is_array($existingRaw) ? self::normalizeSourceStored($existingRaw) : null;
+
+ $normalized = self::normalizeSourceStored($source);
+ if (!$normalized) {
+ return ['ok' => false, 'error' => 'Invalid source configuration'];
+ }
+
+ if (!array_key_exists('disableTrash', $source) && $existing && isset($existing['disableTrash'])) {
+ $normalized['disableTrash'] = !empty($existing['disableTrash']);
+ }
+
+ if ($normalized['type'] === 'webdav') {
+ $cfgStore = $normalized['config'];
+ $password = isset($source['config']['password']) ? trim((string)$source['config']['password']) : '';
+
+ if ($password !== '') {
+ $cfgStore['passwordEnc'] = self::encryptSecret($password);
+ } elseif ($existingRaw && isset($existingRaw['config']) && is_array($existingRaw['config']) && isset($existingRaw['config']['passwordEnc'])) {
+ $cfgStore['passwordEnc'] = (string)$existingRaw['config']['passwordEnc'];
+ }
+
+ if (empty($cfgStore['passwordEnc'])) {
+ return ['ok' => false, 'error' => 'WebDAV requires a password'];
+ }
+
+ $normalized['config'] = $cfgStore;
+ }
+
+ $sourceMap[$id] = $normalized;
+ $raw['sources'] = $sourceMap;
+
+ if (!self::saveRaw($raw)) {
+ return ['ok' => false, 'error' => 'Failed to save sources'];
+ }
+
+ return ['ok' => true, 'source' => self::buildSourceView($normalized, false, true)];
+ }
+
+ public static function deleteSource(string $id): array
+ {
+ if (self::proSourcesAvailable()) {
+ return ProSources::deleteSource($id);
+ }
+
+ $id = trim($id);
+ if ($id === '' || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $id)) {
+ return ['ok' => false, 'error' => 'Invalid source id'];
+ }
+ if ($id === self::DEFAULT_ID) {
+ return ['ok' => false, 'error' => 'Cannot delete the default Local source'];
+ }
+
+ $raw = self::loadRaw();
+ $sourceMap = self::rawSourceMap($raw);
+ if (!isset($sourceMap[$id])) {
+ return ['ok' => true];
+ }
+
+ unset($sourceMap[$id]);
+ $raw['sources'] = $sourceMap;
+
+ if (!self::saveRaw($raw)) {
+ return ['ok' => false, 'error' => 'Failed to delete source'];
+ }
+
+ return ['ok' => true];
+ }
+
+ private static function loadRaw(): array
+ {
+ $path = self::filePath();
+ if ($path === '' || !is_file($path)) {
+ return ['enabled' => false, 'sources' => []];
+ }
+
+ $raw = @file_get_contents($path);
+ $data = is_string($raw) ? json_decode($raw, true) : null;
+ return is_array($data) ? $data : ['enabled' => false, 'sources' => []];
+ }
+
+ private static function saveRaw(array $cfg): bool
+ {
+ $path = self::filePath();
+ if ($path === '') return false;
+ self::ensureDir();
+
+ $json = json_encode($cfg, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
+ if ($json === false) return false;
+
+ $tmp = $path . '.tmp';
+ if (@file_put_contents($tmp, $json, LOCK_EX) === false) {
+ return false;
+ }
+ if (!@rename($tmp, $path)) {
+ @unlink($tmp);
+ return false;
+ }
+ @chmod($path, 0644);
+ return true;
+ }
+}
diff --git a/src/FileRise/Storage/StorageFactory.php b/src/FileRise/Storage/StorageFactory.php
index 6e66a4d..5ef5615 100644
--- a/src/FileRise/Storage/StorageFactory.php
+++ b/src/FileRise/Storage/StorageFactory.php
@@ -8,6 +8,8 @@ use FileRise\Storage\StorageAdapterInterface;
use FileRise\Storage\LocalFsAdapter;
use FileRise\Storage\ReadOnlyAdapter;
use FileRise\Storage\SourceContext;
+use FileRise\Storage\SourcesConfig;
+use FileRise\Storage\WebDavAdapter;
use ProDropboxAdapter;
use ProFtpAdapter;
use ProGDriveAdapter;
@@ -15,7 +17,6 @@ use ProOneDriveAdapter;
use ProS3Adapter;
use ProSftpAdapter;
use ProSmbAdapter;
-use ProSources;
use ProWebDavAdapter;
// src/lib/StorageFactory.php
@@ -23,6 +24,8 @@ use ProWebDavAdapter;
require_once PROJECT_ROOT . '/src/lib/StorageAdapterInterface.php';
require_once PROJECT_ROOT . '/src/lib/LocalFsAdapter.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
+require_once PROJECT_ROOT . '/src/lib/SourcesConfig.php';
+require_once PROJECT_ROOT . '/src/lib/WebDavAdapter.php';
require_once PROJECT_ROOT . '/src/lib/ReadOnlyAdapter.php';
final class StorageFactory
@@ -85,13 +88,15 @@ final class StorageFactory
$adapter = ProFtpAdapter::fromConfig($cfg, $root);
}
} elseif ($type === 'webdav') {
- if (!class_exists('ProWebDavAdapter') && defined('FR_PRO_BUNDLE_DIR') && FR_PRO_BUNDLE_DIR) {
+ if (class_exists(WebDavAdapter::class)) {
+ $adapter = WebDavAdapter::fromConfig($cfg, $root);
+ } elseif (!class_exists('ProWebDavAdapter') && defined('FR_PRO_BUNDLE_DIR') && FR_PRO_BUNDLE_DIR) {
$adapterPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . DIRECTORY_SEPARATOR . 'ProWebDavAdapter.php';
if (is_file($adapterPath)) {
require_once $adapterPath;
}
}
- if (class_exists('ProWebDavAdapter')) {
+ if (!$adapter && class_exists('ProWebDavAdapter')) {
$adapter = ProWebDavAdapter::fromConfig($cfg, $root);
}
} elseif ($type === 'smb') {
@@ -155,16 +160,12 @@ final class StorageFactory
return self::createDefaultAdapter();
}
- if (!class_exists('ProSources')) {
- return self::createDefaultAdapter();
- }
-
- $source = ProSources::getSource($sourceId);
+ $source = SourcesConfig::getSource($sourceId);
if (!$source || empty($source['enabled'])) {
- $source = ProSources::getFirstEnabledSource();
+ $source = SourcesConfig::getFirstEnabledSource();
}
if (!$source) {
- $source = ProSources::getDefaultSource();
+ $source = SourcesConfig::getDefaultSource();
}
$type = strtolower((string)($source['type'] ?? 'local'));
@@ -208,13 +209,16 @@ final class StorageFactory
$adapter = ProFtpAdapter::fromConfig($source['config'] ?? [], $root);
}
} elseif ($type === 'webdav') {
- if (!class_exists('ProWebDavAdapter') && defined('FR_PRO_BUNDLE_DIR') && FR_PRO_BUNDLE_DIR) {
+ if (class_exists(WebDavAdapter::class)) {
+ $root = SourceContext::uploadRootForId((string)($source['id'] ?? ''));
+ $adapter = WebDavAdapter::fromConfig($source['config'] ?? [], $root);
+ } elseif (!class_exists('ProWebDavAdapter') && defined('FR_PRO_BUNDLE_DIR') && FR_PRO_BUNDLE_DIR) {
$adapterPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . DIRECTORY_SEPARATOR . 'ProWebDavAdapter.php';
if (is_file($adapterPath)) {
require_once $adapterPath;
}
}
- if (class_exists('ProWebDavAdapter')) {
+ if (!$adapter && class_exists('ProWebDavAdapter')) {
$root = SourceContext::uploadRootForId((string)($source['id'] ?? ''));
$adapter = ProWebDavAdapter::fromConfig($source['config'] ?? [], $root);
}
diff --git a/src/FileRise/Storage/WebDavAdapter.php b/src/FileRise/Storage/WebDavAdapter.php
new file mode 100644
index 0000000..2f85db5
--- /dev/null
+++ b/src/FileRise/Storage/WebDavAdapter.php
@@ -0,0 +1,496 @@
+>> */
+ private array $listCache = [];
+ private int $listCacheTtl = 5;
+
+ private function __construct(
+ string $baseUri,
+ string $username,
+ string $password,
+ string $localRoot,
+ bool $verifyTls,
+ int $timeout
+ ) {
+ $this->baseUri = rtrim($baseUri, '/');
+ $basePath = (string)(parse_url($this->baseUri, PHP_URL_PATH) ?? '');
+ $this->basePath = rtrim($basePath, '/');
+ $this->username = $username;
+ $this->password = $password;
+ $this->localRoot = rtrim(str_replace('\\', '/', $localRoot), '/');
+ $this->verifyTls = $verifyTls;
+ $this->timeout = $timeout;
+
+ $settings = [
+ 'baseUri' => $this->baseUri . '/',
+ 'userName' => $username,
+ 'password' => $password,
+ ];
+
+ $this->client = new Client($settings);
+ if (!$verifyTls) {
+ $this->client->addCurlSetting(CURLOPT_SSL_VERIFYPEER, false);
+ $this->client->addCurlSetting(CURLOPT_SSL_VERIFYHOST, 0);
+ }
+ if ($timeout > 0) {
+ $this->client->addCurlSetting(CURLOPT_TIMEOUT, $timeout);
+ $this->client->addCurlSetting(CURLOPT_CONNECTTIMEOUT, min(10, $timeout));
+ }
+ }
+
+ public static function fromConfig(array $cfg, string $root): ?self
+ {
+ self::ensureSabreLoaded();
+ $baseUrl = trim((string)($cfg['baseUrl'] ?? $cfg['url'] ?? ''));
+ $username = trim((string)($cfg['username'] ?? ''));
+ if ($baseUrl === '' || $username === '') {
+ return null;
+ }
+ $password = (string)($cfg['password'] ?? '');
+ $rootPath = trim((string)($cfg['root'] ?? $cfg['path'] ?? ''));
+ $verifyTls = !isset($cfg['verifyTls']) || $cfg['verifyTls'] !== false;
+ $timeout = (int)($cfg['timeout'] ?? 20);
+ if ($timeout <= 0) {
+ $timeout = 20;
+ }
+
+ $baseUri = self::buildBaseUri($baseUrl, $rootPath);
+ return new self($baseUri, $username, $password, $root, $verifyTls, $timeout);
+ }
+
+ public function isLocal(): bool
+ {
+ return false;
+ }
+
+ public function testConnection(): bool
+ {
+ $status = $this->request('PROPFIND', $this->buildUrlForRelative(''), null, ['Depth' => '0']);
+ if ($status >= 200 && $status < 300) {
+ $this->lastError = '';
+ return true;
+ }
+ if ($status === 401 || $status === 403) {
+ $this->lastError = 'Auth failed (HTTP ' . $status . ')';
+ } elseif ($status > 0) {
+ $this->lastError = 'HTTP ' . $status;
+ } elseif ($this->lastError === '') {
+ $this->lastError = 'Connection failed';
+ }
+ return false;
+ }
+
+ public function getLastError(): string
+ {
+ return trim($this->lastError);
+ }
+
+ public function list(string $path): array
+ {
+ $props = $this->propFind($path, 1);
+ if (!$props) return [];
+ $parentRel = trim($this->relativePath($path), '/');
+ $items = [];
+ $children = [];
+ foreach ($props as $href => $prop) {
+ $rel = trim($this->hrefToRelative((string)$href), '/');
+ if ($rel === '' || $rel === $parentRel) continue;
+ $name = basename($rel);
+ if ($name === '' || $name === '.' || $name === '..') continue;
+ $items[] = $name;
+ $children[$name] = $this->propsToStat($prop);
+ }
+ $this->storeListCache($parentRel, $children);
+ return array_values(array_unique($items));
+ }
+
+ public function stat(string $path): ?array
+ {
+ $rel = $this->relativePath($path);
+ if ($rel !== '') {
+ $parentRel = trim(str_replace('\\', '/', dirname($rel)), '/');
+ if ($parentRel === '.' || $parentRel === '') {
+ $parentRel = '';
+ }
+ $base = basename($rel);
+ $cached = $this->getListCache($parentRel);
+ if ($cached !== null && isset($cached[$base])) {
+ return $cached[$base];
+ }
+ }
+
+ $props = $this->propFind($path, 0);
+ if (!$props) return null;
+
+ return $this->propsToStat($props);
+ }
+
+ public function read(string $path, ?int $length = null, int $offset = 0): string|false
+ {
+ $stream = $this->openReadStream($path, $length, $offset);
+ if ($stream === false) {
+ return false;
+ }
+
+ if (is_resource($stream)) {
+ $data = ($length !== null)
+ ? stream_get_contents($stream, $length)
+ : stream_get_contents($stream);
+ fclose($stream);
+ return ($data === false) ? false : $data;
+ }
+
+ if (is_object($stream) && method_exists($stream, 'read')) {
+ $data = $stream->read($length ?? 0);
+ if (method_exists($stream, 'close')) {
+ $stream->close();
+ }
+ return $data;
+ }
+
+ return false;
+ }
+
+ public function openReadStream(string $path, ?int $length = null, int $offset = 0)
+ {
+ $url = $this->buildUrlForPath($path);
+ if ($url === '') return false;
+
+ $headers = [];
+ if ($this->username !== '') {
+ $headers[] = 'Authorization: Basic ' . base64_encode($this->username . ':' . $this->password);
+ }
+ if ($offset > 0 || $length !== null) {
+ $end = ($length !== null && $length > 0) ? ($offset + $length - 1) : '';
+ $headers[] = 'Range: bytes=' . $offset . '-' . $end;
+ }
+
+ $opts = [
+ 'http' => [
+ 'method' => 'GET',
+ 'header' => implode("\r\n", $headers),
+ 'ignore_errors' => true,
+ 'timeout' => $this->timeout,
+ ],
+ ];
+ if (!$this->verifyTls) {
+ $opts['ssl'] = [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ ];
+ }
+
+ $context = stream_context_create($opts);
+ $fp = @fopen($url, 'rb', false, $context);
+ return $fp ?: false;
+ }
+
+ public function write(string $path, string $data, int $flags = 0): bool
+ {
+ if (!$this->ensureParentExists($path)) return false;
+ $url = $this->buildUrlForPath($path);
+ if ($url === '') return false;
+ $status = $this->request('PUT', $url, $data, []);
+ return $status >= 200 && $status < 300;
+ }
+
+ public function writeStream(string $path, $stream, ?int $length = null, ?string $mimeType = null): bool
+ {
+ if (!is_resource($stream)) return false;
+ if (!$this->ensureParentExists($path)) return false;
+ $url = $this->buildUrlForPath($path);
+ if ($url === '') return false;
+ $headers = [];
+ if ($mimeType) {
+ $headers['Content-Type'] = $mimeType;
+ }
+ $meta = @stream_get_meta_data($stream);
+ $seekable = is_array($meta) && !empty($meta['seekable']);
+ if ($length === null) {
+ $stat = @fstat($stream);
+ if (is_array($stat) && isset($stat['size'])) {
+ $length = (int)$stat['size'];
+ }
+ }
+ if ($length !== null && $length >= 0) {
+ $headers['Content-Length'] = (string)$length;
+ }
+ if ($seekable) {
+ @rewind($stream);
+ }
+ $status = $this->request('PUT', $url, $stream, $headers);
+ if ($status >= 200 && $status < 300) {
+ return true;
+ }
+
+ if ($seekable && $length !== null && $length <= self::WRITE_FALLBACK_MAX_BYTES) {
+ @rewind($stream);
+ $data = stream_get_contents($stream);
+ if ($data !== false) {
+ $status = $this->request('PUT', $url, $data, $headers);
+ return $status >= 200 && $status < 300;
+ }
+ }
+
+ return false;
+ }
+
+ public function move(string $from, string $to): bool
+ {
+ $src = $this->buildUrlForPath($from);
+ $dst = $this->buildUrlForPath($to);
+ if ($src === '' || $dst === '') return false;
+ $status = $this->request('MOVE', $src, null, [
+ 'Destination' => $dst,
+ 'Overwrite' => 'T',
+ ]);
+ return $status >= 200 && $status < 300;
+ }
+
+ public function copy(string $from, string $to): bool
+ {
+ $src = $this->buildUrlForPath($from);
+ $dst = $this->buildUrlForPath($to);
+ if ($src === '' || $dst === '') return false;
+ $status = $this->request('COPY', $src, null, [
+ 'Destination' => $dst,
+ 'Overwrite' => 'T',
+ ]);
+ return $status >= 200 && $status < 300;
+ }
+
+ public function delete(string $path): bool
+ {
+ $url = $this->buildUrlForPath($path);
+ if ($url === '') return false;
+ $status = $this->request('DELETE', $url, null, []);
+ return $status >= 200 && $status < 300;
+ }
+
+ public function mkdir(string $path, int $mode = 0775, bool $recursive = true): bool
+ {
+ $rel = trim($this->relativePath($path), '/');
+ if ($rel === '') return true;
+ $parts = array_values(array_filter(explode('/', $rel), fn($p) => $p !== ''));
+ if (!$parts) return true;
+
+ $acc = '';
+ foreach ($parts as $part) {
+ $acc = ($acc === '') ? $part : ($acc . '/' . $part);
+ if (!$recursive && $acc !== $rel) continue;
+ $url = $this->buildUrlForRelative($acc);
+ $status = $this->request('MKCOL', $url, null, []);
+ if ($status >= 200 && $status < 300) {
+ continue;
+ }
+ if ($status === 405) {
+ continue;
+ }
+ return false;
+ }
+ return true;
+ }
+
+ private static function ensureSabreLoaded(): void
+ {
+ if (!class_exists(Client::class)) {
+ $autoload = PROJECT_ROOT . '/vendor/autoload.php';
+ if (is_file($autoload)) {
+ require_once $autoload;
+ }
+ }
+ }
+
+ private static function buildBaseUri(string $baseUrl, string $rootPath): string
+ {
+ $base = rtrim($baseUrl, '/');
+ $root = trim($rootPath, '/');
+ if ($root === '') {
+ return $base;
+ }
+ $encoded = self::encodePath($root);
+ return $base . '/' . $encoded;
+ }
+
+ private static function encodePath(string $path): string
+ {
+ $trimmed = trim($path, '/');
+ if ($trimmed === '') return '';
+ $parts = array_map('rawurlencode', explode('/', $trimmed));
+ return implode('/', $parts);
+ }
+
+ private function buildUrlForRelative(string $rel): string
+ {
+ $rel = trim($rel, '/');
+ if ($rel === '') {
+ return $this->baseUri;
+ }
+ return $this->baseUri . '/' . self::encodePath($rel);
+ }
+
+ private function buildUrlForPath(string $path): string
+ {
+ $rel = $this->relativePath($path);
+ return $this->buildUrlForRelative($rel);
+ }
+
+ private function relativePath(string $path): string
+ {
+ $p = str_replace('\\', '/', $path);
+ $root = $this->localRoot;
+ if ($root !== '' && str_starts_with($p, $root)) {
+ $p = substr($p, strlen($root));
+ }
+ return ltrim($p, '/');
+ }
+
+ private function hrefToRelative(string $href): string
+ {
+ $path = (string)(parse_url($href, PHP_URL_PATH) ?? '');
+ $path = rawurldecode($path);
+ $base = $this->basePath;
+ if ($base !== '' && $base !== '/' && str_starts_with($path, $base)) {
+ $path = substr($path, strlen($base));
+ }
+ return ltrim($path, '/');
+ }
+
+ private function propFind(string $path, int $depth): array
+ {
+ $url = $this->buildUrlForPath($path);
+ if ($url === '') return [];
+ try {
+ return $this->client->propFind($url, [
+ '{DAV:}displayname',
+ '{DAV:}resourcetype',
+ '{DAV:}getcontentlength',
+ '{DAV:}getlastmodified',
+ ], $depth);
+ } catch (Throwable $e) {
+ return [];
+ }
+ }
+
+ private function getListCache(string $rel): ?array
+ {
+ if (!isset($this->listCache[$rel])) {
+ return null;
+ }
+ $entry = $this->listCache[$rel];
+ $ts = (int)($entry['ts'] ?? 0);
+ if ($ts <= 0 || (time() - $ts) > $this->listCacheTtl) {
+ unset($this->listCache[$rel]);
+ return null;
+ }
+ $children = $entry['children'] ?? null;
+ return is_array($children) ? $children : null;
+ }
+
+ private function storeListCache(string $rel, array $children): void
+ {
+ $this->listCache[$rel] = [
+ 'ts' => time(),
+ 'children' => $children,
+ ];
+ }
+
+ private function propsToStat(array $props): array
+ {
+ $type = 'file';
+ $resType = $props['{DAV:}resourcetype'] ?? null;
+ if ($this->isCollection($resType)) {
+ $type = 'dir';
+ }
+
+ $size = isset($props['{DAV:}getcontentlength']) ? (int)$props['{DAV:}getcontentlength'] : 0;
+ $mtimeRaw = (string)($props['{DAV:}getlastmodified'] ?? '');
+ $mtime = $mtimeRaw !== '' ? (int)(strtotime($mtimeRaw) ?: 0) : 0;
+
+ return [
+ 'type' => $type,
+ 'size' => $size,
+ 'mtime' => $mtime,
+ 'mode' => 0,
+ ];
+ }
+
+ private function request(string $method, string $url, $body = null, array $headers = []): int
+ {
+ try {
+ $resp = $this->client->request($method, $url, $body, $headers);
+ $status = (int)($resp['statusCode'] ?? 0);
+ if ($status < 200 || $status >= 300) {
+ $msg = trim((string)($resp['body'] ?? ''));
+ if ($msg !== '') {
+ $msg = preg_replace('/\\s+/', ' ', $msg);
+ if (strlen($msg) > 240) {
+ $msg = substr($msg, 0, 240) . '...';
+ }
+ }
+ $this->lastError = $msg !== '' ? ('HTTP ' . $status . ': ' . $msg) : ('HTTP ' . $status);
+ } else {
+ $this->lastError = '';
+ }
+ return $status;
+ } catch (Throwable $e) {
+ $msg = trim($e->getMessage());
+ if ($msg !== '') {
+ $msg = preg_replace('/(https?:\\/\\/)([^\\s@]+@)/i', '$1', $msg);
+ $this->lastError = $msg;
+ } else {
+ $this->lastError = 'WebDAV request failed';
+ }
+ return 0;
+ }
+ }
+
+ private function isCollection($value): bool
+ {
+ if ($value instanceof ResourceType) {
+ return $value->is('{DAV:}collection');
+ }
+ if (is_array($value)) {
+ return in_array('{DAV:}collection', $value, true);
+ }
+ if (is_string($value)) {
+ return stripos($value, 'collection') !== false;
+ }
+ return false;
+ }
+
+ private function ensureParentExists(string $path): bool
+ {
+ $rel = trim($this->relativePath($path), '/');
+ if ($rel === '') return true;
+ $parent = trim(str_replace('\\', '/', dirname($rel)), '/');
+ if ($parent === '' || $parent === '.') {
+ return true;
+ }
+ $parentPath = $this->localRoot !== '' ? ($this->localRoot . '/' . $parent) : $parent;
+ return $this->mkdir($parentPath, 0775, true);
+ }
+}
diff --git a/src/FileRise/Support/ACL.php b/src/FileRise/Support/ACL.php
index 8aa0f52..036f30c 100644
--- a/src/FileRise/Support/ACL.php
+++ b/src/FileRise/Support/ACL.php
@@ -5,13 +5,14 @@ declare(strict_types=1);
namespace FileRise\Support;
use FileRise\Storage\SourceContext;
+use FileRise\Storage\SourcesConfig;
use RuntimeException;
-use ProSources;
// src/lib/ACL.php
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
+require_once PROJECT_ROOT . '/src/lib/SourcesConfig.php';
class ACL
{
@@ -80,8 +81,8 @@ class ACL
{
$user = (string)$user;
- if (class_exists('SourceContext') && SourceContext::sourcesEnabled() && class_exists('ProSources')) {
- $cfg = ProSources::getConfig();
+ if (class_exists('SourceContext') && SourceContext::sourcesEnabled()) {
+ $cfg = SourcesConfig::getConfig();
$sources = isset($cfg['sources']) && is_array($cfg['sources']) ? $cfg['sources'] : [];
$changedAny = false;
foreach ($sources as $src) {
diff --git a/src/lib/SourcesConfig.php b/src/lib/SourcesConfig.php
new file mode 100644
index 0000000..d3a6d57
--- /dev/null
+++ b/src/lib/SourcesConfig.php
@@ -0,0 +1,11 @@
+