mirror of
https://github.com/error311/FileRise.git
synced 2026-05-24 22:49:29 -05:00
release(v3.3.0): security hardening (tag color sanitization + restrict direct uploads access)
This commit is contained in:
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)")
|
||||
* )
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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, '');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user