From 5ce824778a80fb01c5dd160638e1bb6148999c36 Mon Sep 17 00:00:00 2001 From: Ryan Date: Sat, 31 Jan 2026 03:34:28 -0500 Subject: [PATCH] release(v3.3.0): security hardening (tag color sanitization + restrict direct uploads access) --- CHANGELOG.md | 32 +++++ Dockerfile | 17 +-- README.md | 1 + config/config.php | 39 ++++++ docs/wiki/Pro-Client-Portals.md | 2 +- docs/wiki/install setup.md | 22 ++++ docs/wiki/nginx setup.md | 12 +- openapi.json.dist | 43 ++++++- public/api/file/getFileTag.php | 3 +- public/api/file/saveFileTag.php | 14 ++- public/api/pro/portals/publicMeta.php | 12 +- public/api/profile/getCurrentUser.php | 2 +- public/api/profile/uploadPicture.php | 4 +- public/api/public/profilePic.php | 72 +++++++++++ public/js/adminPanel.js | 7 ++ public/js/fileListView.js | 14 ++- public/js/filePreview.js | 16 ++- public/js/fileTags.js | 171 +++++++++++++++++++++----- public/js/main.js | 9 +- public/js/portal-login.js | 25 +++- public/js/portal.js | 13 +- public/js/shareBranding.js | 2 +- src/controllers/FileController.php | 128 ++++++++++++++----- src/controllers/PortalController.php | 6 + src/controllers/UserController.php | 62 ++++++---- src/models/AdminModel.php | 6 +- src/models/FileModel.php | 79 +++++++++++- src/models/UserModel.php | 5 +- 28 files changed, 678 insertions(+), 140 deletions(-) create mode 100644 public/api/public/profilePic.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d52bf8f..3ede6ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## Changes 01/31/2026 (v3.3.0) + +`release(v3.3.0): security hardening (tag color sanitization + restrict direct uploads access)` + +**Security** + +- Hardened tag color handling to prevent HTML/CSS injection: + - Tag colors are now sanitized server-side on save and on read. + - Allowed formats: `#RGB` / `#RRGGBB` and simple named colors. + - Invalid values fall back to a safe default. +- Docker default now blocks direct `/uploads/*` access: + - File data should be accessed via authenticated API/download flows (and share links where applicable). + - Added a constrained public endpoint for profile pictures / portal logos: + - `GET /api/public/profilePic.php?file=` + - Locked to `UPLOAD_DIR/profile_pics/` with realpath boundary checks + - Image-only MIME allowlist + `X-Content-Type-Options: nosniff` + +**Changed** + +- **Behavior change (security, Docker default):** Direct requests to `/uploads/...` are no longer served. + - If you intentionally need a public file host, use share links or a separate explicitly-public directory/vhost. +- Tag APIs now accept optional `sourceId` and sanitize tags end-to-end for Sources. + +**Docs/OpenAPI** + +- OpenAPI updated to reflect: + - tag objects (`{name,color}`) + - `sourceId` parameters for tag endpoints + - profile picture URLs served via `/api/public/profilePic.php` + +--- + ## Changes 01/30/2026 (v3.2.4) `release(v3.2.4): OIDC group-claim mapping + extra scopes (Authentik & Keycloak-friendly) + sponsor list update` diff --git a/Dockerfile b/Dockerfile index 0aeec6e..9cbea85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -117,19 +117,10 @@ RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf ExpiresByType application/javascript "access plus 3 hour" - # Protect uploads directory - Alias /uploads/ /var/www/uploads/ - - Options -Indexes - AllowOverride None - - php_flag engine off - - - php_flag engine off - - Require all granted - + # Block direct access to uploads/users/metadata (data must go through the API) + + Require all denied + # Public directory diff --git a/README.md b/README.md index 868b20c..9b21013 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,7 @@ Notes: - Use HTTPS and set `SECURE="true"` when behind TLS/reverse proxy. - If behind a proxy, set `FR_TRUSTED_PROXIES` and `FR_IP_HEADER`. - Set `FR_PUBLISHED_URL` (and `FR_BASE_PATH` if needed) so share links are correct. +- Block direct HTTP access to `/uploads` (serve only `public/` and deny access to `/uploads`, `/users`, `/metadata`). ## Optional dependencies diff --git a/config/config.php b/config/config.php index 61a2c83..87b1895 100644 --- a/config/config.php +++ b/config/config.php @@ -456,6 +456,45 @@ function fr_with_base_path($path) return $bp . $p; } +function fr_profile_pic_url(string $filename): string +{ + $name = trim((string)$filename); + if ($name === '') return ''; + $name = str_replace('\\', '/', $name); + $name = basename($name); + if ($name === '' || $name === '.' || $name === '..') return ''; + return '/api/public/profilePic.php?file=' . rawurlencode($name); +} + +function fr_normalize_profile_pic_url(string $url): string +{ + $raw = trim((string)$url); + if ($raw === '') return ''; + + // Leave absolute http(s) URLs unchanged. + if (preg_match('~^https?://~i', $raw)) return $raw; + + // Ensure a leading slash for site-relative paths. + if ($raw[0] !== '/') $raw = '/' . ltrim($raw, '/'); + + $base = defined('FR_BASE_PATH') ? (string)FR_BASE_PATH : ''; + $prefix = ''; + if ($base !== '' && $raw !== $base && strpos($raw, $base . '/') === 0) { + $prefix = $base; + $raw = substr($raw, strlen($base)); + if ($raw === '') $raw = '/'; + } + + if (preg_match('~^/uploads/profile_pics/([^/?#]+)~', $raw, $m)) { + return $prefix . fr_profile_pic_url($m[1]); + } + if (preg_match('~^/api/public/profilePic\.php\\?file=~', $raw)) { + return $prefix . $raw; + } + + return $prefix . $raw; +} + if (strpos(BASE_URL, 'yourwebsite') !== false) { $defaultShare = "{$proto}://{$host}" . fr_with_base_path("/api/file/share.php"); } else { diff --git a/docs/wiki/Pro-Client-Portals.md b/docs/wiki/Pro-Client-Portals.md index e4f426e..4e4a090 100644 --- a/docs/wiki/Pro-Client-Portals.md +++ b/docs/wiki/Pro-Client-Portals.md @@ -70,7 +70,7 @@ Use these fields to customize the portal page: - **Instructions**: short text for the client (what to upload, deadlines, etc.). - **Accent color**: UI highlight color (CSS hex). - **Footer text**: small text at the bottom of the portal. -- **Portal logo**: upload a logo image (stored under `uploads/profile_pics`). +- **Portal logo**: upload a logo image (stored under `uploads/profile_pics`, served via `/api/public/profilePic.php`). --- diff --git a/docs/wiki/install setup.md b/docs/wiki/install setup.md index 458dc29..225d299 100644 --- a/docs/wiki/install setup.md +++ b/docs/wiki/install setup.md @@ -136,6 +136,28 @@ sudo chmod 700 /var/www/sessions - If your proxy strips the prefix, set `FR_BASE_PATH` or send `X-Forwarded-Prefix`. - If you are behind a reverse proxy, set `FR_TRUSTED_PROXIES` and `FR_IP_HEADER`. +### Block direct access to /uploads, /users, /metadata (required) + +Uploaded file data and app metadata must go through the API. Do **not** expose `/uploads`, `/users`, or `/metadata` directly. + +Apache: + +``` + + Require all denied + +``` + +Nginx: + +``` +location ~* ^/(uploads|users|metadata)(/|$) { + return 403; +} +``` + +If you previously added aliases for `/uploads`, `/users`, or `/metadata`, remove them. + ### First-run security checklist - Set `PERSISTENT_TOKENS_KEY` to a strong value. diff --git a/docs/wiki/nginx setup.md b/docs/wiki/nginx setup.md index 5bcf7a0..9034b7c 100644 --- a/docs/wiki/nginx setup.md +++ b/docs/wiki/nginx setup.md @@ -32,6 +32,11 @@ server { try_files $uri $uri/ /index.php?$query_string; } + # Block direct access to uploads/users/metadata (serve files via API) + location ~* ^/(uploads|users|metadata)(/|$) { + return 403; + } + location ~ \.php$ { include fastcgi_params; fastcgi_split_path_info ^(.+\.php)(/.+)$; @@ -40,7 +45,7 @@ server { fastcgi_index index.php; } - location ~* /(users|metadata|\.git) { + location ~* ^/\.git { deny all; } @@ -75,6 +80,11 @@ server { try_files $uri $uri/ /files/index.php?$query_string; } + # Block direct access to uploads/users/metadata (serve files via API) + location ~* ^/(uploads|users|metadata)(/|$) { + return 403; + } + location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; diff --git a/openapi.json.dist b/openapi.json.dist index 6af385d..22b6a9e 100644 --- a/openapi.json.dist +++ b/openapi.json.dist @@ -2,7 +2,7 @@ "openapi": "3.0.0", "info": { "title": "FileRise API", - "version": "3.2.3" + "version": "3.3.0" }, "servers": [ { @@ -2146,6 +2146,17 @@ "summary": "Get global file tags", "description": "Returns tag metadata (no auth in current implementation).", "operationId": "getFileTag", + "parameters": [ + { + "name": "sourceId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Optional source id (Pro sources)." + } + ], "responses": { "200": { "description": "Tags map (model-defined JSON)" @@ -2466,14 +2477,34 @@ "type": "string", "example": "doc.md" }, + "sourceId": { + "type": "string", + "example": "local" + }, "tags": { "type": "array", "items": { - "type": "string" + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "work" + }, + "color": { + "type": "string", + "example": "#ff0000" + } + } }, "example": [ - "work", - "urgent" + { + "name": "work", + "color": "#ff0000" + }, + { + "name": "urgent", + "color": "#00ff00" + } ] }, "deleteGlobal": { @@ -5561,7 +5592,7 @@ }, "profile_picture": { "type": "string", - "example": "/uploads/profile_pics/ryan.png" + "example": "/api/public/profilePic.php?file=ryan.png" } }, "type": "object" @@ -5636,7 +5667,7 @@ }, "url": { "type": "string", - "example": "/uploads/profile_pics/alice_9f3c2e1a8bcd.png" + "example": "/api/public/profilePic.php?file=alice_9f3c2e1a8bcd.png" } }, "type": "object" diff --git a/public/api/file/getFileTag.php b/public/api/file/getFileTag.php index 98108c5..7e00191 100644 --- a/public/api/file/getFileTag.php +++ b/public/api/file/getFileTag.php @@ -5,9 +5,10 @@ * @OA\Get( * path="/api/file/getFileTag.php", * summary="Get global file tags", - * description="Returns tag metadata (no auth in current implementation).", + * description="Returns tag metadata for the authenticated session.", * operationId="getFileTag", * tags={"Tags"}, + * @OA\Parameter(name="sourceId", in="query", required=false, @OA\Schema(type="string"), description="Optional source id (Pro sources)."), * @OA\Response(response=200, description="Tags map (model-defined JSON)") * ) */ diff --git a/public/api/file/saveFileTag.php b/public/api/file/saveFileTag.php index f890052..6fdfcbb 100644 --- a/public/api/file/saveFileTag.php +++ b/public/api/file/saveFileTag.php @@ -16,7 +16,17 @@ * required={"folder","file"}, * @OA\Property(property="folder", type="string", example="root"), * @OA\Property(property="file", type="string", example="doc.md"), - * @OA\Property(property="tags", type="array", @OA\Items(type="string"), example={"work","urgent"}), + * @OA\Property(property="sourceId", type="string", example="local"), + * @OA\Property( + * property="tags", + * type="array", + * @OA\Items( + * type="object", + * @OA\Property(property="name", type="string", example="work"), + * @OA\Property(property="color", type="string", example="#ff0000") + * ), + * example={{"name":"work","color":"#ff0000"},{"name":"urgent","color":"#00ff00"}} + * ), * @OA\Property(property="deleteGlobal", type="boolean", example=false), * @OA\Property(property="tagToDelete", type="string", nullable=true, example=null) * ) @@ -33,4 +43,4 @@ require_once __DIR__ . '/../../../config/config.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php'; $fileController = new FileController(); -$fileController->saveFileTag(); \ No newline at end of file +$fileController->saveFileTag(); diff --git a/public/api/pro/portals/publicMeta.php b/public/api/pro/portals/publicMeta.php index 92e4672..2496b34 100644 --- a/public/api/pro/portals/publicMeta.php +++ b/public/api/pro/portals/publicMeta.php @@ -108,6 +108,15 @@ if (!empty($portal['expiresAt'])) { } // Only expose the bits the login page needs (no folder, email, etc.) +$logoFile = (string)($portal['logoFile'] ?? ''); +$logoUrl = (string)($portal['logoUrl'] ?? ''); +if ($logoUrl !== '') { + $logoUrl = fr_normalize_profile_pic_url($logoUrl); +} +if ($logoUrl === '' && $logoFile !== '') { + $logoUrl = fr_profile_pic_url($logoFile); +} + $public = [ 'slug' => $slug, 'label' => (string)($portal['label'] ?? ''), @@ -115,7 +124,8 @@ $public = [ 'introText' => (string)($portal['introText'] ?? ''), 'brandColor' => (string)($portal['brandColor'] ?? ''), 'footerText' => (string)($portal['footerText'] ?? ''), - 'logoFile' => (string)($portal['logoFile'] ?? ''), + 'logoFile' => $logoFile, + 'logoUrl' => $logoUrl, ]; echo json_encode([ diff --git a/public/api/profile/getCurrentUser.php b/public/api/profile/getCurrentUser.php index 5656c4d..3b2f5cf 100644 --- a/public/api/profile/getCurrentUser.php +++ b/public/api/profile/getCurrentUser.php @@ -16,7 +16,7 @@ * @OA\Property(property="username", type="string", example="ryan"), * @OA\Property(property="isAdmin", type="boolean"), * @OA\Property(property="totp_enabled", type="boolean"), - * @OA\Property(property="profile_picture", type="string", example="/uploads/profile_pics/ryan.png") + * @OA\Property(property="profile_picture", type="string", example="/api/public/profilePic.php?file=ryan.png") * ) * ), * @OA\Response(response=401, ref="#/components/responses/Unauthorized") diff --git a/public/api/profile/uploadPicture.php b/public/api/profile/uploadPicture.php index 12304c5..db6a075 100644 --- a/public/api/profile/uploadPicture.php +++ b/public/api/profile/uploadPicture.php @@ -43,7 +43,7 @@ require_once PROJECT_ROOT . '/src/controllers/UserController.php'; * type="object", * required={"success","url"}, * @OA\Property(property="success", type="boolean", example=true), - * @OA\Property(property="url", type="string", example="/uploads/profile_pics/alice_9f3c2e1a8bcd.png") + * @OA\Property(property="url", type="string", example="/api/public/profilePic.php?file=alice_9f3c2e1a8bcd.png") * ) * ), * @OA\Response(response=400, description="No file uploaded, invalid file type, or file too large."), @@ -65,4 +65,4 @@ try { 'success' => false, 'error' => 'Exception: ' . $e->getMessage() ]); -} \ No newline at end of file +} diff --git a/public/api/public/profilePic.php b/public/api/public/profilePic.php new file mode 100644 index 0000000..1ae783f --- /dev/null +++ b/public/api/public/profilePic.php @@ -0,0 +1,72 @@ + 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', +]; +$finfo = finfo_open(FILEINFO_MIME_TYPE); +$mime = $finfo ? finfo_file($finfo, $real) : ''; +if ($finfo) finfo_close($finfo); +if (!isset($allowed[$mime])) { + http_response_code(404); + exit('Not found'); +} + +if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); +} + +$size = @filesize($real); +$mtime = @filemtime($real); + +header('Content-Type: ' . $mime); +header('X-Content-Type-Options: nosniff'); +header('Cache-Control: public, max-age=86400'); +header('Content-Disposition: inline; filename="' . rawurlencode($name) . '"'); +if ($size !== false) { + header('Content-Length: ' . $size); +} +if ($mtime !== false) { + header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT'); +} + +readfile($real); +exit; diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js index ad5ee4d..53fff0e 100644 --- a/public/js/adminPanel.js +++ b/public/js/adminPanel.js @@ -403,6 +403,13 @@ function updateHeaderLogoFromAdmin() { url = '/' + url; } + const legacyMatch = url.match(/\/uploads\/profile_pics\/([^?#]+)/); + if (legacyMatch && legacyMatch[1]) { + let legacyName = legacyMatch[1]; + try { legacyName = decodeURIComponent(legacyName); } catch (e) {} + url = `/api/public/profilePic.php?file=${encodeURIComponent(legacyName)}`; + } + // Strip any CR/LF just in case url = url.replace(/[\r\n]+/g, ''); diff --git a/public/js/fileListView.js b/public/js/fileListView.js index a00492c..2cc1690 100644 --- a/public/js/fileListView.js +++ b/public/js/fileListView.js @@ -3856,6 +3856,14 @@ function _isHexColor(value) { return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(value || '').trim()); } +function sanitizeTagColor(value) { + const raw = String(value || '').trim(); + if (!raw) return '#777777'; + if (_isHexColor(raw)) return raw; + if (/^[a-zA-Z]{1,32}$/.test(raw)) return raw; + return '#777777'; +} + function _resolveFolderColors(hostEl, fullPath) { const frontVar = String(hostEl?.style?.getPropertyValue('--filr-folder-front') || '').trim(); const backVar = String(hostEl?.style?.getPropertyValue('--filr-folder-back') || '').trim(); @@ -6944,7 +6952,8 @@ const subfoldersSorted = await sortSubfoldersForCurrentOrder(allSubfolders); if (file.tags && file.tags.length > 0) { tagBadgesHTML = '
'; file.tags.forEach(tag => { - tagBadgesHTML += `${escapeHTML(tag.name)}`; + const safeColor = sanitizeTagColor(tag.color); + tagBadgesHTML += `${escapeHTML(tag.name)}`; }); tagBadgesHTML += "
"; } @@ -7494,7 +7503,8 @@ export function renderGalleryView(folder, container) { if (file.tags && file.tags.length) { tagBadgesHTML = `
`; file.tags.forEach(tag => { - tagBadgesHTML += ` { const badge = document.createElement('span'); badge.textContent = tag.name; - badge.style.backgroundColor = tag.color || '#444'; + badge.style.backgroundColor = sanitizeTagColor(tag.color); badge.style.color = '#fff'; badge.style.padding = '2px 6px'; badge.style.borderRadius = '999px'; diff --git a/public/js/fileTags.js b/public/js/fileTags.js index 8b842ab..110133c 100644 --- a/public/js/fileTags.js +++ b/public/js/fileTags.js @@ -7,12 +7,80 @@ import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QV let __singleInit = false; let __multiInit = false; let currentFile = null; +let currentTagSourceId = ''; + +const DEFAULT_TAG_COLOR = '#777777'; + +function sanitizeTagColor(value) { + const raw = String(value || '').trim(); + if (!raw) return DEFAULT_TAG_COLOR; + if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(raw)) return raw; + if (/^[a-zA-Z]{1,32}$/.test(raw)) return raw; + return DEFAULT_TAG_COLOR; +} + +function sanitizeTagList(tags) { + if (!Array.isArray(tags)) return []; + const clean = []; + tags.forEach(tag => { + if (!tag || typeof tag !== 'object') return; + const name = String(tag.name || '').trim(); + if (!name) return; + clean.push({ ...tag, name, color: sanitizeTagColor(tag.color) }); + }); + return clean; +} + +function getActiveSourceId() { + try { + if (typeof window.__frGetActiveSourceId === 'function') { + const v = window.__frGetActiveSourceId(); + if (v) return String(v).trim(); + } + } catch (e) { /* ignore */ } + try { + const stored = localStorage.getItem('fr_active_source'); + if (stored) return String(stored).trim(); + } catch (e) { /* ignore */ } + const sel = document.getElementById('sourceSelector'); + if (sel && sel.value) return String(sel.value).trim(); + return ''; +} + +function resolveTagSourceId(sourceId = '') { + const sid = String(sourceId || '').trim(); + if (sid) return sid; + if (currentTagSourceId) return currentTagSourceId; + return getActiveSourceId(); +} + +function getTagStorageKey(sourceId = '') { + const sid = String(sourceId || '').trim(); + return sid ? `globalTags.${sid}` : 'globalTags'; +} + +function loadStoredTags(sourceId = '') { + const key = getTagStorageKey(sourceId); + try { + const raw = localStorage.getItem(key); + if (raw) return sanitizeTagList(JSON.parse(raw)); + if (key !== 'globalTags') { + const legacy = localStorage.getItem('globalTags'); + if (legacy) return sanitizeTagList(JSON.parse(legacy)); + } + } catch (e) { /* ignore */ } + return []; +} + +function persistStoredTags(tags, sourceId = '') { + const key = getTagStorageKey(sourceId); + try { localStorage.setItem(key, JSON.stringify(tags)); } catch (e) { /* ignore */ } +} // Global store (preserve existing behavior) window.globalTags = window.globalTags || []; -if (localStorage.getItem('globalTags')) { - try { window.globalTags = JSON.parse(localStorage.getItem('globalTags')); } catch (e) {} -} +currentTagSourceId = resolveTagSourceId(); +window.globalTags = loadStoredTags(currentTagSourceId); // -------------------- ensure DOM (create-once-if-missing) -------------------- function ensureSingleTagModal() { @@ -108,7 +176,7 @@ function initSingleModalOnce() { // Save handler saveBtn?.addEventListener('click', () => { const tagName = (document.getElementById('tagNameInput')?.value || '').trim(); - const tagColor = document.getElementById('tagColorInput')?.value || '#ff0000'; + const tagColor = sanitizeTagColor(document.getElementById('tagColorInput')?.value || '#ff0000'); if (!tagName) { alert(t('enter_tag_name') || 'Please enter a tag name.'); return; } if (!currentFile) return; @@ -145,7 +213,7 @@ function initMultiModalOnce() { saveBtn?.addEventListener('click', () => { const tagName = (document.getElementById('multiTagNameInput')?.value || '').trim(); - const tagColor = document.getElementById('multiTagColorInput')?.value || '#ff0000'; + const tagColor = sanitizeTagColor(document.getElementById('multiTagColorInput')?.value || '#ff0000'); if (!tagName) { alert(t('enter_tag_name') || 'Please enter a tag name.'); return; } const files = (window.__multiTagFiles || []); @@ -170,7 +238,10 @@ export function openTagModal(file) { const title = document.getElementById('tagModalTitle'); currentFile = file || null; - if (title) title.textContent = `${t('tag_file')}: ${file ? escapeHTML(file.name) : ''}`; + if (title) title.textContent = `${t('tag_file')}: ${file ? String(file.name || '') : ''}`; + const sourceId = resolveTagSourceId(file?.sourceId); + if (sourceId) currentTagSourceId = sourceId; + loadGlobalTags(sourceId); updateCustomTagDropdown(''); updateTagModalDisplay(file); modal.style.display = 'block'; @@ -187,6 +258,9 @@ export function openMultiTagModal(files) { const title = document.getElementById('multiTagTitle'); window.__multiTagFiles = Array.isArray(files) ? files : []; if (title) title.textContent = `${t('tag_selected') || 'Tag Selected'} (${window.__multiTagFiles.length})`; + const sourceId = resolveTagSourceId(window.__multiTagFiles[0]?.sourceId); + if (sourceId) currentTagSourceId = sourceId; + loadGlobalTags(sourceId); updateMultiCustomTagDropdown(''); modal.style.display = 'block'; } @@ -205,12 +279,13 @@ function updateMultiCustomTagDropdown(filterText = "") { if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase())); if (tags.length > 0) { tags.forEach(tag => { + const safeColor = sanitizeTagColor(tag.color); const item = document.createElement("div"); item.style.cursor = "pointer"; item.style.padding = "5px"; item.style.borderBottom = "1px solid #eee"; item.innerHTML = ` - + ${escapeHTML(tag.name)} × `; @@ -219,7 +294,7 @@ function updateMultiCustomTagDropdown(filterText = "") { const n = document.getElementById("multiTagNameInput"); const c = document.getElementById("multiTagColorInput"); if (n) n.value = tag.name; - if (c) c.value = tag.color; + if (c) c.value = safeColor; }); item.querySelector('.global-remove').addEventListener("click", function(e){ e.stopPropagation(); @@ -240,12 +315,13 @@ function updateCustomTagDropdown(filterText = "") { if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase())); if (tags.length > 0) { tags.forEach(tag => { + const safeColor = sanitizeTagColor(tag.color); const item = document.createElement("div"); item.style.cursor = "pointer"; item.style.padding = "5px"; item.style.borderBottom = "1px solid #eee"; item.innerHTML = ` - + ${escapeHTML(tag.name)} × `; @@ -254,7 +330,7 @@ function updateCustomTagDropdown(filterText = "") { const n = document.getElementById("tagNameInput"); const c = document.getElementById("tagColorInput"); if (n) n.value = tag.name; - if (c) c.value = tag.color; + if (c) c.value = safeColor; }); item.querySelector('.global-remove').addEventListener("click", function(e){ e.stopPropagation(); @@ -276,7 +352,7 @@ function updateTagModalDisplay(file) { file.tags.forEach(tag => { const tagElem = document.createElement('span'); tagElem.textContent = tag.name; - tagElem.style.backgroundColor = tag.color; + tagElem.style.backgroundColor = sanitizeTagColor(tag.color); tagElem.style.color = '#fff'; tagElem.style.padding = '2px 6px'; tagElem.style.marginRight = '5px'; @@ -308,25 +384,34 @@ function removeTagFromFile(file, tagName) { } function removeGlobalTag(tagName) { + const sourceId = resolveTagSourceId(); window.globalTags = (window.globalTags || []).filter(t => t.name.toLowerCase() !== tagName.toLowerCase()); - localStorage.setItem('globalTags', JSON.stringify(window.globalTags)); + persistStoredTags(window.globalTags, sourceId); updateCustomTagDropdown(); updateMultiCustomTagDropdown(); saveGlobalTagRemoval(tagName); } function saveGlobalTagRemoval(tagName) { + const sourceId = resolveTagSourceId(); fetch("/api/file/saveFileTag.php", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, - body: JSON.stringify({ folder: "root", file: "global", deleteGlobal: true, tagToDelete: tagName, tags: [] }) + body: JSON.stringify({ + folder: "root", + file: "global", + deleteGlobal: true, + tagToDelete: tagName, + tags: [], + ...(sourceId ? { sourceId } : {}) + }) }) .then(r => r.json()) .then(data => { if (data.success && data.globalTags) { - window.globalTags = data.globalTags; - localStorage.setItem('globalTags', JSON.stringify(window.globalTags)); + window.globalTags = sanitizeTagList(data.globalTags); + persistStoredTags(window.globalTags, sourceId); updateCustomTagDropdown(); updateMultiCustomTagDropdown(); } else if (!data.success) { @@ -337,33 +422,50 @@ function saveGlobalTagRemoval(tagName) { } // -------------------- exports kept from your original -------------------- -export function loadGlobalTags() { - fetch("/api/file/getFileTag.php", { credentials: "include" }) +export function loadGlobalTags(sourceId = '') { + const resolvedSourceId = resolveTagSourceId(sourceId); + const url = resolvedSourceId + ? `/api/file/getFileTag.php?sourceId=${encodeURIComponent(resolvedSourceId)}` + : "/api/file/getFileTag.php"; + if (resolvedSourceId) currentTagSourceId = resolvedSourceId; + fetch(url, { credentials: "include" }) .then(r => r.ok ? r.json() : []) .then(data => { - window.globalTags = data || []; - localStorage.setItem('globalTags', JSON.stringify(window.globalTags)); + window.globalTags = sanitizeTagList(data || []); + persistStoredTags(window.globalTags, resolvedSourceId); updateCustomTagDropdown(); updateMultiCustomTagDropdown(); }) .catch(err => { console.error("Error loading global tags:", err); - window.globalTags = []; + window.globalTags = loadStoredTags(resolvedSourceId); updateCustomTagDropdown(); updateMultiCustomTagDropdown(); }); } loadGlobalTags(); +try { + window.addEventListener('filerise:source-change', (e) => { + const nextId = String(e?.detail?.id || '').trim(); + if (nextId) currentTagSourceId = nextId; + loadGlobalTags(nextId); + }); +} catch (e) { /* ignore */ } + export function addTagToFile(file, tag) { if (!file.tags) file.tags = []; - const exists = file.tags.find(tg => tg.name.toLowerCase() === tag.name.toLowerCase()); - if (exists) exists.color = tag.color; else file.tags.push(tag); + const safeName = String(tag.name || '').trim(); + if (!safeName) return; + const safeColor = sanitizeTagColor(tag.color); + const sourceId = resolveTagSourceId(file?.sourceId); + const exists = file.tags.find(tg => tg.name.toLowerCase() === safeName.toLowerCase()); + if (exists) exists.color = safeColor; else file.tags.push({ ...tag, name: safeName, color: safeColor }); - const globalExists = (window.globalTags || []).find(tg => tg.name.toLowerCase() === tag.name.toLowerCase()); + const globalExists = (window.globalTags || []).find(tg => tg.name.toLowerCase() === safeName.toLowerCase()); if (!globalExists) { - window.globalTags.push(tag); - localStorage.setItem('globalTags', JSON.stringify(window.globalTags)); + window.globalTags.push({ ...tag, name: safeName, color: safeColor }); + persistStoredTags(window.globalTags, sourceId); } } @@ -384,7 +486,7 @@ export function updateFileRowTagDisplay(file) { (file.tags || []).forEach(tag => { const badge = document.createElement('span'); badge.textContent = tag.name; - badge.style.backgroundColor = tag.color; + badge.style.backgroundColor = sanitizeTagColor(tag.color); badge.style.color = '#fff'; badge.style.padding = '2px 4px'; badge.style.marginRight = '2px'; @@ -417,7 +519,9 @@ export function initTagSearch() { export function filterFilesByTag(files) { const q = (window.currentTagFilter || '').trim().toLowerCase(); if (!q) return files; - return files.filter(file => (file.tags || []).some(tag => tag.name.toLowerCase().includes(q))); + return files.filter(file => + (file.tags || []).some(tag => String(tag?.name || '').toLowerCase().includes(q)) + ); } function updateGlobalTagList() { @@ -433,13 +537,16 @@ function updateGlobalTagList() { export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) { const folder = file.folder || "root"; + const sourceId = resolveTagSourceId(file.sourceId); + const safeTags = sanitizeTagList(file.tags || []); const payload = deleteGlobal && tagToDelete ? { folder: "root", file: "global", deleteGlobal: true, tagToDelete, - tags: [] - } : { folder, file: file.name, tags: file.tags }; + tags: [], + ...(sourceId ? { sourceId } : {}) + } : { folder, file: file.name, tags: safeTags, ...(sourceId ? { sourceId } : {}) }; fetch("/api/file/saveFileTag.php", { method: "POST", @@ -451,8 +558,8 @@ export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) { .then(data => { if (data.success) { if (data.globalTags) { - window.globalTags = data.globalTags; - localStorage.setItem('globalTags', JSON.stringify(window.globalTags)); + window.globalTags = sanitizeTagList(data.globalTags); + persistStoredTags(window.globalTags, sourceId); updateCustomTagDropdown(); updateMultiCustomTagDropdown(); } @@ -462,4 +569,4 @@ export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) { } }) .catch(err => console.error("Error saving tags:", err)); -} \ No newline at end of file +} diff --git a/public/js/main.js b/public/js/main.js index 8882e0f..14f5022 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1104,11 +1104,14 @@ function bindDarkMode() { // --- Header logo (branding) in BOTH phases --- try { - const customLogoUrl = branding.customLogoUrl || ""; + const customLogoUrl = String(branding.customLogoUrl || "").trim(); + const resolvedLogoUrl = customLogoUrl && customLogoUrl.startsWith('/') && !customLogoUrl.startsWith('//') + ? withBase(customLogoUrl) + : customLogoUrl; const logoImg = document.querySelector('.header-logo img'); if (logoImg) { - if (customLogoUrl) { - logoImg.setAttribute('src', customLogoUrl); + if (resolvedLogoUrl) { + logoImg.setAttribute('src', resolvedLogoUrl); logoImg.setAttribute('alt', 'Site logo'); } else { // fall back to default FileRise logo diff --git a/public/js/portal-login.js b/public/js/portal-login.js index cf9430c..2062be0 100644 --- a/public/js/portal-login.js +++ b/public/js/portal-login.js @@ -299,23 +299,36 @@ function getRedirectTarget() { } } - // 🔹 Portal logo: use logoFile from metadata if present - if (logoEl) { - let logoSrc = null; + // 🔹 Portal logo: use logoFile from metadata if present + if (logoEl) { + const buildPortalLogoUrl = (fileName) => + fileName ? `/api/public/profilePic.php?file=${encodeURIComponent(fileName)}` : ''; + + let logoSrc = ''; // If you ever decide to store a direct URL: if (portal.logoUrl && portal.logoUrl.trim()) { logoSrc = portal.logoUrl.trim(); } else if (portal.logoFile && portal.logoFile.trim()) { - // Same convention as portal.html: files live in uploads/profile_pics - logoSrc = withBase('/uploads/profile_pics/' + encodeURIComponent(portal.logoFile.trim())); + logoSrc = buildPortalLogoUrl(portal.logoFile.trim()); + } + + const legacyMatch = logoSrc.match(/\/uploads\/profile_pics\/([^?#]+)/); + if (legacyMatch && legacyMatch[1]) { + let legacyName = legacyMatch[1]; + try { legacyName = decodeURIComponent(legacyName); } catch (e) {} + logoSrc = buildPortalLogoUrl(legacyName); + } + + if (logoSrc && logoSrc.startsWith('/')) { + logoSrc = withBase(logoSrc); } if (logoSrc) { logoEl.src = logoSrc; logoEl.alt = title; } - } + } // Document title try { diff --git a/public/js/portal.js b/public/js/portal.js index 069b1d1..d3d509c 100644 --- a/public/js/portal.js +++ b/public/js/portal.js @@ -1366,12 +1366,21 @@ function renderPortalInfo() { } } + const buildPortalLogoUrl = (fileName) => + fileName ? `/api/public/profilePic.php?file=${encodeURIComponent(fileName)}` : ''; + let portalLogoUrl = ''; if (portal.logoUrl && portal.logoUrl.trim()) { portalLogoUrl = portal.logoUrl.trim(); } else if (portal.logoFile && portal.logoFile.trim()) { - // Fallback if backend only supplies logoFile - portalLogoUrl = '/uploads/profile_pics/' + encodeURIComponent(portal.logoFile.trim()); + portalLogoUrl = buildPortalLogoUrl(portal.logoFile.trim()); + } + + const legacyMatch = portalLogoUrl.match(/\/uploads\/profile_pics\/([^?#]+)/); + if (legacyMatch && legacyMatch[1]) { + let legacyName = legacyMatch[1]; + try { legacyName = decodeURIComponent(legacyName); } catch (e) {} + portalLogoUrl = buildPortalLogoUrl(legacyName); } if (logoImg && portalLogoUrl) { diff --git a/public/js/shareBranding.js b/public/js/shareBranding.js index f51ad39..0015b43 100644 --- a/public/js/shareBranding.js +++ b/public/js/shareBranding.js @@ -227,7 +227,7 @@ function applyBranding(cfg) { if (!cfg || !cfg.pro || !cfg.pro.active) return; const branding = cfg.branding || {}; - const logoUrl = (branding.customLogoUrl || '').trim(); + const logoUrl = withBaseIfRelative(branding.customLogoUrl || ''); const accent = (branding.headerBgLight || '').trim(); const accentDark = (branding.headerBgDark || '').trim(); const footerHtml = (branding.footerHtml || '').trim(); diff --git a/src/controllers/FileController.php b/src/controllers/FileController.php index 4241990..2ecac2d 100644 --- a/src/controllers/FileController.php +++ b/src/controllers/FileController.php @@ -3993,9 +3993,49 @@ class FileController public function getFileTags(): void { header('Content-Type: application/json; charset=utf-8'); - $tags = FileModel::getFileTags(); - echo json_encode($tags); - exit; + if (!$this->_requireAuth()) { + return; + } + $sourceId = ''; + $allowDisabled = false; + if (class_exists('SourceContext') && SourceContext::sourcesEnabled()) { + $rawSourceId = trim((string)($_GET['sourceId'] ?? '')); + if ($rawSourceId !== '') { + $sourceId = $this->normalizeSourceId($rawSourceId); + if ($sourceId === '') { + http_response_code(400); + echo json_encode(["error" => "Invalid source id."]); + exit; + } + $info = SourceContext::getSourceById($sourceId); + if (!$info) { + http_response_code(400); + echo json_encode(["error" => "Invalid source."]); + exit; + } + $username = $_SESSION['username'] ?? ''; + $perms = $username !== '' ? $this->loadPerms($username) : []; + $allowDisabled = $this->isAdmin($perms); + if (!$allowDisabled && empty($info['enabled'])) { + http_response_code(403); + echo json_encode(["error" => "Source is disabled."]); + exit; + } + } + } + + $runner = function () { + $tags = FileModel::getFileTags(); + echo json_encode($tags); + exit; + }; + + if ($sourceId !== '') { + $this->withSourceContext($sourceId, $runner, $allowDisabled); + return; + } + + $runner(); } public function saveFileTag(): void @@ -4029,34 +4069,66 @@ class FileController $username = $_SESSION['username'] ?? ''; $userPermissions = $this->loadPerms($username); - // Need write (or ancestor-owner) - if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) { - $this->_jsonOut(["error" => "Forbidden: no full write access"], 403); - return; - } - - // Folder scope: write - $dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write'); - if ($dv) { - $this->_jsonOut(["error" => $dv], 403); - return; - } - - // Ownership unless admin/folder-owner - $ignoreOwnership = $this->isAdmin($userPermissions) - || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)) - || ACL::isOwner($username, $userPermissions, $folder) - || $this->ownsFolderOrAncestor($folder, $username, $userPermissions); - if (!$ignoreOwnership) { - $meta = $this->loadFolderMetadata($folder); - if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) { - $this->_jsonOut(["error" => "Forbidden: you are not the owner of this file."], 403); - return; + $sourceId = ''; + $allowDisabled = false; + if (class_exists('SourceContext') && SourceContext::sourcesEnabled()) { + $rawSourceId = trim((string)($data['sourceId'] ?? '')); + if ($rawSourceId !== '') { + $sourceId = $this->normalizeSourceId($rawSourceId); + if ($sourceId === '') { + $this->_jsonOut(["error" => "Invalid source id."], 400); + return; + } + $info = SourceContext::getSourceById($sourceId); + if (!$info) { + $this->_jsonOut(["error" => "Invalid source."], 400); + return; + } + $allowDisabled = $this->isAdmin($userPermissions); + if (!$allowDisabled && empty($info['enabled'])) { + $this->_jsonOut(["error" => "Source is disabled."], 403); + return; + } } } - $result = FileModel::saveFileTag($folder, $file, $tags, $deleteGlobal, $tagToDelete); - $this->_jsonOut($result); + $runner = function () use ($file, $folder, $tags, $deleteGlobal, $tagToDelete, $username, $userPermissions) { + // Need write (or ancestor-owner) + if (!(ACL::canWrite($username, $userPermissions, $folder) || $this->ownsFolderOrAncestor($folder, $username, $userPermissions))) { + $this->_jsonOut(["error" => "Forbidden: no full write access"], 403); + return; + } + + // Folder scope: write + $dv = $this->enforceFolderScope($folder, $username, $userPermissions, 'write'); + if ($dv) { + $this->_jsonOut(["error" => $dv], 403); + return; + } + + // Ownership unless admin/folder-owner + $ignoreOwnership = $this->isAdmin($userPermissions) + || ($userPermissions['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false)) + || ACL::isOwner($username, $userPermissions, $folder) + || $this->ownsFolderOrAncestor($folder, $username, $userPermissions); + if (!$ignoreOwnership) { + $meta = $this->loadFolderMetadata($folder); + if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) { + $this->_jsonOut(["error" => "Forbidden: you are not the owner of this file."], 403); + return; + } + } + + $result = FileModel::saveFileTag($folder, $file, $tags, $deleteGlobal, $tagToDelete); + $this->_jsonOut($result); + }; + + if ($sourceId !== '') { + $this->withSourceContext($sourceId, $runner, $allowDisabled); + return; + } + + $runner(); } catch (Throwable $e) { error_log('FileController::saveFileTag error: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine()); $this->_jsonOut(['error' => 'Internal server error while saving tags.'], 500); diff --git a/src/controllers/PortalController.php b/src/controllers/PortalController.php index dc3dc3a..d605084 100644 --- a/src/controllers/PortalController.php +++ b/src/controllers/PortalController.php @@ -217,6 +217,12 @@ final class PortalController // Optional per-portal logo $logoFile = trim((string)($p['logoFile'] ?? '')); $logoUrl = trim((string)($p['logoUrl'] ?? '')); + if ($logoUrl !== '') { + $logoUrl = fr_normalize_profile_pic_url($logoUrl); + } + if ($logoUrl === '' && $logoFile !== '') { + $logoUrl = fr_profile_pic_url($logoFile); + } // Upload rules / thank-you behavior $uploadMaxSizeMb = isset($p['uploadMaxSizeMb']) ? (int)$p['uploadMaxSizeMb'] : 0; diff --git a/src/controllers/UserController.php b/src/controllers/UserController.php index 1e3019d..b6fae3e 100644 --- a/src/controllers/UserController.php +++ b/src/controllers/UserController.php @@ -762,15 +762,15 @@ public function adminChangeUserPassword() exit; } - $fsPath = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics/' . $filename; - - // Remove the filesystem root (PROJECT_ROOT) so we get a web-relative path - $root = rtrim(PROJECT_ROOT, '/\\'); - $url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath); - - // Ensure it starts with / - if ($url === '' || $url[0] !== '/') { - $url = '/' . $url; + $url = fr_profile_pic_url($filename); + if ($url === '') { + @unlink($dest); + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => 'Failed to generate profile picture URL' + ]); + exit; } $result = UserModel::setProfilePicture($_SESSION['username'], $url); @@ -843,15 +843,12 @@ public function adminChangeUserPassword() } - $fsPath = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics/' . $filename; - - // Remove the filesystem root (PROJECT_ROOT) so we get a web-relative path - $root = rtrim(PROJECT_ROOT, '/\\'); - $url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath); - - // Ensure it starts with / - if ($url === '' || $url[0] !== '/') { - $url = '/' . $url; + $url = fr_profile_pic_url($filename); + if ($url === '') { + @unlink($dest); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to generate logo URL']); + exit; } echo json_encode(['success' => true, 'url' => $url]); @@ -924,14 +921,12 @@ public function adminChangeUserPassword() exit; } - // Build a web path similar to uploadBrandLogo - $fsPath = $uploadDir . '/' . $filename; - - $root = rtrim(PROJECT_ROOT, '/\\'); - $url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath); - - if ($url === '' || $url[0] !== '/') { - $url = '/' . ltrim($url, '/\\'); + $url = fr_profile_pic_url($filename); + if ($url === '') { + @unlink($dest); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to generate logo URL']); + exit; } echo json_encode([ @@ -1007,6 +1002,21 @@ public function adminChangeUserPassword() } if (!$shouldRegenerate) { + if (isset($data['branding']) && is_array($data['branding'])) { + $keys = [ + 'customLogoUrl', + 'faviconSvg', + 'faviconPng', + 'faviconIco', + 'appleTouchIcon', + 'maskIcon', + ]; + foreach ($keys as $k) { + if (!empty($data['branding'][$k])) { + $data['branding'][$k] = fr_normalize_profile_pic_url((string)$data['branding'][$k]); + } + } + } echo json_encode($data); return; } diff --git a/src/models/AdminModel.php b/src/models/AdminModel.php index 8577389..e92cddb 100644 --- a/src/models/AdminModel.php +++ b/src/models/AdminModel.php @@ -89,7 +89,7 @@ class AdminModel return ($scheme === 'http' || $scheme === 'https') ? $url : ''; } - /** Allow logo URLs that are either site-relative (/uploads/…) or http(s). */ + /** Allow logo URLs that are either site-relative or http(s). */ private static function sanitizeLogoUrl($url): string { $url = trim((string)$url); @@ -100,7 +100,7 @@ class AdminModel $url = '/' . ltrim($url, '/'); } - // 1) Site-relative like "/uploads/profile_pics/branding_foo.png" + // 1) Site-relative (normalize legacy /uploads/profile_pics to the public API) if ($url[0] === '/') { // Strip CRLF just in case $url = preg_replace('~[\r\n]+~', '', $url); @@ -108,7 +108,7 @@ class AdminModel if (strpos($url, '://') !== false) { return ''; } - return $url; + return fr_normalize_profile_pic_url($url); } // 2) Fallback to plain http(s) validation diff --git a/src/models/FileModel.php b/src/models/FileModel.php index cfde2b7..c25d9b2 100644 --- a/src/models/FileModel.php +++ b/src/models/FileModel.php @@ -3084,9 +3084,12 @@ class FileModel { $metadataPath = self::metaRoot() . 'createdTags.json'; - // Check if the metadata file exists and is readable. - if (!file_exists($metadataPath) || !is_readable($metadataPath)) { - error_log('Metadata file does not exist or is not readable: ' . $metadataPath); + // Missing file is normal (especially for new sources); unreadable is worth logging. + if (!file_exists($metadataPath)) { + return []; + } + if (!is_readable($metadataPath)) { + error_log('Metadata file is not readable: ' . $metadataPath); return []; } @@ -3103,7 +3106,62 @@ class FileModel return []; } - return $jsonData; + if (!is_array($jsonData)) { + return []; + } + + return self::sanitizeTags($jsonData); + } + + private static function isValidTagColor($color): bool + { + $color = trim((string)$color); + if ($color === '') { + return false; + } + if (preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $color)) { + return true; + } + if (preg_match('/^[a-zA-Z]{1,32}$/', $color)) { + return true; + } + return false; + } + + private static function sanitizeTagColor($color): string + { + $color = trim((string)$color); + return self::isValidTagColor($color) ? $color : '#777777'; + } + + private static function sanitizeTags(array $tags): array + { + $clean = []; + foreach ($tags as $tag) { + if (is_string($tag)) { + $name = trim($tag); + if ($name === '') { + continue; + } + $clean[] = [ + 'name' => $name, + 'color' => self::sanitizeTagColor('') + ]; + continue; + } + if (!is_array($tag)) { + continue; + } + $name = trim((string)($tag['name'] ?? '')); + if ($name === '') { + continue; + } + $tag['name'] = $name; + $tag['color'] = self::sanitizeTagColor($tag['color'] ?? ''); + $clean[] = $tag; + } + + return $clean; } /** @@ -3128,6 +3186,9 @@ class FileModel return ["error" => "Invalid file name."]; } + $tags = is_array($tags) ? $tags : []; + $tags = self::sanitizeTags($tags); + // Determine the folder metadata file. $metadataFile = (strtolower($folder) === "root") ? self::metaRoot() . "root_metadata.json" @@ -3158,6 +3219,7 @@ class FileModel $globalTags = []; } } + $globalTags = self::sanitizeTags($globalTags); // If deleteGlobal is true and tagToDelete is provided, remove that tag. if ($deleteGlobal && !empty($tagToDelete)) { @@ -3371,7 +3433,7 @@ class FileModel 'size' => $fileSizeFormatted, 'sizeBytes' => $fileSizeBytes, // ← numeric size for frontend logic 'uploader' => $fileUploader, - 'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : [], + 'tags' => [], 'mime' => $mime, 'sourceId' => $activeSourceId, ]; @@ -3401,6 +3463,12 @@ class FileModel } } + $tags = []; + if (isset($metadata[$metaKey]['tags']) && is_array($metadata[$metaKey]['tags'])) { + $tags = self::sanitizeTags($metadata[$metaKey]['tags']); + } + $fileEntry['tags'] = $tags; + $fileList[] = $fileEntry; } @@ -3411,6 +3479,7 @@ class FileModel // Load global tags. $globalTagsFile = self::metaRoot() . "createdTags.json"; $globalTags = file_exists($globalTagsFile) ? (json_decode(file_get_contents($globalTagsFile), true) ?: []) : []; + $globalTags = is_array($globalTags) ? self::sanitizeTags($globalTags) : []; return ["files" => $fileList, "globalTags" => $globalTags, "sourceId" => $activeSourceId]; } diff --git a/src/models/UserModel.php b/src/models/UserModel.php index 0b74a26..9a6a736 100644 --- a/src/models/UserModel.php +++ b/src/models/UserModel.php @@ -923,9 +923,8 @@ public static function adminResetPassword($targetUsername, $newPassword) $totpEnabled = !empty($parts[3]); $pic = isset($parts[4]) ? $parts[4] : ''; - // Normalize to a leading slash (UI expects /uploads/…) - if ($pic !== '' && $pic[0] !== '/') { - $pic = '/' . $pic; + if ($pic !== '') { + $pic = fr_normalize_profile_pic_url($pic); } return [