release(v3.3.0): security hardening (tag color sanitization + restrict direct uploads access)

This commit is contained in:
Ryan
2026-01-31 03:34:28 -05:00
committed by GitHub
parent 137c92054a
commit 5ce824778a
28 changed files with 678 additions and 140 deletions
+32
View File
@@ -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=<filename>`
- 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`
+4 -13
View File
@@ -117,19 +117,10 @@ RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
ExpiresByType application/javascript "access plus 3 hour"
</IfModule>
# Protect uploads directory
Alias /uploads/ /var/www/uploads/
<Directory "/var/www/uploads/">
Options -Indexes
AllowOverride None
<IfModule mod_php7.c>
php_flag engine off
</IfModule>
<IfModule mod_php.c>
php_flag engine off
</IfModule>
Require all granted
</Directory>
# Block direct access to uploads/users/metadata (data must go through the API)
<LocationMatch "^/(uploads|users|metadata)(?:/|$)">
Require all denied
</LocationMatch>
# Public directory
<Directory "/var/www/public">
+1
View File
@@ -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
+39
View File
@@ -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 {
+1 -1
View File
@@ -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`).
---
+22
View File
@@ -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:
```
<LocationMatch "^/(uploads|users|metadata)(?:/|$)">
Require all denied
</LocationMatch>
```
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.
+11 -1
View File
@@ -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;
+37 -6
View File
@@ -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"
+2 -1
View File
@@ -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)")
* )
*/
+12 -2
View File
@@ -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();
$fileController->saveFileTag();
+11 -1
View File
@@ -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([
+1 -1
View File
@@ -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")
+2 -2
View File
@@ -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()
]);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
// public/api/public/profilePic.php
require_once __DIR__ . '/../../../config/config.php';
$raw = $_GET['file'] ?? '';
$name = trim((string)$raw);
if ($name === '') {
http_response_code(400);
exit('Missing file');
}
// Normalize and lock to a bare filename.
$name = str_replace('\\', '/', $name);
$name = basename($name);
if ($name === '' || $name === '.' || $name === '..') {
http_response_code(400);
exit('Invalid file');
}
if (preg_match('~[\\/\\x00]~', $name)) {
http_response_code(400);
exit('Invalid file');
}
$baseDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
$baseReal = realpath($baseDir);
if ($baseReal === false || !is_dir($baseReal)) {
http_response_code(404);
exit('Not found');
}
$path = $baseReal . DIRECTORY_SEPARATOR . $name;
$real = realpath($path);
if ($real === false || strpos($real, $baseReal . DIRECTORY_SEPARATOR) !== 0 || !is_file($real)) {
http_response_code(404);
exit('Not found');
}
// Only serve known safe image types (uploads are already constrained).
$allowed = [
'image/jpeg' => '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;
+7
View File
@@ -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, '');
+12 -2
View File
@@ -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 = '<div class="tag-badges" style="display:inline-block; margin-left:5px;">';
file.tags.forEach(tag => {
tagBadgesHTML += `<span style="background-color: ${tag.color}; color: #fff; padding: 2px 4px; border-radius: 3px; margin-right: 2px; font-size: 0.8em;">${escapeHTML(tag.name)}</span>`;
const safeColor = sanitizeTagColor(tag.color);
tagBadgesHTML += `<span style="background-color: ${safeColor}; color: #fff; padding: 2px 4px; border-radius: 3px; margin-right: 2px; font-size: 0.8em;">${escapeHTML(tag.name)}</span>`;
});
tagBadgesHTML += "</div>";
}
@@ -7494,7 +7503,8 @@ export function renderGalleryView(folder, container) {
if (file.tags && file.tags.length) {
tagBadgesHTML = `<div class="tag-badges" style="margin-top:4px;">`;
file.tags.forEach(tag => {
tagBadgesHTML += `<span style="background-color:${tag.color};
const safeColor = sanitizeTagColor(tag.color);
tagBadgesHTML += `<span style="background-color:${safeColor};
color:#fff;
padding:2px 4px;
border-radius:3px;
+15 -1
View File
@@ -4,6 +4,20 @@ import { t } from './i18n.js?v={{APP_QVER}}';
import { fileData, setFileProgressBadge, setFileWatchedBadge } from './fileListView.js?v={{APP_QVER}}';
import { withBase } from './basePath.js?v={{APP_QVER}}';
const DEFAULT_TAG_COLOR = '#777777';
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 DEFAULT_TAG_COLOR;
if (isHexColor(raw)) return raw;
if (/^[a-zA-Z]{1,32}$/.test(raw)) return raw;
return DEFAULT_TAG_COLOR;
}
function resolvePreviewSourceId(sourceId, folder, name) {
let sid = String(sourceId || '').trim();
if (sid) return sid;
@@ -466,7 +480,7 @@ function setTitle(overlay, name) {
fileObj.tags.forEach(tag => {
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';
+139 -32
View File
@@ -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 = `
<span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
<span style="display:inline-block; width:16px; height:16px; background-color:${safeColor}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
${escapeHTML(tag.name)}
<span class="global-remove" style="color:red; font-weight:bold; margin-left:5px; cursor:pointer;">×</span>
`;
@@ -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 = `
<span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
<span style="display:inline-block; width:16px; height:16px; background-color:${safeColor}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
${escapeHTML(tag.name)}
<span class="global-remove" style="color:red; font-weight:bold; margin-left:5px; cursor:pointer;">×</span>
`;
@@ -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));
}
}
+6 -3
View File
@@ -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
+19 -6
View File
@@ -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 {
+11 -2
View File
@@ -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) {
+1 -1
View File
@@ -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();
+100 -28
View File
@@ -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);
+6
View File
@@ -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;
+36 -26
View File
@@ -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;
}
+3 -3
View File
@@ -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
+74 -5
View File
@@ -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];
}
+2 -3
View File
@@ -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 [